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
2 changes: 2 additions & 0 deletions docs/runtime/shell.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Note>

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:
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/shell/builtin/exit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ impl Exit {
}

fn parse_exit_code(s: &[u8]) -> Option<crate::shell::ExitCode> {
// %256 is bash semantics — keep wrapper fn.
bun_core::fmt::parse_decimal::<u64>(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::<u64>(bun_core::strings::trim(s, b" \t"))
.map(|n| (n % 256) as crate::shell::ExitCode)
}
23 changes: 19 additions & 4 deletions src/runtime/shell/states/Expansion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>) {
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();
}
}
Comment thread
robobun marked this conversation as resolved.
}

/// Newlines→spaces, trim, then split on whitespace runs into separate
/// argv words.
fn post_subshell_expansion(me: &mut Expansion, mut stdout: Vec<u8>) {
Expand Down Expand Up @@ -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);
}
Comment thread
robobun marked this conversation as resolved.
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;
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down
49 changes: 49 additions & 0 deletions test/js/bun/shell/bunshell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
17 changes: 17 additions & 0 deletions test/js/bun/shell/commands/exit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
5 changes: 5 additions & 0 deletions test/js/bun/shell/exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
Loading