From b0d79dd9978a3a208956255f90a12ded880daf8b Mon Sep 17 00:00:00 2001 From: Fernando Dingler Date: Wed, 21 Oct 2026 04:57:17 +0000 Subject: [PATCH] feat: add the builtin builtin `builtin NAME [args...]` runs the shell builtin NAME and skips a shell function of the same name. A name that is not a builtin fails with status 1 and a "not a shell builtin" message, and `builtin` with no arguments returns 0. The prefix is dropped before the control-flow builtins that exec.rs handles itself (exit, return, eval, source, ...), so they keep their effect on the shell. --- src/builtins/builtin.rs | 27 ++++++++++++++++++++++ src/builtins/mod.rs | 2 ++ src/exec.rs | 28 +++++++++++++++++++++++ tests/shell_integration.rs | 47 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 src/builtins/builtin.rs diff --git a/src/builtins/builtin.rs b/src/builtins/builtin.rs new file mode 100644 index 0000000..17d222f --- /dev/null +++ b/src/builtins/builtin.rs @@ -0,0 +1,27 @@ +use std::future::Future; +use std::pin::Pin; + +use crate::commands::CommandResult; +use crate::os::{Kernel, Process}; + +/// Run the named builtin, bypassing any shell function of the same name. +pub fn builtin_builtin<'a>( + os: &'a dyn Kernel, + proc: &'a mut Process, + args: &'a [String], +) -> Pin + 'a>> { + Box::pin(async move { + let Some(name) = args.first() else { + return Ok(0); + }; + match super::lookup(name) { + Some(f) => f(os, proc, &args[1..]).await, + None => { + proc.err_msg(&format!( + "strands-shell: builtin: {name}: not a shell builtin" + )); + Ok(1) + } + } + }) +} diff --git a/src/builtins/mod.rs b/src/builtins/mod.rs index 9345d89..43c5250 100644 --- a/src/builtins/mod.rs +++ b/src/builtins/mod.rs @@ -1,4 +1,5 @@ mod alias; +mod builtin; mod cd; mod colon; mod echo; @@ -37,6 +38,7 @@ pub fn lookup(name: &str) -> Option { match name { ":" | "true" => Some(colon::builtin_colon), "alias" => Some(alias::builtin_alias), + "builtin" => Some(builtin::builtin_builtin), "cd" => Some(cd::builtin_cd), "echo" => Some(echo::builtin_echo), "export" => Some(export::builtin_export), diff --git a/src/exec.rs b/src/exec.rs index 1c9a853..c49ca12 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -2224,6 +2224,34 @@ async fn execute_pipeline_checked( if pipeline.len() == 1 && !pipeline[0].args.is_empty() { let name_word = &pipeline[0].args[0]; if let Some(name) = parser::word_to_str(name_word) { + // `builtin NAME` for a builtin handled below drops the prefix and runs NAME. + if name == "builtin" + && pipeline[0] + .args + .get(1) + .and_then(parser::word_to_str) + .is_some_and(|inner| { + matches!( + inner.as_str(), + "break" + | "continue" + | "exit" + | "return" + | "eval" + | "." + | "source" + | "exec" + | "command" + ) + }) + { + let unprefixed = parser::Command { + env: pipeline[0].env.clone(), + args: pipeline[0].args[1..].to_vec(), + redirects: pipeline[0].redirects.clone(), + }; + return Box::pin(execute_pipeline_checked(os, proc, &[unprefixed])).await; + } match name.as_str() { "break" => { let n = get_numeric_arg(os.clone(), proc, pipeline).await; diff --git a/tests/shell_integration.rs b/tests/shell_integration.rs index 24739e4..23c38ce 100644 --- a/tests/shell_integration.rs +++ b/tests/shell_integration.rs @@ -1512,6 +1512,53 @@ expect!( "1" ); +// ── builtin builtin ───────────────────────────────────────────────── + +expect!(builtin_runs_echo, "builtin echo hello", "hello"); +expect!(builtin_passes_args, "builtin printf '%s-%s\\n' a b", "a-b"); +expect!(builtin_keeps_status, "builtin false; echo $?", "1"); +expect!(builtin_no_args, "builtin", ""); +expect!( + builtin_bypasses_function, + "echo() { printf 'shadowed\\n'; }; builtin echo real", + "real" +); +expect!( + builtin_in_wrapper_function, + "go() { builtin cd \"$@\"; }; go /tmp; pwd", + "/tmp" +); +expect!( + builtin_special_return, + "f() { builtin return 4; echo unreachable; }; f; echo $?", + "4" +); +expect_status!(builtin_special_exit, "builtin exit 3; echo unreachable", 3); + +shell_test!( + builtin_rejects_command, + "builtin cat /dev/null", + |_shell: &mut Shell, out: strands_shell::Output| { + assert_eq!(out.status, 1); + assert_eq!( + out.stderr.trim(), + "strands-shell: builtin: cat: not a shell builtin" + ); + } +); + +shell_test!( + builtin_rejects_unknown, + "builtin nosuch_xyz", + |_shell: &mut Shell, out: strands_shell::Output| { + assert_eq!(out.status, 1); + assert_eq!( + out.stderr.trim(), + "strands-shell: builtin: nosuch_xyz: not a shell builtin" + ); + } +); + // ── Redirections: read-write, dup, close ──────────────────────────── expect!(