Skip to content
Open
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
35 changes: 10 additions & 25 deletions src/runtime/shell/states/Cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ pub struct Cmd {
pub(crate) redirection_file: Vec<u8>,
pub(crate) redirection_fd: Option<*mut CowFd>,
pub(crate) exec: Exec,
/// `Some` means the exec has reported (`has_finished`, `on_process_exit`).
pub(crate) exit_code: Option<ExitCode>,
/// Status of a sole `$(...)` argv atom; used only if it expands to no words.
cmd_subst_exit_code: ExitCode,
}

#[derive(Default, strum::IntoStaticStr)]
Expand Down Expand Up @@ -216,6 +219,7 @@ impl Cmd {
redirection_fd: None,
exec: Exec::None,
exit_code: None,
cmd_subst_exit_code: 0,
}))
}

Expand Down Expand Up @@ -342,31 +346,14 @@ impl Cmd {
match interp.as_cmd_mut(this).state {
CmdState::ExpandingArgs { ref mut idx } => {
*idx += 1;
let new_idx = *idx;
// When the sole
// `name_and_args` atom is a `.simple == .cmd_subst`, stash
// `e.out_exit_code` so an empty-argv command consisting
// only of `$(cmd)` propagates `cmd`'s exit code via the
// empty-argv0 branch in `transition_to_exec` (POSIX: "if
// there is no command name, but the command contained a
// command substitution, the command shall complete with
// the exit status of the last command substitution
// performed").
let me = interp.as_cmd_mut(this);
if let [ast::Atom::Simple(ast::SimpleAtom::CmdSubst(_))] =
me.ast_node().name_and_args
{
let n = interp.as_cmd(this).ast_node();
if new_idx == 1
&& n.name_and_args.len() == 1
&& matches!(
n.name_and_args[0],
ast::Atom::Simple(ast::SimpleAtom::CmdSubst(_))
)
{
interp.as_cmd_mut(this).exit_code = Some(out.out_exit_code);
}
me.cmd_subst_exit_code = out.out_exit_code;
}
// `out.bounds` splits the expansion into multiple argv
// words (glob/IFS).
let me = interp.as_cmd_mut(this);
if out.bounds.is_empty() {
// An empty
// expansion that did *not* see a `""` literal pushes
Expand Down Expand Up @@ -416,9 +403,7 @@ impl Cmd {
}
}

// Empty/null argv[0] → exit
// with the exit code from a sole command-substitution (stashed by
// `child_done` from `Expansion::out_exit_code`), else 0.
// Empty/null argv[0] → nothing to run.
let first_arg: Vec<u8> = {
let me = interp.as_cmd(this);
match me.args.first() {
Expand All @@ -427,7 +412,7 @@ impl Cmd {
a[..a.len() - 1].to_vec()
}
_ => {
let exit = me.exit_code.unwrap_or(0);
let exit = me.cmd_subst_exit_code;
let parent = me.base.parent;
return interp.child_done(parent, this, exit);
}
Expand Down
44 changes: 44 additions & 0 deletions test/js/bun/shell/bunshell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,50 @@ describe("bunshell", () => {
TestBuilder.command`FOO="" $FOO`.runAsTest("empty var");
});

// When `$(...)` is the only word of a command and expands to a command
// line, the exit code is that of the command it expanded to, not the
// substitution's (bash: `$(echo ./exit7.sh); echo $?` prints 7).
describe("cmd subst expanding to the whole command", () => {
const exit7 = "console.log('ran'); process.exit(7);";

TestBuilder.command`$(echo ${BUN} exit7.js)`
.file("exit7.js", exit7)
.stdout("ran\n")
.exitCode(7)
.runAsTest("external command");

TestBuilder.command`$(echo ${BUN} exit7.js) > out.txt`
.file("exit7.js", exit7)
.fileEquals("out.txt", "ran\n")
.exitCode(7)
.runAsTest("external command with stdout redirected");

// With both streams redirected nothing is piped back to the shell, so the
// command can only finish through the process exit notification.
TestBuilder.command`$(echo ${BUN} exit7.js) &> out.txt`
.file("exit7.js", exit7)
.fileEquals("out.txt", "ran\n")
.exitCode(7)
.runAsTest("external command with stdout and stderr redirected");

TestBuilder.command`$(echo ${BUN} ok.js; exit 5)`
.file("ok.js", "console.log('ran');")
.stdout("ran\n")
.exitCode(0)
.runAsTest("failing substitution does not override the command's exit code");

TestBuilder.command`$(echo ${BUN} exit7.js) || echo fallback`
.file("exit7.js", exit7)
.stdout("ran\nfallback\n")
.runAsTest("external command's exit code drives ||");

TestBuilder.command`$(echo false)`.exitCode(1).runAsTest("builtin");

TestBuilder.command`$(echo; exit 3)`
.exitCode(3)
.runAsTest("substitution expanding to nothing keeps its own exit code");
});

describe("tilde_expansion", () => {
describe("with paths", async () => {
TestBuilder.command`echo ~/Documents`.stdout(`${process.env.HOME}/Documents\n`).runAsTest("normal");
Expand Down
Loading