diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index cd2c13e29..1f69846f0 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -5763,11 +5763,37 @@ impl Interpreter { let deferred_proc_sub_start = self.deferred_proc_subs.len(); let (_debug_stdout, _debug_stderr) = self.run_debug_trap().await; - let name = match self.expand_word(&command.name).await { - Ok(name) => name, - Err(err) => { - self.discard_deferred_proc_subs_from(deferred_proc_sub_start); - return Err(err); + // The command word undergoes the same field splitting as args: + // `"$@"`, `$cmd`, `$(...)` may yield several words (first is the + // name, rest are prepended args) or none at all (no command). + // Pure literals skip the field path (hot path, no splitting). + let start_subst_gen = self.subst_generation; + let name_is_literal = command + .name + .parts + .iter() + .all(|p| matches!(p, WordPart::Literal(_))); + let (name, name_extra_args, name_vanished) = if name_is_literal { + match self.expand_word(&command.name).await { + Ok(name) => (name, Vec::new(), false), + Err(err) => { + self.discard_deferred_proc_subs_from(deferred_proc_sub_start); + return Err(err); + } + } + } else { + match self.expand_word_to_fields(&command.name).await { + Ok(fields) => { + let mut fields = fields.into_iter(); + match fields.next() { + Some(first) => (first, fields.collect::>(), false), + None => (String::new(), Vec::new(), true), + } + } + Err(err) => { + self.discard_deferred_proc_subs_from(deferred_proc_sub_start); + return Err(err); + } } }; @@ -5785,7 +5811,12 @@ impl Interpreter { let pre_expanded_args = if !name.is_empty() { match self.expand_command_args(command).await { - Ok(args) => Some(args), + Ok(args) if name_extra_args.is_empty() => Some(args), + Ok(args) => { + let mut all = name_extra_args; + all.extend(args); + Some(all) + } Err(err) => { self.discard_deferred_proc_subs_from(deferred_proc_sub_start); return Err(err); @@ -5819,7 +5850,7 @@ impl Interpreter { // Empty command handling if name.is_empty() { - if command.name.quoted && command.assignments.is_empty() { + if command.name.quoted && !name_vanished && command.assignments.is_empty() { self.last_exit_code = 127; self.discard_deferred_proc_subs_from(deferred_proc_sub_start); return Ok(ExecResult::err( @@ -5834,6 +5865,10 @@ impl Interpreter { } else if command.assignments.is_empty() && !command.redirects.is_empty() { // Redirect-only null command (`> file`, `< file`). 0 + } else if self.subst_generation == start_subst_gen { + // Name expanded to nothing (`"$@"` with no params, empty + // `$x`) and no command substitution ran: status 0. + 0 } else { self.last_exit_code }; diff --git a/crates/bashkit/tests/spec_cases/bash/command-word-expansion.test.sh b/crates/bashkit/tests/spec_cases/bash/command-word-expansion.test.sh new file mode 100644 index 000000000..0614ce675 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/command-word-expansion.test.sh @@ -0,0 +1,121 @@ +# Command word expansion: the command name undergoes the same field +# splitting as arguments, so "$@", $cmd, $(...) may yield several words +# (first is the name) or none (no command runs). + +### cmdword_quoted_at_in_function +# "$@" as the command word runs the positional params as a command +g() { "$@"; } +g echo hi +### expect +hi +### end + +### cmdword_quoted_at_with_trailing_args +set -- echo a b +"$@" c +### expect +a b c +### end + +### cmdword_braced_at +set -- printf '%s|' x y +"${@}" +echo +### expect +x|y| +### end + +### cmdword_unquoted_at +set -- echo a b +$@ +### expect +a b +### end + +### cmdword_quoted_at_keeps_field_with_space +# Each positional param stays one field: name "echo z" is not found +set -- "echo z" +"$@" 2>/dev/null +echo "rc=$?" +### expect +rc=127 +### end + +### cmdword_array_splat +cmd=(printf '%s-' a b) +"${cmd[@]}" +echo +### expect +a-b- +### end + +### cmdword_unquoted_var_splits +c="echo hi there" +$c +### expect +hi there +### end + +### cmdword_quoted_var_no_split +c="echo hi" +"$c" 2>/dev/null +echo "rc=$?" +### expect +rc=127 +### end + +### cmdword_command_substitution_splits +$(echo echo from sub) +### expect +from sub +### end + +### cmdword_empty_quoted_at_is_no_command +set -- +false +"$@" +echo "rc=$?" +### expect +rc=0 +### end + +### cmdword_empty_unquoted_var_is_no_command +x= +false +$x +echo "rc=$?" +### expect +rc=0 +### end + +### cmdword_empty_first_param_not_found +set -- "" a +"$@" 2>/dev/null +echo "rc=$?" +### expect +rc=127 +### end + +### cmdword_quoted_empty_var_not_found +x= +"$x" 2>/dev/null +echo "rc=$?" +### expect +rc=127 +### end + +### cmdword_at_calls_function +f() { echo "f:$#:$*"; } +set -- f one two +"$@" +### expect +f:2:one two +### end + +### cmdword_at_with_redirect +set -- echo redirected +"$@" > /tmp/cmdword_out +cat /tmp/cmdword_out +### expect +redirected +### end