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.