diff --git a/docs/runtime/shell.mdx b/docs/runtime/shell.mdx index 4cc6ed402917..40927db29c35 100644 --- a/docs/runtime/shell.mdx +++ b/docs/runtime/shell.mdx @@ -269,6 +269,8 @@ await $` `; ``` +Bun Shell removes the newlines at the end of the output, and any `\r` directly before them. A quoted substitution in a command (`"$(...)"`) keeps the rest of the output. An unquoted substitution also drops trailing spaces, tabs and `\r`. A variable assignment drops them too, also when the substitution is quoted. + Because Bun internally uses the special [`raw`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#raw_strings) property on the input template literal, using the backtick syntax for command substitution doesn't work: diff --git a/src/runtime/shell/builtin/exit.rs b/src/runtime/shell/builtin/exit.rs index cce673241ec4..b2c6432999ef 100644 --- a/src/runtime/shell/builtin/exit.rs +++ b/src/runtime/shell/builtin/exit.rs @@ -55,6 +55,7 @@ impl Exit { } fn parse_exit_code(s: &[u8]) -> Option { - // %256 is bash semantics — keep wrapper fn. - bun_core::fmt::parse_decimal::(s).map(|n| (n % 256) as crate::shell::ExitCode) + // Blanks around the number and %256 are bash semantics — keep wrapper fn. + bun_core::fmt::parse_decimal::(bun_core::strings::trim(s, b" \t")) + .map(|n| (n % 256) as crate::shell::ExitCode) } diff --git a/src/runtime/shell/states/Expansion.rs b/src/runtime/shell/states/Expansion.rs index e82f0af8d51c..a0d2801ace08 100644 --- a/src/runtime/shell/states/Expansion.rs +++ b/src/runtime/shell/states/Expansion.rs @@ -544,6 +544,16 @@ impl Expansion { me.meta_offsets.clear(); } + /// POSIX 2.6.3: a command substitution ends before its trailing newlines. + fn strip_trailing_line_ends(stdout: &mut Vec) { + if stdout.last() == Some(&b'\n') { + // bash keeps a CR before a trailing LF. It goes here so CRLF output gives a clean word. + while matches!(stdout.last(), Some(b'\n' | b'\r')) { + stdout.pop(); + } + } + } + /// Newlines→spaces, trim, then split on whitespace runs into separate /// argv words. fn post_subshell_expansion(me: &mut Expansion, mut stdout: Vec) { @@ -646,11 +656,16 @@ impl Expansion { me.out_exit_code = exit_code; } if no_split { - let mut hi = stdout.len(); - while hi > 0 && matches!(stdout[hi - 1], b' ' | b'\n' | b'\r' | b'\t') { - hi -= 1; + if me.assign_ctx { + // `$X` is never field split, so a value still loses these trailing bytes. + let mut hi = stdout.len(); + while hi > 0 && matches!(stdout[hi - 1], b' ' | b'\n' | b'\r' | b'\t') { + hi -= 1; + } + stdout.truncate(hi); + } else { + Self::strip_trailing_line_ends(&mut stdout); } - stdout.truncate(hi); // A quoted substitution that printed only NUL bytes is one empty word, as in bash. if had_nul && me.cmd_subst_quoted { me.has_quoted_empty = true; diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index 7b305694c2b0..da9b2db12e6f 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -379,6 +379,42 @@ describe("bunshell", () => { .ensureTempDir() .runAsTest("make sure its two separate arguments"); }); + + // POSIX 2.6.3: only the newlines at the end of the output are removed. + describe("quoted substitution keeps trailing whitespace", () => { + TestBuilder.command`echo "[$(echo 'a ')]"`.stdout("[a ]\n").runAsTest("space"); + TestBuilder.command`echo "[$(echo -e 'a\t')]"`.stdout("[a\t]\n").runAsTest("tab"); + TestBuilder.command`echo "[$(echo -n -e 'a\r')]"`.stdout("[a\r]\n").runAsTest("CR with no LF after it"); + TestBuilder.command`echo "a$(echo -n ' ')b"`.stdout("a b\n").runAsTest("only spaces"); + TestBuilder.command`echo "[$(echo a; echo ' ')]"`.stdout("[a\n ]\n").runAsTest("space on the last line"); + TestBuilder.command`echo "$(echo 'a ')$(echo 'b ')c"` + .stdout("a b c\n") + .runAsTest("two substitutions in one word"); + TestBuilder.command`echo "[$(echo "$(echo 'a ')b")]"`.stdout("[a b]\n").runAsTest("nested substitution"); + TestBuilder.command`[[ -n "$(echo -n ' ')" ]] && echo yes`.stdout("yes\n").runAsTest("[[ -n ]] operand"); + + TestBuilder.command`${BUN} run ./code.ts a "$(echo -n ' ')" b` + .ensureTempDir() + .file("code.ts", `console.log(JSON.stringify(process.argv.slice(2)))`) + .stdout('["a"," ","b"]\n') + .runAsTest("output of one space is one argument"); + + // Windows removes a trailing space from a file name. + test.skipIf(isWindows)("redirect target", async () => { + await TestBuilder.command`echo hi > "$(echo 'out ')"`.ensureTempDir().fileEquals("out ", "hi\n").run(); + }); + }); + + // A CR directly before a trailing LF goes with it on every platform. bash keeps that CR. + describe("quoted substitution removes trailing line ends", () => { + TestBuilder.command`echo "[$(echo a; echo; echo)]"`.stdout("[a]\n").runAsTest("every LF"); + TestBuilder.command`echo "[$(echo -n -e 'a\r\n\r\n')]"`.stdout("[a]\n").runAsTest("every CRLF"); + TestBuilder.command`echo "[$(echo -n -e 'a\r\r\n')]"`.stdout("[a]\n").runAsTest("every CR before the LF"); + TestBuilder.command`echo "[$(echo -n -e 'a \r\n')]"`.stdout("[a ]\n").runAsTest("CRLF after a space"); + TestBuilder.command`echo "[$(echo -n -e 'a \r\r\n\r\r\n')]"` + .stdout("[a ]\n") + .runAsTest("two line ends after a space"); + }); }); describe("unicode", () => { @@ -1580,6 +1616,19 @@ describe("deno_task", () => { TestBuilder.command`VAR=$(echo a && echo b) && echo "$VAR"` .stdout("a\nb\n") .runAsTest("assignment keeps command substitution newlines"); + + // An unquoted $VAR is not split into fields, so an assignment value drops the trailing whitespace. + TestBuilder.command`A=$(echo 'f '); B="$(echo 'g ')"; export C="$(echo 'h ')"; ${BUN} run ./code.ts $A $B $C` + .ensureTempDir() + .file("code.ts", `console.log(JSON.stringify(process.argv.slice(2)))`) + .stdout('["f","g","h"]\n') + .runAsTest("unquoted variable assigned a command substitution with a trailing space"); + + // bash keeps the space in the value and drops it when it splits an unquoted $VAR. + TestBuilder.command`VAR="$(echo 'a ')" && echo "[$VAR]"` + .stdout("[a ]\n") + .todo("an unquoted variable is not split into fields") + .runAsTest("assignment keeps the trailing space of a command substitution"); }); describe("env variables", async () => { diff --git a/test/js/bun/shell/commands/exit.test.ts b/test/js/bun/shell/commands/exit.test.ts index 5a3239d0f49d..f57747a6e367 100644 --- a/test/js/bun/shell/commands/exit.test.ts +++ b/test/js/bun/shell/commands/exit.test.ts @@ -15,6 +15,23 @@ describe("exit", async () => { TestBuilder.command`exit 62757836`.exitCode(204).runAsTest("exit code wraps u8"); + // bash accepts a space or a tab around the number, and no other byte after it. + describe("blanks around the number", () => { + for (const arg of ["3 ", " 3", "3\t"]) { + TestBuilder.command`exit ${arg}`.exitCode(3).runAsTest(`accepts ${JSON.stringify(arg)}`); + } + + for (const arg of ["3\r", "3\n", "3\f"]) { + TestBuilder.command`exit ${arg}` + .exitCode(1) + .stderr("exit: numeric argument required\n") + .runAsTest(`rejects ${JSON.stringify(arg)}`); + } + + // A quoted substitution keeps its trailing space, so the status reaches exit with it. + TestBuilder.command`exit "$(echo '3 ')"`.exitCode(3).runAsTest("accepts a quoted command substitution"); + }); + // prettier-ignore TestBuilder.command`exit abc`.exitCode(1).stderr("exit: numeric argument required\n").runAsTest("numeric argument required"); }); diff --git a/test/js/bun/shell/exec.test.ts b/test/js/bun/shell/exec.test.ts index 2243fb93fad4..5c670e8ba6b8 100644 --- a/test/js/bun/shell/exec.test.ts +++ b/test/js/bun/shell/exec.test.ts @@ -47,6 +47,11 @@ describe("bun exec", () => { .stdout("ab\n") .runAsTest("command substitution drops NUL bytes from the output"); + TestBuilder.command`${BUN} exec ${`echo "[$(echo 'a ')]"`}` + .env(bunEnv) + .stdout("[a ]\n") + .runAsTest("quoted command substitution keeps a trailing space"); + describe("--help works", () => { // prettier-ignore const programs = [