Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 42 additions & 7 deletions crates/bashkit/src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>(), false),
None => (String::new(), Vec::new(), true),
}
}
Err(err) => {
self.discard_deferred_proc_subs_from(deferred_proc_sub_start);
return Err(err);
}
}
};

Expand All @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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
};
Expand Down
121 changes: 121 additions & 0 deletions crates/bashkit/tests/spec_cases/bash/command-word-expansion.test.sh
Original file line number Diff line number Diff line change
@@ -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
Loading