diff --git a/docs/runtime/shell.mdx b/docs/runtime/shell.mdx index 4cc6ed402917..eea62c94c584 100644 --- a/docs/runtime/shell.mdx +++ b/docs/runtime/shell.mdx @@ -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. + + + +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 +``` + + + ### Changing the environment variables By default, all commands use `process.env` as their environment variables. diff --git a/src/runtime/shell/shell_body.rs b/src/runtime/shell/shell_body.rs index eec562d638c5..31da3d6dcafc 100644 --- a/src/runtime/shell/shell_body.rs +++ b/src/runtime/shell/shell_body.rs @@ -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, } } diff --git a/src/shell_parser/parse.rs b/src/shell_parser/parse.rs index 7a29d7d3b5a4..7af2324dcaa9 100644 --- a/src/shell_parser/parse.rs +++ b/src/shell_parser/parse.rs @@ -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()); + } _ => return Ok(TextRange { start, end: self.j }), } } @@ -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 { self.chars.eat() } diff --git a/test/cli/run/run-shell.test.ts b/test/cli/run/run-shell.test.ts index 4d2778abb7cd..047b732a264a 100644 --- a/test/cli/run/run-shell.test.ts +++ b/test/cli/run/run-shell.test.ts @@ -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 () => { diff --git a/test/js/bun/shell/bunshell.test.ts b/test/js/bun/shell/bunshell.test.ts index c921c279f393..6124cb641073 100644 --- a/test/js/bun/shell/bunshell.test.ts +++ b/test/js/bun/shell/bunshell.test.ts @@ -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(); @@ -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", () => { @@ -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", () => { diff --git a/test/js/bun/shell/env.positionals.test.ts b/test/js/bun/shell/env.positionals.test.ts index 66273eec7a17..96274610957f 100644 --- a/test/js/bun/shell/env.positionals.test.ts +++ b/test/js/bun/shell/env.positionals.test.ts @@ -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 () => { @@ -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({ diff --git a/test/js/bun/shell/exec.test.ts b/test/js/bun/shell/exec.test.ts index b7c272197124..ba5d0352dfec 100644 --- a/test/js/bun/shell/exec.test.ts +++ b/test/js/bun/shell/exec.test.ts @@ -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", diff --git a/test/js/bun/shell/fixtures/positionals3.bun.sh b/test/js/bun/shell/fixtures/positionals3.bun.sh new file mode 100644 index 000000000000..29609814fa38 --- /dev/null +++ b/test/js/bun/shell/fixtures/positionals3.bun.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +echo ${0} +echo ${1} +echo ${2}${2} +echo ${1}0 +echo "${3}" diff --git a/test/js/bun/shell/lex.test.ts b/test/js/bun/shell/lex.test.ts index aebe12bc91a2..664118dad52c 100644 --- a/test/js/bun/shell/lex.test.ts +++ b/test/js/bun/shell/lex.test.ts @@ -821,6 +821,122 @@ describe("lex shell", () => { expect(JSON.parse(lex({ raw: [source] }))).toEqual(expected); }); + // `${` in a JS template literal starts an interpolation, so the sources + // here go to the lexer as a raw array. + describe("${NAME}", () => { + const tokens = (source: string) => JSON.parse(lex({ raw: [source] })); + + test.each([ + ["a whole word", "echo ${PORT}", "echo $PORT"], + ["in double quotes", 'echo "${X}"', 'echo "$X"'], + ["in double quotes beside text", 'echo "a ${X} b"', 'echo "a $X b"'], + ["two in one word", "${A}${B}", "$A$B"], + ["a positional parameter", "echo ${1}", "echo $1"], + ["before a brace group", "${X}{a,b}", "$X{a,b}"], + ["in a brace group", "{${X},b}", "{$X,b}"], + ["in a script that is not ASCII", "é${X}", "é$X"], + ["before a character that is not ASCII", "${X}é", "$Xé"], + ["in $(...)", "echo $(echo ${X})", "echo $(echo $X)"], + ["in $(...) in a script that is not ASCII", "é $(echo ${X})", "é $(echo $X)"], + ["in backticks", "echo `echo ${X}`", "echo `echo $X`"], + ["in a subshell", "(echo ${X})", "(echo $X)"], + ["before a space", "${FOO} bar", "$FOO bar"], + ["before a newline", "echo ${FOO}\nls", "echo $FOO\nls"], + ["before a semicolon", "${FOO};b", "$FOO;b"], + ["before an operator", "${FOO}|b", "$FOO|b"], + ["before a #", "${X}#c", "$X#c"], + ["after a literal $", "$${X}", "$$X"], + ["inside a form that is not supported", "${X:-${Y}}", "${X:-$Y}"], + ["inside a form that is not supported, in double quotes", '"${X:-${Y}}"', '"${X:-$Y}"'], + ])("the same tokens as $NAME: %s", (_name, braced, bare) => { + expect(tokens(braced)).toEqual(tokens(bare)); + }); + + test.each([ + [ + "text after the brace", + "echo ${FOO}bar", + [{ Text: "echo" }, { Delimit: {} }, { Var: "FOO" }, { Text: "bar" }, { Delimit: {} }, { Eof: {} }], + ], + ["text on both sides", "a${X}b", [{ Text: "a" }, { Var: "X" }, { Text: "b" }, { Delimit: {} }, { Eof: {} }]], + [ + "text on both sides in a script that is not ASCII", + "é${X}b", + [{ Text: "é" }, { Var: "X" }, { Text: "b" }, { Delimit: {} }, { Eof: {} }], + ], + ["name characters after the brace", "${X}_1", [{ Var: "X" }, { Text: "_1" }, { Delimit: {} }, { Eof: {} }]], + ["a digit after a positional parameter", "${1}0", [{ VarArgv: 1 }, { Text: "0" }, { Delimit: {} }, { Eof: {} }]], + ["an underscore and digits in the name", "${_X_1}", [{ Var: "_X_1" }, { Eof: {} }]], + ])("the braces end the name: %s", (_name, source, expected) => { + expect(tokens(source)).toEqual(expected); + }); + + const literal = (...inside: object[]) => [ + { Text: "$" }, + { BraceBegin: {} }, + ...inside, + { BraceEnd: {} }, + { Eof: {} }, + ]; + test.each([ + ["an escaped $", "\\${X}", literal({ Text: "X" })], + ["an escaped {", "$\\{X}", [{ Text: "$" }, { Text: "{X" }, { BraceEnd: {} }, { Eof: {} }]], + ["single quotes", "'${X}'", [{ SingleQuotedText: "${X}" }, { Eof: {} }]], + ["an escaped $ in double quotes", '"\\${X}"', [{ DoubleQuotedText: "${X}" }, { Eof: {} }]], + ])("stays text, as in sh: %s", (_name, source, expected) => { + expect(tokens(source)).toEqual(expected); + }); + + // sh expands or rejects each of these. Bun's shell does not support them: + // the `$` is text and the rest is lexed as ordinary shell syntax. The rows + // record that reading `${NAME}` left these tokens as they were. + test.each([ + ["an operator", "${X:-d}", literal({ Text: "X:-d" })], + [ + "an operator in double quotes", + '"${X:-d}"', + [{ DoubleQuotedText: "$" }, { DoubleQuotedText: "{X:-d}" }, { Eof: {} }], + ], + ["a length", "${#X}", literal({ Text: "#X" })], + ["two digits", "${10}", literal({ Text: "10" })], + ["no name", "${}", literal()], + ["a digit then a letter", "${1a}", literal({ Text: "1a" })], + ["a name that is not ASCII", "${é}", literal({ Text: "é" })], + ["spaces around the name", "${ X }", literal({ Delimit: {} }, { Text: "X" }, { Delimit: {} })], + ["a backslash in the name", "${F\\OO}", literal({ Text: "FOO" })], + ["a brace group after $", "${a,b}", literal({ Text: "a" }, { Comma: {} }, { Text: "b" })], + ["no closing brace", "${X", [{ Text: "$" }, { BraceBegin: {} }, { Text: "X" }, { Delimit: {} }, { Eof: {} }]], + [ + "no closing brace in $(...)", + "$(echo ${X)", + [ + { CmdSubstBegin: {} }, + { Text: "echo" }, + { Delimit: {} }, + { Text: "$" }, + { BraceBegin: {} }, + { Text: "X" }, + { Delimit: {} }, + { CmdSubstEnd: {} }, + { Eof: {} }, + ], + ], + ])("not supported, tokens unchanged: %s", (_name, source, expected) => { + expect(tokens(source)).toEqual(expected); + }); + + // An interpolated value is data. It never supplies the name, or a part of + // the name, of a `${NAME}` that the template opens. + test.each([ + ["the whole name", ["echo ${", "}"], "X", literal({ Text: "X" })], + ["the end of the name", ["echo ${FOO", "}"], "BAR", literal({ Text: "FOO" }, { Text: "BAR" })], + ["a digit", ["echo ${", "}"], 1, literal({ Text: "1" })], + ["the name, after an escaped $", ["echo \\${", "}"], "X", literal({ Text: "X" })], + ])("an interpolated value is not a braced name: %s", (_name, raw, value, word) => { + expect(JSON.parse(lex({ raw }, value))).toEqual([{ Text: "echo" }, { Delimit: {} }, ...word]); + }); + }); + describe("errors", async () => { // This is disallowed because the js object references get turned into special vars: $__bun_0, $__bun_1, etc. // this will break things inside of a quote.