Skip to content
Closed
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
27 changes: 27 additions & 0 deletions docs/runtime/shell.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,33 @@ const foo = "bar123; rm -rf /tmp";
await $`FOO=${foo} bun -e 'console.log(process.env.FOO)'`; // bar123; rm -rf /tmp\n
```

### Reading variables

Read a variable with `$NAME` or `${NAME}`. Use the braces when text follows the name, because `$FOObaz` reads the variable `FOObaz`:

```sh script.sh icon="file-code"
FOO=bar
echo ${FOO}baz
```

```txt
barbaz
```

`${0}` to `${9}` read the same positional arguments as `$0` to `$9`. Other `${...}` forms, such as `${NAME:-default}`, `${#NAME}` and `${10}`, are not supported. Bun Shell does not expand them: the `$` is text, and the rest is read as ordinary shell syntax.

<Note>

In a `$` template literal, `${...}` is a JavaScript interpolation. Write `$NAME` there, and put it in double quotes when text follows the name:

```js
import { $ } from "bun";

await $`FOO=bar; echo "$FOO"baz`; // barbaz\n
```

</Note>

### Changing the environment variables

By default, all commands use `process.env` as their environment variables.
Expand Down
3 changes: 2 additions & 1 deletion src/runtime/shell/shell_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -618,13 +618,14 @@ impl<'a> ShellSrcBuilder<'a> {
Ok(true)
}

/// The script ends in `$NAME` or `{NAME`: a value spliced in as text would extend the name.
fn outbuf_ends_with_var_ref(&self) -> bool {
match self
.outbuf
.iter()
.rposition(|b| !(b.is_ascii_alphanumeric() || *b == b'_'))
{
Some(i) => self.outbuf[i] == b'$',
Some(i) => matches!(self.outbuf[i], b'$' | b'{'),
None => false,
}
}
Expand Down
27 changes: 27 additions & 0 deletions src/shell_parser/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3415,6 +3415,9 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
c if (u32::from(b'a')..=u32::from(b'z')).contains(&c)
|| (u32::from(b'A')..=u32::from(b'Z')).contains(&c)
|| c == u32::from(b'_') => {}
c if c == u32::from(b'{') && !escaped => {
return Ok(self.eat_braced_var_name());
Comment thread
robobun marked this conversation as resolved.
Resolved
}
_ => return Ok(TextRange { start, end: self.j }),
}
}
Expand Down Expand Up @@ -3459,6 +3462,30 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
Ok(TextRange { start, end: self.j })
}

/// Matches `{NAME}` or `{0}`..`{9}` on the source bytes: no escape or JS ref joins the name.
#[cold]
fn eat_braced_var_name(&mut self) -> TextRange {
let start = self.j;
let bytes = self.chars.src_bytes_at_cursor();
let name_len = match bytes {
[b'{', b'0'..=b'9', ..] => 1,
[b'{', name @ ..] => name
.iter()
.take_while(|b| b.is_ascii_alphanumeric() || **b == b'_')
.count(),
_ => 0,
};
if name_len == 0 || bytes.get(1 + name_len) != Some(&b'}') {
return TextRange { start, end: start };
}
let name = &bytes[1..=name_len];
self.strpool.extend_from_slice(name);
self.j += name_len as u32;
let after_brace = self.chars.cursor_pos() + name_len + 2;
self.bump_cursor_ascii(after_brace, Some(name[name_len - 1]), b'}');
TextRange { start, end: self.j }
}

fn eat(&mut self) -> Option<InputChar> {
self.chars.eat()
}
Expand Down
28 changes: 28 additions & 0 deletions test/cli/run/run-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,34 @@ describe.concurrent("run-shell", () => {
});
});

// Bun's shell runs package.json scripts by default on Windows. The system
// shell row shows that both shells give the same output.
test.each(isWindows ? ["bun"] : ["bun", "system"])("package script expands ${NAME} with --shell=%s", async shell => {
using dir = tempDir("run-shell-braced", {
"package.json": JSON.stringify({
name: "braced-fixture",
version: "1.2.3",
scripts: {
braced: 'echo ${BRACED_NAME} "${BRACED_VERSION}" ${npm_lifecycle_event}-x',
},
}),
});

await using proc = Bun.spawn({
cmd: [bunExe(), "run", `--shell=${shell}`, "braced", "${BRACED_NAME}"],
cwd: String(dir),
env: { ...bunEnv, BRACED_NAME: "braced-fixture", BRACED_VERSION: "1.2.3" },
stdout: "pipe",
stderr: "pipe",
});

const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

// The argument after the script name is data. It stays text.
expect(stdout).toBe("braced-fixture 1.2.3 braced-x ${BRACED_NAME}\n");
expect(exitCode).toBe(0);
});

test.skipIf(isWindows)(
"package script shell interpreter is resolved from the original PATH, not node_modules/.bin",
async () => {
Expand Down
137 changes: 137 additions & 0 deletions test/js/bun/shell/bunshell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,63 @@ describe("bunshell", () => {
});
});

// The first `${FOO}` of each script is written in the shell source and
// expands. The second one gets its name, or the end of its name, from an
// interpolated value and stays text.
describe("interpolated value after a literal ${ stays literal", () => {
const source = { raw: "${FOO}" };
const open = { raw: "${" };
const name = "FOO";
TestBuilder.command`FOO=bar && echo ${source} ${open}${name}}`.stdout("bar ${FOO}\n").runAsTest("no quotes");
TestBuilder.command`FOO=bar && echo "${source} ${open}${name}}"`
.stdout("bar ${FOO}\n")
.runAsTest("double quotes");
TestBuilder.command`FOO=bar && echo ${source} a${open}${name}}b`
.stdout("bar a${FOO}b\n")
.runAsTest("inside a word");
TestBuilder.command`FOO=bar && echo ${source} ${open}${1}}`.stdout("bar ${1}\n").runAsTest("a number");
TestBuilder.command`FOO=bar && echo ${source} ${open}${["FOO"]}}`.stdout("bar ${FOO}\n").runAsTest("an array");
TestBuilder.command`FOO=bar && echo ${source} ${open}${{ toString: () => "FOO" }}}`
.stdout("bar ${FOO}\n")
.runAsTest("an object with toString");
TestBuilder.command`FOO=bar && echo ${source} ${open}${Bun.file("FOO")}}`
.stdout("bar ${FOO}\n")
.runAsTest("a Bun.file path");
TestBuilder.command`FOOBAR=long && FOO=short && echo ${source} ${{ raw: "${FOO" }}${Bun.file("BAR")}}`
.stdout("short ${FOOBAR}\n")
.runAsTest("a Bun.file path that would complete a name");
// A backslash-newline breaks the word today, so this prints `$ {FOO}`.
TestBuilder.command`FOO=bar && echo ${source} ${{ raw: "$\\\n{" }}${name}}`
.stdout(stdout => expect(stdout.replace("$ {", "${")).toBe("bar ${FOO}\n"))
.runAsTest("after a line continuation between $ and {");

test("does not complete a variable name", async () => {
const { stdout, stderr, exitCode } =
await $`FOOBAR=long && FOO=short && echo ${source} ${{ raw: "${FOO" }}${"BAR"}}`.env({ ...bunEnv });
expect(stderr.toString()).toBe("");
expect(stdout.toString()).toBe("short ${FOOBAR}\n");
expect(exitCode).toBe(0);
});

test("does not name a variable from the environment", async () => {
const { stdout, stderr, exitCode } = await $`echo ${source} ${open}${"SHELL_TEST_SECRET"}}`.env({
...bunEnv,
FOO: "bar",
SHELL_TEST_SECRET: "hunter2",
});
expect(stderr.toString()).toBe("");
expect(stdout.toString()).toBe("bar ${SHELL_TEST_SECRET}\n");
expect(exitCode).toBe(0);
});

test("when $ is called with a strings array", async () => {
const { stdout, stderr, exitCode } = await $({ raw: ["FOO=bar && echo ${FOO} ${", "}"] } as any, name);
expect(stderr.toString()).toBe("");
expect(stdout.toString()).toBe("bar ${FOO}\n");
expect(exitCode).toBe(0);
});
});

test("can't escape a js string/obj ref", async () => {
const shellvar = "$FOO";
await TestBuilder.command`FOO=bar && echo \\${shellvar}`.stdout(`\\$FOO\n`).run();
Expand Down Expand Up @@ -1148,6 +1205,12 @@ booga"
const { stdout } = await $`{echo,a,b,c} {d,e,f}`;
expect(stdout.toString()).toEqual("a b c d e f\n");
});

describe("interpolated value after {", () => {
TestBuilder.command`echo {${"a"},b}`.stdout("a b\n").runAsTest("first item");
TestBuilder.command`echo {pre${"a"},b}`.stdout("prea b\n").runAsTest("end of the first item");
TestBuilder.command`echo '{${"a"}}'`.stdout("{a}\n").runAsTest("in single quotes");
});
});

describe("variables", () => {
Expand Down Expand Up @@ -1209,6 +1272,80 @@ booga"

expect(procEnv).toEqual({ ...bunEnv, BUN_TEST_VAR: "1", FOO: "bar" });
});

// `${` in a JS template literal starts an interpolation, so `{ raw }`
// carries the braced form to the shell.
describe("${NAME}", () => {
const raw = (src: string) => TestBuilder.command`${{ raw: src }}`;

raw("FOO=bar; echo ${FOO}").stdout("bar\n").runAsTest("expands like $NAME");
raw('FOO=bar; echo "${FOO}"').stdout("bar\n").runAsTest("in double quotes");
raw('FOO="a b"; echo "x ${FOO} y"').stdout("x a b y\n").runAsTest("in double quotes beside text");
raw("FOO=bar; echo a${FOO}b").stdout("abarb\n").runAsTest("inside a word");
raw("FOO=bar; FOObar=no; echo ${FOO}bar").stdout("barbar\n").runAsTest("the braces end the name");
raw("FOO=a; BAR=b; echo ${FOO}${BAR}$FOO").stdout("aba\n").runAsTest("beside other variables");
raw("echo a${SHELL_TEST_UNSET}b").stdout("ab\n").runAsTest("unset is empty");
raw("echo '${FOO}' \\${FOO}").stdout("${FOO} ${FOO}\n").runAsTest("quoted or escaped stays text");

raw("echo ${FOO}; FOO=bar; echo ${FOO}").stdout("\nbar\n").runAsTest("reads the value when the command runs");
raw("FOO=a; FOO=b; echo ${FOO}").stdout("b\n").runAsTest("after a second assignment");
raw("FOO=a; (FOO=b; echo ${FOO}); echo ${FOO}").stdout("b\na\n").runAsTest("in a subshell");
raw("FOO=bar; echo $(echo ${FOO}) `echo ${FOO}`").stdout("bar bar\n").runAsTest("in a command substitution");
raw("FOO=bar; BAR=${FOO}; echo $BAR").stdout("bar\n").runAsTest("as an assignment value");
raw("FOO=echo; ${FOO} hi").stdout("hi\n").runAsTest("as the command name");
raw("FOO=bar; echo hi > out_${FOO}.txt")
.ensureTempDir()
.fileEquals("out_bar.txt", "hi\n")
.runAsTest("in a redirect target");
raw("FOO=bar; echo ${FOO}{a,b} {${FOO},c} {a,b}")
.stdout("bara barb bar c a b\n")
.runAsTest("beside a brace group");
raw("[[ -n ${SHELL_TEST_UNSET} ]]").exitCode(1).runAsTest("unset in [[ -n ]]");
raw("FOO=bar; [[ -n ${FOO} ]] && [[ ${FOO} == bar ]] && echo yes").stdout("yes\n").runAsTest("set in [[ ]]");
raw("mkdir sub && cd sub && echo ${PWD} && echo $PWD")
.ensureTempDir()
.stdout(stdout => {
const [braced, bare] = stdout.split("\n");
expect(braced).toEndWith("sub");
expect(braced).toBe(bare);
})
.runAsTest("${PWD} after cd");

test("reads an environment set with .env()", async () => {
const { stdout, stderr, exitCode } = await $`echo ${{ raw: "${FOO}" }}`.env({ ...bunEnv, FOO: "bar" });
expect(stderr.toString()).toBe("");
expect(stdout.toString()).toBe("bar\n");
expect(exitCode).toBe(0);
});

// sh gives one empty word for a double-quoted expansion that is empty.
// Bun's shell drops the word, for "$NAME" too. #44904 changes both.
raw('echo a "${SHELL_TEST_UNSET}" b')
.stdout("a b\n")
.todo("needs #44904")
.runAsTest("unset in double quotes is one empty word");
raw('FOO=; echo a "${FOO}" b')
.stdout("a b\n")
.todo("needs #44904")
.runAsTest("empty in double quotes is one empty word");
test.todo("unset in double quotes is one empty argument (needs #44904)", async () => {
const words = { raw: '"${SHELL_TEST_UNSET}" x "$SHELL_TEST_UNSET" y' };
const { stdout, stderr, exitCode } =
await $`${BUN} -e "console.log(JSON.stringify(process.argv.slice(1)))" ${words}`;
expect(stderr.toString()).toBe("");
expect(JSON.parse(stdout.toString())).toEqual(["", "x", "", "y"]);
expect(exitCode).toBe(0);
});

test("is one argument of a command", async () => {
const words = { raw: '"${FOO}" x${BAR}y ${BAR}' };
const { stdout, stderr, exitCode } =
await $`FOO="a b"; BAR=c; ${BUN} -e "console.log(JSON.stringify(process.argv.slice(1)))" ${words}`;
expect(stderr.toString()).toBe("");
expect(JSON.parse(stdout.toString())).toEqual(["a b", "xcy", "c"]);
expect(exitCode).toBe(0);
});
});
});

describe("cd & pwd", () => {
Expand Down
22 changes: 22 additions & 0 deletions test/js/bun/shell/env.positionals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ describe("$ argv", async () => {
.stdout(process.argv[i] + "\n")
.runAsTest(`$${i} should equal process.argv[${i}]`);
}
for (let i = 0; i < Math.min(process.argv.length, 10); i++) {
TestBuilder.command`echo ${{ raw: "${" + i + "}" }}`
.exitCode(0)
.stdout(process.argv[i] + "\n")
.runAsTest(`\${${i}} should equal process.argv[${i}]`);
}
});

test("$ argv: standalone", async () => {
Expand Down Expand Up @@ -73,6 +79,22 @@ test("$ argv: standalone: only 10", async () => {
expect(out.split("\n")).toEqual([script, "a", "bb", "c", "d", "e", "f", "g", "h", "i", "a0", ""]);
});

test("$ argv: standalone: ${N}", async () => {
const script = path.join(import.meta.dir, "fixtures", "positionals3.bun.sh");
await using proc = spawn({
cmd: [bunExe(), "run", script, "a", "b", "c d"],
stdout: "pipe",
stdin: "ignore",
stderr: "pipe",
env: bunEnv,
});
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(err).toBeEmpty();
expect(out.split("\n")).toEqual([script, "a", "bb", "a0", "c d", ""]);
expect(exitCode).toBe(0);
});

test("$ argv: standalone: non-ascii", async () => {
const script = path.join(import.meta.dir, "fixtures", "positionals2.bun.sh");
const { stdout, stderr, exited } = spawn({
Expand Down
11 changes: 11 additions & 0 deletions test/js/bun/shell/exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@ describe("bun exec", () => {
.stdout('hi "there bud"\n')
.runAsTest("it works2");

TestBuilder.command`${BUN} exec ${'FOO=bar; echo ${FOO} "${FOO}" ${FOO}baz'}`
.env(bunEnv)
.stdout("bar bar barbaz\n")
.runAsTest("expands ${NAME}");

TestBuilder.command`${BUN} script.sh`
.file("script.sh", 'FOO=bar\necho ${FOO} "${FOO}" ${FOO}baz\n')
.env(bunEnv)
.stdout("bar bar barbaz\n")
.runAsTest("expands ${NAME} in a .sh file");

TestBuilder.command`${BUN} exec ${"cat filename"}`
.file(
"filename",
Expand Down
7 changes: 7 additions & 0 deletions test/js/bun/shell/fixtures/positionals3.bun.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/bin/sh

echo ${0}
echo ${1}
echo ${2}${2}
echo ${1}0
echo "${3}"
Loading
Loading