\n"],
+ ["./args.sh \"pre ${X:-mid dle} post\"", "\n"],
+ ["./args.sh ${X:-${Y:-${Z:-deep}}}", "\n"],
+ ["./args.sh ${X:-${Y:+no}${Z:-yes}}", "\n"],
+ ["./args.sh ${X:=one} ${X:=two} $X", "\n"],
+ ["./args.sh ${X:=a b} \"$X\"", "\n", true, ["\n", true, SPLITS]],
+ ["./args.sh \"${X:=a b}\" \"$X\"", "\n"],
+ ["X=1; ./args.sh ${X:?no} ${X?no}", "<1><1>\n"],
+ ["./args.sh ${X:-a} > out1.txt; cat out1.txt", "\n"],
+ ["echo hi > ${X:-out2.txt}; cat out2.txt", "hi\n"],
+ ["[[ -n ${X:-v} ]] && echo nonempty", "nonempty\n"],
+ ["[[ -z ${X:-} ]] && echo empty", "empty\n"],
+ ["[[ ${X:-a} == a ]] && echo eq", "eq\n"],
+ ["./args.sh $(echo ${X:-in subst})", "\n"],
+ ["./args.sh \"$(echo ${X:-in subst})\"", "\n"],
+ ["./args.sh `echo ${X:-bt}`", "\n"],
+ ["(X=inner; ./args.sh ${X:-d}); ./args.sh ${X:-d}", "\n\n"],
+ ["(: ${X:=inner}); ./args.sh \"[$X]\"", "<[]>\n"],
+ ["echo ${X:=piped} | cat; ./args.sh \"[$X]\"", "piped\n<[]>\n"],
+ ["./args.sh ${X:-a} | cat", "\n"],
+ ["./args.sh ${HOME:+set} ${HOME:-unset}", "\n"],
+ ["./args.sh ${X:-'single quoted'} ${X:-\"double quoted\"}", "\n"],
+ ["./args.sh ${X:-a\\ b} ${X:-a\\\\b}", "\n"],
+ ["./args.sh ${X:-$}", "<$>\n"],
+ ["./args.sh ${X:-$ }", "<$>\n"],
+ ["./args.sh ${X:-a$}", "\n"],
+ ["./args.sh ${X:-}${X:-}", "\n"],
+ ["./args.sh \"${X:-}\"x", "\n"],
+ ["./args.sh ${X:+}", "\n"],
+ ["./args.sh \"a${X:-b}c\" 'a${X:-b}c' a\\${X:-b}c", "\n"],
+ ["./args.sh ${X:-a} ${Y:-b} ${Z:-c}", "\n"],
+ ["./args.sh ${X}", "\n"],
+ ["./args.sh \"${X}\"", "<>\n", true, ["\n", true, EMPTY]],
+ ["./args.sh ${X}y", "\n"],
+ ["X=1; ./args.sh ${X} \"${X}\" ${X}y $X${X} ${X}${X}", "<1><1><1y><11><11>\n"],
+ ["./args.sh ${0} | grep -c .", "1\n"],
+ ["./args.sh ${X:-\"$(echo q1 q2)\"}", "\n"],
+ ["./args.sh ${X:-$(echo q1 q2) tail}", "\n"],
+ ["./args.sh pre${X:-$(echo q1 q2)}post", "\n"],
+ ["./args.sh ${X:- $(echo q1) }post", "\n"],
+ ["./args.sh ${X:-=} ${X:-+} ${X:--} ${X:-?}", "<=><+><->>\n"],
+ ["./args.sh ${X-=} ${X=+}", "<=><+>\n"],
+ ["./args.sh ${X:-a=b}", "\n"],
+ ["FOO=${X:-bar}; ./args.sh $FOO", "\n"],
+ ["FOO=a${X:-bar}b; ./args.sh $FOO", "\n"],
+ ["./args.sh ${X:-#notcomment} #comment", "<#notcomment>\n"],
+ ["./args.sh ${X:-{a,b}} {a,b}${X:-c}", "<{a,b}>\n"],
+ ["./args.sh ${X:-a,b}", "\n"],
+ ["./args.sh {${X:-a},b}", "\n"],
+ ["./args.sh ~/x${X:- } ~/x${X:- y}", "\n"],
+ ["./args.sh ${X:-~abc} ${X:-~$Y} ${X:-~ b} a${X:-~/b}", "<~abc><~><~>\n"],
+ ["./args.sh ${X:-a \"\" b} ${HOME:+name \"\"}", "<><>\n"],
+ ["./args.sh x${X:-$Y $(echo a)} ${X:-$(echo a) $Y}y", "\n"],
+ ["[[ ${X:-a b} == \"a b\" ]] && echo one", "one\n"],
+ ["[[ ${X:-a b} == ab ]] && echo glued", "", false],
+ ["true \"${X:?}\" && echo RAN", "", false],
+ ["X=1; true \"${X:?}\" && echo RAN", "RAN\n"],
+ ["X=v; ./args.sh ${X:-{}} \"${X:-{}}\" ${X:+{m}}", "<{m}>\n"],
+ ["./args.sh ${X:={}}; ./args.sh \"$X\"", "<{}>\n<{>\n"],
+];
+
+// `--shell=system` runs bash when there is one. bash 3.2 (macOS) and dash give other results for a few rows.
+const bashVersion = await (async () => {
+ const bash = Bun.which("bash", { PATH: bunEnv.PATH });
+ if (!bash) return 0;
+ await using proc = Bun.spawn({ cmd: [bash, "-c", 'echo "${BASH_VERSINFO[0]} ${BASH_VERSINFO[1]}"'], stdout: "pipe" });
+ const [major, minor] = (await proc.stdout.text()).trim().split(" ").map(Number);
+ return Number.isInteger(major) && Number.isInteger(minor) ? major * 100 + minor : 0;
+})();
+
+// Each name that `script` reads, as not set in the environment. A row reads a name as not set until the row sets it.
+const notSetIn = (script: string) =>
+ Object.fromEntries(Array.from(script.matchAll(/\$\{?([A-Za-z_]\w*)/g), match => [match[1], undefined]));
+
+// Bun's shell runs package.json scripts by default on Windows. The system
+// shell rows show that both shells give the same output.
+describe.each(isWindows ? ["bun"] : ["bun", "system"])("package script with --shell=%s", shell => {
+ const run = async (script: string, env: Record, ...args: string[]) => {
+ using dir = tempDir("run-shell-braced", {
+ "package.json": JSON.stringify({ name: "braced-fixture", version: "1.2.3", scripts: { braced: script } }),
+ });
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), "run", `--shell=${shell}`, "braced", ...args],
+ cwd: String(dir),
+ env: { ...bunEnv, ...notSetIn(script), ...env },
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
+ return { stdout, stderr, exitCode };
+ };
+
+ test("expands ${NAME}", async () => {
+ const { stdout, exitCode } = await run(
+ 'echo ${BRACED_NAME} "${BRACED_VERSION}" ${npm_lifecycle_event}-x',
+ { BRACED_NAME: "braced-fixture", BRACED_VERSION: "1.2.3" },
+ "${BRACED_NAME}",
+ );
+ // 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("expands ${NAME:-word} and ${NAME:+word}", async () => {
+ const script = "echo --port ${BRACED_PORT:-3002} ${BRACED_HOST:+--host $BRACED_HOST} ${BRACED_NONE:+--none}";
+ expect(await run(script, { BRACED_HOST: "localhost" })).toMatchObject({
+ stdout: "--port 3002 --host localhost\n",
+ exitCode: 0,
+ });
+ expect(await run(script, { BRACED_PORT: "8080" })).toMatchObject({ stdout: "--port 8080\n", exitCode: 0 });
+ });
+
+ // Each row runs in a subshell of one script, so that no row changes a variable of another row.
+ test.skipIf(isWindows || (shell === "system" && bashVersion < 502))(
+ "${NAMEword} gives what sh gives",
+ async () => {
+ const script = paramRows.map(([row]) => `echo @@row; (\n${row}\n) && echo @@ok || echo @@fail`).join("\n");
+ using dir = tempDir("run-shell-param-rows", {
+ "package.json": JSON.stringify({ name: "param-rows", scripts: { rows: script } }),
+ "args.sh": `#!/bin/sh\nfor a in "$@"; do printf '<%s>' "$a"; done; printf '\\n'\n`,
+ });
+ chmodSync(join(String(dir), "args.sh"), 0o755);
+ await using proc = Bun.spawn({
+ cmd: [bunExe(), "run", `--shell=${shell}`, "rows"],
+ cwd: String(dir),
+ env: { ...bunEnv, ...notSetIn(script), HOME: "/home/user" },
+ stdout: "pipe",
+ stderr: "ignore",
+ });
+ const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
+ const results = stdout
+ .split("@@row\n")
+ .slice(1)
+ .map(result => [result.replace(/@@(ok|fail)\n$/, ""), result.endsWith("@@ok\n")]);
+ expect(Object.fromEntries(paramRows.map(([row], index) => [row, results[index]]))).toEqual(
+ Object.fromEntries(
+ paramRows.map(([row, out, ok = true, bun]) => [row, shell === "bun" && bun ? [bun[0], bun[1]] : [out, ok]]),
+ ),
+ );
+ expect(exitCode).toBe(0);
+ },
+ );
+
+ test("a ${NAME:?} guard in an assignment stops the command", async () => {
+ const script = "ENV=${BRACED_DEPLOY_ENV:?} echo deploying";
+ const unset = await run(script, {});
+ expect(unset.stderr).toContain("BRACED_DEPLOY_ENV: parameter");
+ expect(unset.stdout).toBe("");
+ expect(unset.exitCode).not.toBe(0);
+ expect(await run(script, { BRACED_DEPLOY_ENV: "staging" })).toMatchObject({ stdout: "deploying\n", exitCode: 0 });
+ });
+
+ test("a ${NAME:?} guard stops the script when NAME is not set", async () => {
+ const script = 'true "${BRACED_DEPLOY_ENV:?}" && echo deploying to $BRACED_DEPLOY_ENV';
+ const unset = await run(script, {});
+ expect(unset.stderr).toContain("BRACED_DEPLOY_ENV: parameter");
+ expect(unset.stdout).toBe("");
+ expect(unset.exitCode).not.toBe(0);
+ expect(await run(script, { BRACED_DEPLOY_ENV: "staging" })).toMatchObject({
+ stdout: "deploying to staging\n",
+ exitCode: 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 f2831d30d204..d6fa02c18bc5 100644
--- a/test/js/bun/shell/bunshell.test.ts
+++ b/test/js/bun/shell/bunshell.test.ts
@@ -41,6 +41,12 @@ export const bunEnv: NodeJS.ProcessEnv = {
PWD: (process.env.PWD || process.cwd()).replaceAll("\\", "/"),
};
+// The parameter expansion tests read these names as not set.
+const notSet = ["A", "B", "BAR", "C", "E", "EMPTY", "FOO", "NONE", "S", "U", "V", "VALUE", "W"];
+for (const name of Object.keys(bunEnv)) {
+ if (notSet.includes(name) || name.startsWith("SHELL_TEST_")) delete bunEnv[name];
+}
+
$.env(bunEnv);
$.cwd(process.cwd().replaceAll("\\", "/"));
$.nothrow();
@@ -64,6 +70,8 @@ afterAll(async () => {
});
const BUN = bunExe();
+// With `bun -e`: prints the arguments as JSON, so a test sees where each one ends.
+const printArgv = "console.log(JSON.stringify(process.argv.slice(1)))";
describe("bunshell", () => {
describe("exit codes", async () => {
@@ -278,6 +286,85 @@ 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");
+ TestBuilder.command`FOO=bar && echo ${source} ${{ raw: "${FOO" }}${":-other"}} ${{ raw: "${NONE:" }}${"-other"}}`
+ .stdout("bar ${FOO:-other} ${NONE:-other}\n")
+ .runAsTest("a value that would be the operator");
+ // 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);
+ });
+ });
+
+ // The template opens the form and its operator. The value is the word, and it is data there.
+ describe("interpolated value in the word of a literal ${NAME", () => {
+ const open = { raw: "${SHELL_TEST_UNSET:-" };
+ TestBuilder.command`echo ${open}${"a; echo INJECTED"}}`.stdout("a; echo INJECTED\n").runAsTest("a command");
+ TestBuilder.command`echo ${open}${"$(echo INJECTED) `echo INJECTED` $HOME"}}`
+ .stdout("$(echo INJECTED) `echo INJECTED` $HOME\n")
+ .runAsTest("a substitution or a variable");
+ TestBuilder.command`echo ${open}${"a}b"}}c ${open}${"x y"}}`.stdout("a}bc x y\n").runAsTest("a closing brace");
+ TestBuilder.command`echo ${open}${"~"}} ${open}${"*"}} ${open}${"${FOO:-d}"}}`
+ .stdout("~ * ${FOO:-d}\n")
+ .runAsTest("a tilde, a glob or another form");
+ TestBuilder.command`echo ${open}${"plain"}} ${{ raw: "${SHELL_TEST_UNSET:-a" }}${"b"}}`
+ .stdout("plain ab\n")
+ .runAsTest("plain text");
+ TestBuilder.command`true ${{ raw: "${SHELL_TEST_A:=" }}${"$(echo INJECTED)"}}; echo "$SHELL_TEST_A"`
+ .stdout("$(echo INJECTED)\n")
+ .runAsTest("the value of an assignment");
+ });
+
test("can't escape a js string/obj ref", async () => {
const shellvar = "$FOO";
await TestBuilder.command`FOO=bar && echo \\${shellvar}`.stdout(`\\$FOO\n`).run();
@@ -1316,6 +1403,594 @@ booga"
expect(procEnv).toEqual({ ...bunEnv, BUN_TEST_VAR: "1", FOO: "bar" });
});
+
+ // `${` in a JS template literal starts an interpolation, so `{ raw }`
+ // carries the braced forms 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");
+ raw("echo ${SHELL_TEST_X%a} ${#SHELL_TEST_X} ${SHELL_TEST_X/a/b} ${10}")
+ .stdout("${SHELL_TEST_X%a} ${#SHELL_TEST_X} ${SHELL_TEST_X/a/b} ${10}\n")
+ .runAsTest("the forms that are not read stay text");
+
+ 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);
+ });
+
+ 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 ${printArgv} ${words}`;
+ expect(stderr.toString()).toBe("");
+ expect(JSON.parse(stdout.toString())).toEqual(["a b", "xcy", "c"]);
+ expect(exitCode).toBe(0);
+ });
+
+ // sh gives one empty argument for a double-quoted expansion that is
+ // empty. Bun's shell gives none, for "$NAME" too.
+ test('empty in double quotes is what "$NAME" is', async () => {
+ const braced = { raw: 'a "${SHELL_TEST_UNSET}" "${EMPTY}" b' };
+ const bare = { raw: 'a "$SHELL_TEST_UNSET" "$EMPTY" b' };
+ const run = (words: { raw: string }) => $`EMPTY=; ${BUN} -e ${printArgv} ${words}`.text();
+ expect(await run(braced)).toBe(await run(bare));
+ });
+
+ // sh gives one empty word. 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");
+ raw('echo a "${SHELL_TEST_UNSET:-}" b')
+ .stdout("a b\n")
+ .todo("needs #44904")
+ .runAsTest("a form in double quotes that expands to nothing is one empty word");
+ });
+
+ describe("${NAMEword}", () => {
+ const raw = (src: string) => TestBuilder.command`${{ raw: src }}`;
+ // The arguments that the words of `src` give, after the assignments in `setup`.
+ const argvOf = async (src: string, setup = "") => {
+ const { stdout, stderr } = await $`${{ raw: setup }} ${BUN} -e ${printArgv} ${{ raw: src }}`;
+ expect(stderr.toString()).toBe("");
+ return JSON.parse(stdout.toString());
+ };
+ // The arguments that each of `words` gives, from one command. `E` is empty and HOME is /home/user.
+ const fieldsOf = async (words: string[], cwd = process.cwd()) => {
+ const { stdout, stderr } = await $`E=; ${BUN} -e ${printArgv} ${{ raw: words.join(" @@ ") }}`
+ .cwd(cwd)
+ .env({ ...bunEnv, HOME: "/home/user", USERPROFILE: "/home/user" })
+ .quiet();
+ expect(stderr.toString()).toBe("");
+ const fields: string[][] = [[]];
+ for (const arg of JSON.parse(stdout.toString())) arg === "@@" ? fields.push([]) : fields.at(-1)!.push(arg);
+ return fields;
+ };
+
+ // The result for a parameter that is not set, is empty, or is `v`. `!`: the command fails.
+ const results: Record = {
+ ":-": ["w", "w", "v"],
+ "-": ["w", "", "v"],
+ ":+": ["", "", "w"],
+ "+": ["", "w", "w"],
+ ":=": ["w", "w", "v"],
+ "=": ["w", "", "v"],
+ ":?": ["!", "!", "v"],
+ "?": ["!", "", "v"],
+ };
+ const cells = Object.entries(results).flatMap(([op, byState], row) =>
+ (["unset", "empty", "set"] as const).map((state, column) => {
+ const name = `SHELL_TEST_P${row}${column}`;
+ const result = byState[column];
+ const assigns = op.endsWith("=") && result === "w";
+ return {
+ op,
+ id: `${op} ${state}`,
+ name,
+ setup: state === "unset" ? "" : state === "empty" ? `${name}=; ` : `${name}=v; `,
+ form: "${" + name + op + "w}",
+ result,
+ after: assigns ? "w" : state === "set" ? "v" : "",
+ error: `bun: ${name}: w\n`,
+ };
+ }),
+ );
+
+ test.each([
+ ["an argument", (form: string) => `echo "$ID" .${form}.`],
+ ["an argument in double quotes", (form: string) => `echo "$ID" ".${form}."`],
+ ])("every operator as %s", async (_name, command) => {
+ const script = cells
+ .map(cell => `${cell.setup}ID="${cell.id}"; ${command(cell.form)} "[$${cell.name}]"`)
+ .join("\n");
+ const { stdout, stderr, exitCode } = await $`${{ raw: script }}`;
+ expect(stdout.toString()).toBe(
+ cells
+ .filter(cell => cell.result !== "!")
+ .map(cell => `${cell.id} .${cell.result}. [${cell.after}]\n`)
+ .join(""),
+ );
+ expect(stderr.toString()).toBe(
+ cells
+ .filter(cell => cell.result === "!")
+ .map(cell => cell.error)
+ .join(""),
+ );
+ expect(exitCode).toBe(0);
+ });
+
+ test.each([
+ ["an assignment", (form: string) => `VALUE=${form}; `, (result: string) => result],
+ ["an assignment in double quotes", (form: string) => `VALUE=".${form}."; `, (result: string) => `.${result}.`],
+ // An assignment before a command is for that command only.
+ ["an assignment before a command", (form: string) => `VALUE=${form} true; `, () => "none"],
+ ])("every operator in the value of %s", async (_name, assign, value) => {
+ const script = cells
+ .map(cell => `${cell.setup}VALUE=none; ${assign(cell.form)}echo "${cell.id} [$VALUE] [$${cell.name}]"`)
+ .join("\n");
+ const { stdout, stderr, exitCode } = await $`${{ raw: script }}`;
+ expect(stdout.toString()).toBe(
+ cells
+ .map(cell => `${cell.id} [${cell.result === "!" ? "none" : value(cell.result)}] [${cell.after}]\n`)
+ .join(""),
+ );
+ expect(stderr.toString()).toBe(
+ cells
+ .filter(cell => cell.result === "!")
+ .map(cell => cell.error)
+ .join(""),
+ );
+ expect(exitCode).toBe(0);
+ });
+
+ test("in a [[ ]] operand", async () => {
+ const script = cells
+ .map(cell => `${cell.setup}[[ .${cell.form}. == .${cell.result}. ]] && echo "${cell.id}"`)
+ .join("\n");
+ const { stdout, stderr } = await $`${{ raw: script }}`;
+ expect(stdout.toString()).toBe(
+ cells
+ .filter(cell => cell.result !== "!")
+ .map(cell => `${cell.id}\n`)
+ .join(""),
+ );
+ expect(stderr.toString()).toBe(
+ cells
+ .filter(cell => cell.result === "!")
+ .map(cell => cell.error)
+ .join(""),
+ );
+ });
+
+ // What changes "$NAME" (#44904) must change each of these with it.
+ test('a form that expands to nothing in double quotes is what "$NAME" is', async () => {
+ const spellings = [
+ ...['"${U}"', '"${E}"', '"${9}"', '"${U:-}"', '"${U-}"', '"${E:-}"', '"${E-x}"', '"${9:-}"'],
+ ...['"${U:+x}"', '"${U+x}"', '"${E:+x}"', '"${E+}"', '"${E=x}"', '"${E?x}"'],
+ ...['"${U:-$U}"', '"${U:-${U}}"', '"${U:-$(true)}"', '"${U:-""}"', '${U:-"$U"}', '${U:-"$(true)"}'],
+ ...['"${SHELL_TEST_A:=}"', '"${SHELL_TEST_B=}"', '~"${U:-}"'],
+ ];
+ const echo = (word: string) => `echo a ${word} b`;
+ const bare = (word: string) => (word.startsWith("~") ? '~"$U"' : word.includes("E") ? '"$E"' : '"$U"');
+ const run = (script: string) => $`E=; ${{ raw: script }}`.text();
+ expect(await run(spellings.map(echo).join("\n"))).toBe(await run(spellings.map(bare).map(echo).join("\n")));
+ // A `""` in a word that is not in double quotes is one empty argument.
+ expect(await argvOf('a ${U:-""} ${U:-\'\'} "${U:-}""" b')).toEqual(["a", "", "", "", "b"]);
+ });
+
+ // Whitespace in the word ends no argument there.
+ raw(
+ [
+ '[[ ${U:-a b} == "a b" ]] && echo 1',
+ "[[ ${U:-a b} != ab ]] && echo 2",
+ '[[ ${U:-x $(echo a b)} == "x a b" ]] && echo 3',
+ "[[ -n ${U:- } ]] && echo 4",
+ "touch myfile; [[ -f ${U:-my file} ]] || echo 5",
+ '[[ ${S:=a b} == "a b" ]] && echo 6',
+ ].join("\n"),
+ )
+ .ensureTempDir()
+ .stdout("1\n2\n3\n4\n5\n6\n")
+ .runAsTest("a [[ ]] operand is one string");
+
+ raw("echo hi > out_${SHELL_TEST_UNSET:-a}.txt; SET=b; echo hi > out_${SET:+c}.txt")
+ .ensureTempDir()
+ .fileEquals("out_a.txt", "hi\n")
+ .fileEquals("out_c.txt", "hi\n")
+ .runAsTest("in a redirect target");
+ raw("echo hi > ${U:-a b}")
+ .ensureTempDir()
+ .exitCode(1)
+ .stderr("bun: ambiguous redirect: at `echo`\n")
+ .doesNotExist("a")
+ .runAsTest("a redirect target of two words is ambiguous");
+
+ test("a positional parameter", async () => {
+ const { stdout } = await $`echo ${{ raw: "${0:+zero} ${0:-none} ${9:-none} ${9:+nine}." }}`;
+ expect(stdout.toString()).toBe(`zero ${process.argv[0]} none .\n`);
+ });
+
+ describe("the word", () => {
+ test("unquoted whitespace ends an argument", async () => {
+ expect(await argvOf("${U:-a b} ${U:- a\tb } pre${U:- a }post ${U:-} x${U:-}y")).toEqual([
+ ...["a", "b"],
+ ...["a", "b"],
+ ...["pre", "a", "post"],
+ "xy",
+ ]);
+ expect(await argvOf("${U:-a\nb}")).toEqual(["a", "b"]);
+ });
+
+ test("quotes and escapes keep whitespace", async () => {
+ expect(await argvOf(`"\${U:-a b}" \${U:-'a b' "c d" e\\ f} \${U:-a"b c"d e} \${U:-""} \${U:-''}x`)).toEqual([
+ "a b",
+ "a b",
+ "c d",
+ "e f",
+ "ab cd",
+ "e",
+ "",
+ "x",
+ ]);
+ });
+
+ raw('VALUE=${U:-a b}; echo "[$VALUE]"; export EXPORTED=${U:- c }; echo "[$EXPORTED]"')
+ .stdout("[a b]\n[ c ]\n")
+ .runAsTest("whitespace is text in the value of an assignment");
+
+ test("the value of a variable is one argument", async () => {
+ expect(await argvOf("${S:-d} ${S:+$S} ${U:-$S} ${U:=a b} $U", 'S="a b";')).toEqual([
+ "a b",
+ "a b",
+ "a b",
+ "a b",
+ "a b",
+ ]);
+ });
+
+ test("a $(...)", async () => {
+ expect(
+ await argvOf(
+ '${U:-$(echo c d)} "${U:-$(echo "c d")}" ${U:-`echo e`} pre${U:-$(echo q r)}post ${U:- $(echo s) }post',
+ ),
+ ).toEqual(["c", "d", "c d", "e", "preq", "rpost", "s", "post"]);
+ });
+
+ test("a nested form", async () => {
+ expect(await argvOf("${U:-${V:-z w}} ${U:-${V:-${W:-deep}}} ${U:-${S:+no}${V:-yes}} ${U:-${S%x}}")).toEqual([
+ "z",
+ "w",
+ "deep",
+ "yes",
+ "${S%x}",
+ ]);
+ });
+
+ raw("S=1; echo ${S:-$(touch unused1.txt)} ${U:+$(touch unused2.txt)} ${U:-$(touch used.txt)}.")
+ .ensureTempDir()
+ .stdout("1 .\n")
+ .doesNotExist("unused1.txt")
+ .doesNotExist("unused2.txt")
+ .fileEquals("used.txt", "")
+ .runAsTest("a $(...) runs only when the word is the result");
+
+ raw('echo ${U:-a;b|c&d>ee {
+ expect(
+ await fieldsOf([
+ "~/.cache${HOME:+ force}",
+ "~/x${U:- }",
+ "~/x${U:- ~/y}",
+ '~/a$(echo "b c")',
+ '${HOME:+name ""}',
+ '${U:-"" b}',
+ '${U:-a "" b}',
+ '${U:-"" ""}',
+ '${U:-"" }',
+ '${U:-"" }{a,b}',
+ // A `~` before an expansion that gives nothing is no argument.
+ "~$U",
+ "~${HOME:+ x}",
+ ]),
+ ).toEqual([
+ ["/home/user/.cache", "force"],
+ ["/home/user/x"],
+ ["/home/user/x", "~/y"],
+ ["/home/user/ab", "c"],
+ ["name", ""],
+ ["", "b"],
+ ["a", "", "b"],
+ ["", ""],
+ [""],
+ ["", "a", "b"],
+ [],
+ ["x"],
+ ]);
+ });
+
+ // The word expands in the same way with a `$(...)` in it.
+ test("a $(...) in the word changes no other part of it", async () => {
+ using dir = tempDir("shell-word-subst", { "a.txt": "", "src/x.ts": "" });
+ const pairs: [string, string, string[]][] = [
+ ["pre${U:- a $E}post", "pre${U:- a $(true)}post", ["pre", "a", "post"]],
+ ["a${U:-$E b}", "a${U:-$(true) b}", ["a", "b"]],
+ ["x${U:-$E a}", "x${U:-$E $(echo a)}", ["x", "a"]],
+ ["${U:-a $E}y", "${U:-$(echo a) $E}y", ["a", "y"]],
+ ["x=${U:-$E y}", "x=${U:-$(true) y}", ["x=", "y"]],
+ ["a${U:-${V:- b}}", "a${U:-${V:- b}$(true)}", ["a", "b"]],
+ ["${U:-${V:-b }}c", "${U:-$(true)${V:-b }}c", ["b", "c"]],
+ ["${U:-~/bin /opt/bin}", "${U:-~/bin $(echo /opt/bin)}", ["/home/user/bin", "/opt/bin"]],
+ ["${U:-~abc}", "${U:-~abc$(echo)}", ["~abc"]],
+ ["${U:-~}", "${U:-~}$(true)", ["/home/user"]],
+ ["${U:-src/*.ts}", "${U:-$(echo src)/*.ts}", [join("src", "x.ts")]],
+ ["${U:-a.*}", "${U:-$(echo a).*}", ["a.txt"]],
+ ["${U:-*.txt}$E", "${U:-*.txt$(true)}", ["a.txt"]],
+ ];
+ const expected = pairs.map(([, , fields]) => fields);
+ expect(
+ await fieldsOf(
+ pairs.map(([plain]) => plain),
+ String(dir),
+ ),
+ ).toEqual(expected);
+ expect(
+ await fieldsOf(
+ pairs.map(([, withSubst]) => withSubst),
+ String(dir),
+ ),
+ ).toEqual(expected);
+ });
+
+ raw('echo ${U:-*.txt} "${U:-*.txt}" ${U:-a.*}')
+ .ensureTempDir()
+ .file("a.txt", "")
+ .file("b.txt", "")
+ .stdout(stdout =>
+ expect(stdout.split(/\s+/).filter(Boolean).sort()).toEqual(["*.txt", "a.txt", "a.txt", "b.txt"]),
+ )
+ .runAsTest("an unquoted * is a glob");
+
+ raw("S=x; echo ${U:-*.none}; echo ${S:-*.none} ${U:+*.none} ${U:-a }")
+ .ensureTempDir()
+ .stdout("x a\n")
+ .stderr("bun: no matches found: *.none\n")
+ .runAsTest("a * is a glob only in the word that is the result");
+
+ // The rule for the last field is for a word with a form. A word with no form has its one walk for the last field
+ // as before, also when the `*` is in a field before it. sh walks the pattern of each field.
+ test("a * in a word with no form is as before", async () => {
+ using dir = tempDir("shell-word-no-form", { "qa": "" });
+ const run = async (word: string) => {
+ const { stdout, stderr, exitCode } = await $`echo ${{ raw: word }}`.cwd(String(dir)).quiet();
+ return [stdout.toString(), stderr.toString(), exitCode];
+ };
+ expect([await run("*$(echo a b)"), await run("*$(echo a qa)"), await run("*$(echo a b){x,y}")]).toEqual([
+ ["", "bun: no matches found: b\n", 1],
+ ["*a qa\n", "", 0],
+ ["", "bun: no matches found: b{x,y}\n", 1],
+ ]);
+ });
+
+ test("whitespace at the end of a word adds no empty argument", async () => {
+ expect(await argvOf("${U:-a}${U:-b c}d ${U:-a }${U:-} ${U:- } x${U:-b } ${U:-$(echo c) }")).toEqual([
+ "ab",
+ "cd",
+ "a",
+ "xb",
+ "c",
+ ]);
+ });
+
+ // A `{a,b}` or a `*` expands only in the last field of an argument. sh expands it in each field.
+ test("a brace group or a glob in a field that whitespace of a word ends is text", async () => {
+ expect(await argvOf("{a,b}${U:-c } *.none${U:-d } *.none${U:- e}")).toEqual([
+ "{a,b}c",
+ "*.noned",
+ "*.none",
+ "e",
+ ]);
+ });
+ test.failing("a brace group in a field that whitespace of a word ends", async () => {
+ expect(await argvOf("{a,b}${U:-c }")).toEqual(["ac", "bc"]);
+ });
+ test.failing("a glob in a field that whitespace of a word ends", async () => {
+ using dir = tempDir("shell-word-glob", { "a.txt": "" });
+ const { stdout } = await $`${BUN} -e ${printArgv} ${{ raw: "*.txt${U:- }" }}`.cwd(String(dir));
+ expect(JSON.parse(stdout.toString())).toEqual(["a.txt"]);
+ });
+
+ raw('S=é; echo ${S:-ü} ${U:-ü ö} "${U:-ü ö}" é${U:+ü}é')
+ .stdout("é ü ö ü ö éé\n")
+ .runAsTest("text that is not ASCII");
+ });
+
+ describe("= and :=", () => {
+ raw('echo ${A:=one} ${A:=two} $A; B=; echo "[${B=no}]" "[${B:=yes}]" $B')
+ .stdout("one one one\n[] [yes] yes\n")
+ .runAsTest("assign once");
+ raw('S=1; true ${A:=$(echo a) b$S ${S:+c}}; echo "$A"')
+ .stdout("a b1 c\n")
+ .runAsTest("the value is the expanded word");
+ raw('(echo ${A:=sub}); echo $(echo ${B:=subst}) "[$A$B]"; echo ${C:=pipe} | cat; echo "[$C]"')
+ .stdout("sub\nsubst []\npipe\n[]\n")
+ .runAsTest("not outside a subshell, a $(...) or a pipeline");
+ raw('true ${A:=v}; echo "[$A]"').stdout("[v]\n").runAsTest("in the argument of a command that ignores it");
+ test("a quote or a * in the word is not syntax of the argument", async () => {
+ using dir = tempDir("shell-assign-word", { "a.txt": "" });
+ const { stdout } = await $`${BUN} -e ${printArgv} ${{ raw: "${A:=\"\"} ${B=''} ${C:=*.txt} x" }}`.cwd(
+ String(dir),
+ );
+ expect(JSON.parse(stdout.toString())).toEqual(["*.txt", "x"]);
+ });
+ raw("echo ${9:=v}; echo after")
+ .stdout("after\n")
+ .stderr("bun: $9: cannot assign in this way\n")
+ .runAsTest("not to a positional parameter");
+ });
+
+ describe("? and :?", () => {
+ raw('true "${U:?}" && echo RAN')
+ .exitCode(1)
+ .stderr("bun: U: parameter null or not set\n")
+ .runAsTest("the command does not run and what follows && does not");
+ raw("echo ${U?}; E=; echo ${E:?}; echo ${9:?}; echo ${U:?need $E U here}")
+ .exitCode(1)
+ .stderr(
+ "bun: U: parameter not set\nbun: E: parameter null or not set\nbun: 9: parameter null or not set\nbun: U: need U here\n",
+ )
+ .runAsTest("the message");
+ raw('echo "${U:?need $(echo that) U}"')
+ .exitCode(1)
+ .stderr("bun: U: need that U\n")
+ .runAsTest("the message from a word in double quotes");
+ raw("echo a; echo ${U:?boom}; echo b; echo ${U:?boom} || echo c")
+ .stdout("a\nb\nc\n")
+ .stderr("bun: U: boom\nbun: U: boom\n")
+ .runAsTest("only that command fails");
+ raw("VALUE=${U:?one}; VALUE=${U:?two} echo cmd; echo after")
+ .stdout("after\n")
+ .stderr("bun: U: one\nbun: U: two\n")
+ .runAsTest("in the value of an assignment");
+ raw("VALUE=${U:?one}").exitCode(1).stderr("bun: U: one\n").runAsTest("an assignment alone has exit code 1");
+ raw("VALUE=${U:?two} echo cmd")
+ .exitCode(1)
+ .stderr("bun: U: two\n")
+ .runAsTest("an assignment before a command has exit code 1 and the command does not run");
+ raw("VALUE=${U:?one}; VALUE=${U:?two} echo cmd; echo after")
+ .quiet()
+ .stdout("after\n")
+ .stderr("bun: U: one\nbun: U: two\n")
+ .runAsTest("in the value of an assignment, with .quiet()");
+ raw("A=1 B=${U:?stop} C=2; echo $A-$C; VALUE=${U:?one} || echo or")
+ .stdout("1-\nor\n")
+ .stderr("bun: U: stop\nbun: U: one\n")
+ .runAsTest("the assignments before it are made and the ones after it are not");
+ // 16 alternatives in each of 5 groups are 1048576 words, over the limit of 65536.
+ raw("VALUE=" + Buffer.alloc(33 * 5, "{a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p}").toString() + "; echo after")
+ .stdout("after\n")
+ .stderr("bun: too many brace expansions (1048576 > 65536)\n")
+ .runAsTest("another error in the value of an assignment is written too");
+ raw("echo hi > ${U:?no file}.txt; echo ${U:-$(echo ${U:?inner})} after")
+ .ensureTempDir()
+ .stdout("after\n")
+ .stderr("bun: U: no file\nbun: U: inner\n")
+ .doesNotExist(".txt")
+ .runAsTest("in a redirect target and in a $(...)");
+ raw("echo ${U:-x${U:?nested}} after; echo ${U:-$(echo y)${U:?beside a substitution}} after")
+ .exitCode(1)
+ .stderr("bun: U: nested\nbun: U: beside a substitution\n")
+ .runAsTest("in the word of another form");
+ raw("S=1; echo ${S:?no} ${S?no} ${U:-${S:?no}} ${S:+${U:-ok}}")
+ .stdout("1 1 1 ok\n")
+ .runAsTest("set is the value");
+ });
+
+ // sh reads the `A` of the same command. Bun's shell does not, for $A too.
+ test("an assignment before a command is not read by a later one, like $NAME", async () => {
+ const printB = "console.log(process.env.B)";
+ const run = (value: string) => $`A=x ${{ raw: "B=[" + value + "]" }} ${BUN} -e ${printB}`.text();
+ expect([await run("$A"), await run("${A}"), await run("${A:-none}")]).toEqual(["[]\n", "[]\n", "[none]\n"]);
+ });
+
+ // `$` reads the raw template, so an escaped `${` is text. A `{ raw }` value carries a form.
+ test("in a template literal", async () => {
+ const { stdout, stderr, exitCode } =
+ await $`FOO=bar; echo \${FOO} \${FOO:-d} "\${NONE:?}" ${{ raw: "${FOO:-d} ${NONE:-d}" }}`.quiet();
+ expect(stderr.toString()).toBe("");
+ expect(stdout.toString()).toBe("${FOO} ${FOO:-d} ${NONE:?} bar d\n");
+ expect(exitCode).toBe(0);
+ });
+
+ test("reads an environment set with .env()", async () => {
+ const words = { raw: "${FOO:-none} ${BAR:-none} ${FOO:+set}" };
+ const { stdout, stderr, exitCode } = await $`echo ${words}`.env({ ...bunEnv, FOO: "bar" });
+ expect(stderr.toString()).toBe("");
+ expect(stdout.toString()).toBe("bar none set\n");
+ expect(exitCode).toBe(0);
+ });
+
+ // A script with a form that does not end runs with every `${` as text.
+ raw("echo ${U:-a} $(echo ${U:-b) c}; echo next")
+ .stdout("${U:-a} ${U:-b c}\nnext\n")
+ .runAsTest("a form that does not end makes every form text");
+ // As text, a guard stops nothing. So a script that has one does not run.
+ raw('true "${U:?}" && echo RAN; echo ${V:-z')
+ .error("Unclosed parameter expansion")
+ .runAsTest("a form that does not end is an error in a script with a ? form");
+ test("forms nest to the depth of $(...)", async () => {
+ const repeat = (text: string, count: number) => Buffer.alloc(text.length * count, text).toString();
+ const nested = (depth: number) => ({ raw: repeat("${A:-", depth) + "x" + repeat("}", depth) });
+ expect(await $`echo ${nested(128)}`.text()).toBe("x\n");
+ // A form past the limit is text.
+ expect(await $`echo ${nested(129)}`.text()).toBe("${A:-x}\n");
+ // A form counts as one level of $(...).
+ const inSubst = (depth: number) => ({ raw: repeat("$(echo ", depth) + "${A:-x}" + repeat(")", depth) });
+ expect(await $`echo ${inSubst(127)}`.text()).toBe("x\n");
+ expect(await $`echo ${inSubst(128)}`.text()).toBe("${A:-x}\n");
+ // The forms after it are read: a guard still stops its command.
+ const guard = { raw: 'true "${SHELL_TEST_UNSET:?}" && echo RAN' };
+ const { stdout, stderr, exitCode } = await $`echo ${nested(129)}; ${guard}`.nothrow().quiet();
+ expect(stderr.toString()).toBe("bun: SHELL_TEST_UNSET: parameter null or not set\n");
+ expect(stdout.toString()).toBe("${A:-x}\n");
+ expect(exitCode).toBe(1);
+ });
+ });
});
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..b34c9d92f20b 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", "none one", ""]);
+ 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..a9008c5f186f 100644
--- a/test/js/bun/shell/exec.test.ts
+++ b/test/js/bun/shell/exec.test.ts
@@ -27,6 +27,44 @@ describe("bun exec", () => {
.stdout('hi "there bud"\n')
.runAsTest("it works2");
+ // The scripts read NONE as not set.
+ const paramEnv = { ...bunEnv, NONE: undefined };
+
+ TestBuilder.command`${BUN} exec ${'FOO=bar; echo ${FOO} "${FOO}" ${FOO}baz'}`
+ .env(paramEnv)
+ .stdout("bar bar barbaz\n")
+ .runAsTest("expands ${NAME}");
+
+ TestBuilder.command`${BUN} exec ${'FOO=bar; echo ${FOO:-none} ${NONE:-"a b"} ${FOO:+set}; echo ${NONE:=made} $NONE'}`
+ .env(paramEnv)
+ .stdout("bar a b set\nmade made\n")
+ .runAsTest("expands ${NAMEword}");
+
+ TestBuilder.command`${BUN} exec ${'true "${NONE:?need NONE}" && echo RAN'}`
+ .env(paramEnv)
+ .stderr("bun: NONE: need NONE\n")
+ .exitCode(1)
+ .runAsTest("${NAME:?word} fails the command when NAME is not set");
+
+ TestBuilder.command`${BUN} exec ${"ENV=${NONE:?} echo cmd"}`
+ .env(paramEnv)
+ .stderr("bun: NONE: parameter null or not set\n")
+ .exitCode(1)
+ .runAsTest("${NAME:?word} in an assignment before a command");
+
+ TestBuilder.command`${BUN} script.sh`
+ .file("script.sh", "ENV=${NONE:?need NONE}\n")
+ .env(paramEnv)
+ .stderr("bun: NONE: need NONE\n")
+ .exitCode(1)
+ .runAsTest("${NAME:?word} in an assignment in a .sh file");
+
+ TestBuilder.command`${BUN} script.sh`
+ .file("script.sh", 'FOO=bar\necho ${FOO} "${FOO}" ${FOO}baz ${NONE:-é ${FOO:+ü}}\n')
+ .env(paramEnv)
+ .stdout("bar bar barbaz é ü\n")
+ .runAsTest("expands ${NAME} and ${NAMEword} 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..b64bad288941
--- /dev/null
+++ b/test/js/bun/shell/fixtures/positionals3.bun.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+
+echo ${0}
+echo ${1}
+echo ${2}${2}
+echo ${1}0
+echo "${3}"
+echo ${4:-none} ${1:+one}
diff --git a/test/js/bun/shell/lex.test.ts b/test/js/bun/shell/lex.test.ts
index aebe12bc91a2..2cba6978c454 100644
--- a/test/js/bun/shell/lex.test.ts
+++ b/test/js/bun/shell/lex.test.ts
@@ -821,6 +821,474 @@ 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("${...}", () => {
+ const tokens = (source: string) => JSON.parse(lex({ raw: [source] }));
+ const param = (op: string, target: string | number, word: object[]) => [
+ { ParamBegin: op },
+ typeof target === "number" ? { VarArgv: target } : { Var: target },
+ ...word,
+ { ParamEnd: {} },
+ ];
+ const operators = [":-", "-", ":+", "+", ":=", "=", ":?", "?"];
+
+ 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"],
+ ["in the word of another form", "${X:-${Y}}", "${X:-$Y}"],
+ ["in the word of another form, in double quotes", '"${X:-${Y}}"', '"${X:-$Y}"'],
+ ])("${NAME} gives the tokens of $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);
+ });
+
+ test.each(operators)("${NAME%sword}", op => {
+ expect(tokens("${X" + op + "w}")).toEqual([...param(op, "X", [{ Text: "w" }]), { Eof: {} }]);
+ expect(tokens('"${X' + op + 'w}"')).toEqual([...param(op, "X", [{ DoubleQuotedText: "w" }]), { Eof: {} }]);
+ expect(tokens("${1" + op + "}")).toEqual([...param(op, 1, []), { Eof: {} }]);
+ });
+
+ test.each([
+ [
+ "unquoted whitespace",
+ "${X:- a\tb\nc }",
+ [
+ { ParamSpace: " " },
+ { Text: "a" },
+ { ParamSpace: "\t" },
+ { Text: "b" },
+ { ParamSpace: "\n" },
+ { Text: "c" },
+ { ParamSpace: " " },
+ ],
+ ],
+ [
+ "quotes",
+ "${X:-'a b' \"c d\"}",
+ [{ SingleQuotedText: "a b" }, { ParamSpace: " " }, { DoubleQuotedText: "c d" }],
+ ],
+ ["empty quotes", "${X:-''\"\"}", [{ SingleQuotedText: "" }, { DoubleQuotedText: "" }]],
+ [
+ "a } in quotes",
+ "${X:-a'b}c'\"d}e\"}",
+ [{ Text: "a" }, { SingleQuotedText: "b}c" }, { DoubleQuotedText: "d}e" }],
+ ],
+ ["escapes", "${X:-a\\}b\\ c\\$d}", [{ Text: "a}b c$d" }]],
+ ["a line continuation", "${X:-a\\\nb}", [{ Text: "ab" }]],
+ ["variables", "${X:-$Y$1 z}", [{ Var: "Y" }, { VarArgv: 1 }, { ParamSpace: " " }, { Text: "z" }]],
+ ["a variable in double quotes", '${X:-"$Y"}', [{ Var: "Y" }]],
+ ["a $ with no name", "${X:-$ $}", [{ Text: "$" }, { ParamSpace: " " }, { Text: "$" }]],
+ ["a glob", "${X:-*.js **}", [{ Asterisk: {} }, { Text: ".js" }, { ParamSpace: " " }, { DoubleAsterisk: {} }]],
+ [
+ "operators, braces and a # are text",
+ "${X:-a;b|c&df(g)#h{i,j[[k]]}",
+ [{ Text: "a;b|c&df(g)#h{i,j[[k]]" }],
+ ],
+ ["a tilde is text for the parser", "${X:-~/a}", [{ Text: "~/a" }]],
+ [
+ "$(...)",
+ "${X:-$(echo a) b}",
+ [
+ { CmdSubstBegin: {} },
+ { Text: "echo" },
+ { Delimit: {} },
+ { Text: "a" },
+ { Delimit: {} },
+ { CmdSubstEnd: {} },
+ { ParamSpace: " " },
+ { Text: "b" },
+ ],
+ ],
+ ["backticks", "${X:-`echo`}", [{ CmdSubstBegin: {} }, { Text: "echo" }, { Delimit: {} }, { CmdSubstEnd: {} }]],
+ [
+ "a nested form",
+ "${X:-a${Y:+b ${Z}}c}",
+ [{ Text: "a" }, ...param(":+", "Y", [{ Text: "b" }, { ParamSpace: " " }, { Var: "Z" }]), { Text: "c" }],
+ ],
+ ["a nested form that is not read keeps its }", "${X:-${Y%z}}", [{ Text: "$" }, { Text: "{Y%z}" }]],
+ ["an escaped nested form does not", "${X:-\\${Y}", [{ Text: "${Y" }]],
+ ])("the word: %s", (_name, source, word) => {
+ expect(tokens(source)).toEqual([...param(":-", "X", word), { Eof: {} }]);
+ });
+
+ // `$?` and the like are not read yet. What reads them must read them in a word too.
+ test.each(["$?", "$$", "$#", "$@", "$!", "$-", "$*", "$=", "$", "$NAME", "$1", "$9x", "${Y}", "${Y:-z}", "$(a)"])(
+ "the word: %s gives the tokens that it gives outside of a form",
+ dollar => {
+ const outside = tokens(dollar);
+ while ("Delimit" in outside.at(-1) || "Eof" in outside.at(-1)) outside.pop();
+ expect(tokens("${X:-" + dollar + "}")).toEqual([...param(":-", "X", outside), { Eof: {} }]);
+ },
+ );
+
+ test.each([
+ ["text and whitespace", '"${X:-a b}"', [{ DoubleQuotedText: "a b" }]],
+ ["single quotes are text", '"${X:-\'a}"', [{ DoubleQuotedText: "'a" }]],
+ ["inner double quotes keep a }", '"${X:-a"b}c"d}"', [{ DoubleQuotedText: "ab}cd" }]],
+ ["a backslash keeps a }", '"${X:-a\\}b}"', [{ DoubleQuotedText: "a}b" }]],
+ ["a glob is text", '"${X:-*}"', [{ DoubleQuotedText: "*" }]],
+ [
+ "$(...)",
+ '"${X:-$(echo)}"',
+ [{ CmdSubstBegin: {} }, { CmdSubstQuoted: {} }, { Text: "echo" }, { Delimit: {} }, { CmdSubstEnd: {} }],
+ ],
+ ])("the word in double quotes: %s", (_name, source, word) => {
+ expect(tokens(source)).toEqual([...param(":-", "X", word), { Eof: {} }]);
+ });
+
+ test.each([
+ [
+ "inside a word",
+ "pre${X:-d}post",
+ [{ Text: "pre" }, ...param(":-", "X", [{ Text: "d" }]), { Text: "post" }, { Delimit: {} }, { Eof: {} }],
+ ],
+ [
+ "before a space",
+ "${X:-a} b",
+ [...param(":-", "X", [{ Text: "a" }]), { Delimit: {} }, { Text: "b" }, { Delimit: {} }, { Eof: {} }],
+ ],
+ [
+ "before a semicolon",
+ "${X:-a};b",
+ [...param(":-", "X", [{ Text: "a" }]), { Semicolon: {} }, { Text: "b" }, { Delimit: {} }, { Eof: {} }],
+ ],
+ [
+ "before a pipe",
+ "${X:-a}|b",
+ [
+ ...param(":-", "X", [{ Text: "a" }]),
+ { Delimit: {} },
+ { Pipe: {} },
+ { Text: "b" },
+ { Delimit: {} },
+ { Eof: {} },
+ ],
+ ],
+ [
+ "two forms",
+ "${X:-a}${Y:-b}",
+ [...param(":-", "X", [{ Text: "a" }]), ...param(":-", "Y", [{ Text: "b" }]), { Eof: {} }],
+ ],
+ [
+ "in $(...)",
+ "$(echo ${X:-a})",
+ [
+ { CmdSubstBegin: {} },
+ { Text: "echo" },
+ { Delimit: {} },
+ ...param(":-", "X", [{ Text: "a" }]),
+ { Delimit: {} },
+ { CmdSubstEnd: {} },
+ { Eof: {} },
+ ],
+ ],
+ [
+ "in a script that is not ASCII",
+ "é${X:-ü ö}é",
+ [
+ { Text: "é" },
+ ...param(":-", "X", [{ Text: "ü" }, { ParamSpace: " " }, { Text: "ö" }]),
+ { Text: "é" },
+ { Delimit: {} },
+ { Eof: {} },
+ ],
+ ],
+ ])("a form beside other tokens: %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: {} }]],
+ ["an escaped $ before an operator", "\\${X:-d}", literal({ Text: "X:-d" })],
+ [
+ "an escaped { before an operator",
+ "$\\{X:-d}",
+ [{ Text: "$" }, { Text: "{X:-d" }, { BraceEnd: {} }, { Eof: {} }],
+ ],
+ ["an operator in single quotes", "'${X:-d}'", [{ SingleQuotedText: "${X:-d}" }, { 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 read them:
+ // the `$` is text and the rest is lexed as ordinary shell syntax.
+ test.each([
+ ["a suffix pattern", "${X%a}", literal({ Text: "X%a" })],
+ ["a substitution", "${X/a/b}", literal({ Text: "X/a/b" })],
+ ["a substring", "${X:1:2}", literal({ Text: "X:1:2" })],
+ ["a : with no operator", "${X:}", literal({ Text: "X:" })],
+ ["a length", "${#X}", literal({ Text: "#X" })],
+ ["an indirection", "${!X}", literal({ Text: "!X" })],
+ ["two digits", "${10}", literal({ Text: "10" })],
+ ["two digits before an operator", "${10:-a}", literal({ Text: "10:-a" })],
+ ["no name", "${}", literal()],
+ ["no name before an operator", "${:-a}", literal({ Text: ":-a" })],
+ ["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" })],
+ ["$?", "$?", [{ Text: "$" }, { Text: "?" }, { Delimit: {} }, { Eof: {} }]],
+ ["$$", "$$", [{ Text: "$" }, { Text: "$" }, { Eof: {} }]],
+ ])("not read, tokens unchanged: %s", (_name, source, expected) => {
+ expect(tokens(source)).toEqual(expected);
+ });
+
+ // A script with a form that does not end is lexed with every `${` as text.
+ test.each([
+ ["no closing brace", "${X", [{ Text: "$" }, { BraceBegin: {} }, { Text: "X" }, { Delimit: {} }, { Eof: {} }]],
+ [
+ "no closing brace after a word",
+ "${X:-a",
+ [{ Text: "$" }, { BraceBegin: {} }, { Text: "X:-a" }, { Delimit: {} }, { Eof: {} }],
+ ],
+ [
+ "no closing brace in double quotes",
+ '"${X:-a',
+ [{ DoubleQuotedText: "$" }, { DoubleQuotedText: "{X:-a" }, { Delimit: {} }, { Eof: {} }],
+ ],
+ [
+ "the closing brace is in quotes",
+ "${X:-'a}",
+ [{ Text: "$" }, { BraceBegin: {} }, { Text: "X:-" }, { SingleQuotedText: "a}" }, { Delimit: {} }, { Eof: {} }],
+ ],
+ [
+ "a form that ends, before one that does not",
+ "${A:-b} ${X:-a",
+ [
+ { Text: "$" },
+ { BraceBegin: {} },
+ { Text: "A:-b" },
+ { BraceEnd: {} },
+ { Delimit: {} },
+ { Text: "$" },
+ { BraceBegin: {} },
+ { Text: "X:-a" },
+ { Delimit: {} },
+ { Eof: {} },
+ ],
+ ],
+ [
+ "no closing brace in $(...)",
+ "$(echo ${X:-a)",
+ [
+ { CmdSubstBegin: {} },
+ { Text: "echo" },
+ { Delimit: {} },
+ { Text: "$" },
+ { BraceBegin: {} },
+ { Text: "X:-a" },
+ { Delimit: {} },
+ { CmdSubstEnd: {} },
+ { Eof: {} },
+ ],
+ ],
+ [
+ "no closing brace in backticks",
+ "`echo ${X:-a`",
+ [
+ { CmdSubstBegin: {} },
+ { Text: "echo" },
+ { Delimit: {} },
+ { Text: "$" },
+ { BraceBegin: {} },
+ { Text: "X:-a" },
+ { Delimit: {} },
+ { CmdSubstEnd: {} },
+ { Eof: {} },
+ ],
+ ],
+ [
+ "a backtick in the word ends the backticks around the form",
+ "`echo ${X:-a`true`echo b}`",
+ [
+ { CmdSubstBegin: {} },
+ { Text: "echo" },
+ { Delimit: {} },
+ { Text: "$" },
+ { BraceBegin: {} },
+ { Text: "X:-a" },
+ { Delimit: {} },
+ { CmdSubstEnd: {} },
+ { CmdSubstBegin: {} },
+ { Text: "trueecho" },
+ { Delimit: {} },
+ { Text: "b" },
+ { BraceEnd: {} },
+ { Delimit: {} },
+ { CmdSubstEnd: {} },
+ { Eof: {} },
+ ],
+ ],
+ [
+ "no closing brace for ${NAME} in $(...)",
+ "$(echo ${X)",
+ [
+ { CmdSubstBegin: {} },
+ { Text: "echo" },
+ { Delimit: {} },
+ { Text: "$" },
+ { BraceBegin: {} },
+ { Text: "X" },
+ { Delimit: {} },
+ { CmdSubstEnd: {} },
+ { Eof: {} },
+ ],
+ ],
+ ])("a form that does not end is text: %s", (_name, source, expected) => {
+ expect(tokens(source)).toEqual(expected);
+ });
+
+ test("a script with a form that does not end has the error it had as text", () => {
+ expect(() => lex({ raw: ["${X:-$(echo}"] })).toThrow("Unclosed command substitution");
+ expect(() => lex({ raw: ["${X:-a} |& b"] })).toThrow("Piping stdout and stderr (`|&`) is not supported yet.");
+ });
+
+ // As text, a `${NAME:?}` guard stops nothing. So a script with a guard is not lexed again.
+ test.each([
+ ["the guard before a form that does not end", 'true "${X:?}"; echo ${Y:-z'],
+ ["the guard after a form that does not end", "echo ${Y:-z; echo ${X?need X}"],
+ ["the guard that does not end", "echo ${X:?need X"],
+ ["the guard in the form that does not end", "echo ${Y:-${X?}"],
+ ["a backtick in a word in backticks", "${X:?}; `echo ${Y:-a`"],
+ ])("a form that does not end is an error in a script with a ? form: %s", (_name, source) => {
+ expect(() => lex({ raw: [source] })).toThrow("Unclosed parameter expansion");
+ });
+
+ test("the errors of a script with a ? form", () => {
+ expect(() => lex({ raw: ["${X:?}", ""] }, new Uint8Array(1))).not.toThrow();
+ expect(() => lex({ raw: ["${X:?} ${Y:-", "}"] }, new Uint8Array(1))).toThrow(
+ "JS object reference not allowed in a parameter expansion",
+ );
+ const repeat = (text: string, count: number) => Buffer.alloc(text.length * count, text).toString();
+ expect(() => lex({ raw: [repeat("${A?", 129) + repeat("}", 129)] })).toThrow("SubshellDepthExceeded");
+ });
+
+ test("forms nest to the depth of $(...), and deeper forms are text", () => {
+ const forms = (source: string) => tokens(source).filter((token: object) => "ParamBegin" in token);
+ const repeat = (text: string, count: number) => Buffer.alloc(text.length * count, text).toString();
+ const nested = (depth: number) => repeat("${A:-", depth) + "x" + repeat("}", depth);
+ expect(forms(nested(128))).toHaveLength(128);
+ expect(forms(nested(129))).toHaveLength(128);
+ // A form counts as one level of $(...).
+ const inSubst = (depth: number) => repeat("$(echo ", depth) + "${A:-x}" + repeat(")", depth);
+ expect(forms(inSubst(127))).toHaveLength(1);
+ expect(forms(inSubst(128))).toHaveLength(0);
+ // The forms after a form that is text are read.
+ expect(forms(nested(129) + ' "${X:?}"')).toHaveLength(129);
+ });
+
+ // The lexer stopped before the end, so a guard can follow. The script is not lexed again with every `${` as text.
+ test("a $(...) past the nesting limit in the word of a form is an error", () => {
+ const repeat = (text: string, count: number) => Buffer.alloc(text.length * count, text).toString();
+ const tooDeep = repeat("${A:-", 128) + "$(echo x)" + repeat("}", 128);
+ expect(() => lex({ raw: [tooDeep] })).toThrow("SubshellDepthExceeded");
+ expect(() => lex({ raw: [tooDeep + '; true "${X:?}" && echo RAN'] })).toThrow("SubshellDepthExceeded");
+ });
+
+ // An interpolated value is data. It never supplies the name, the operator
+ // or the closing brace of a form 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" })],
+ ["the operator", ["echo ${FOO", "}"], ":-d", literal({ Text: "FOO" }, { Text: ":-d" })],
+ ["the end of the operator", ["echo ${FOO:", "}"], "-d", literal({ Text: "FOO:" }, { Text: "-d" })],
+ ["the word", ["echo ${FOO:-", "}"], "d", [...param(":-", "FOO", [{ Text: "d" }]), { Eof: {} }]],
+ [
+ "a word that looks like a tilde",
+ ["echo ${FOO:-", "}"],
+ "~",
+ [...param(":-", "FOO", [{ DoubleQuotedText: "~" }]), { Eof: {} }],
+ ],
+ [
+ "a word with a closing brace",
+ ["echo ${FOO:-", "}"],
+ "a}b",
+ [...param(":-", "FOO", [{ Text: "a}b" }]), { Eof: {} }],
+ ],
+ [
+ "a word with syntax",
+ ["echo ${FOO:-", "}"],
+ "$(x) `y` $Z *",
+ [...param(":-", "FOO", [{ Text: "$(x) `y` $Z *" }]), { Eof: {} }],
+ ],
+ ])("an interpolated value is not syntax of a form: %s", (_name, raw, value, word) => {
+ expect(JSON.parse(lex({ raw }, value))).toEqual([{ Text: "echo" }, { Delimit: {} }, ...word]);
+ });
+
+ // A value with no special character goes into the script as text. A value
+ // with one goes in as a reference. After any start of a `$` form, both
+ // must give the same tokens apart from text.
+ test("a value that is text and a value that is a reference give the same tokens after a $", () => {
+ const syntax = (source: string[], value: string) => {
+ try {
+ return JSON.parse(lex({ raw: source }, value))
+ .filter((token: object) => !Object.keys(token)[0].endsWith("Text"))
+ .map((token: object) => ("Var" in token || "ParamBegin" in token ? token : Object.keys(token)[0]));
+ } catch (error) {
+ return String(error);
+ }
+ };
+ // A byte that the lexer learns to read after `${NAME` must be here, and in `ends_in_param_head`.
+ const alphabet = ["{", "}", "?", ":", "-", "+", "=", "a", "1", "#", "!", "@", '"', "%", "/", "^"];
+ const short = ["", ...alphabet, ...alphabet.flatMap(first => alphabet.map(second => first + second))];
+ const tails = ["", "{a", "{1"].flatMap(head => short.map(tail => head + tail));
+ const differences = tails.filter(tail => {
+ const source = ["echo $" + tail, "}"];
+ return JSON.stringify(syntax(source, "bc")) !== JSON.stringify(syntax(source, "b c"));
+ });
+ expect(differences).toEqual([]);
+ });
+ });
+
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.
diff --git a/test/js/bun/shell/parse.test.ts b/test/js/bun/shell/parse.test.ts
index ac77b69d54b6..a5fc4551519f 100644
--- a/test/js/bun/shell/parse.test.ts
+++ b/test/js/bun/shell/parse.test.ts
@@ -511,6 +511,102 @@ describe("parse shell", () => {
expect(result).toEqual(expected);
});
+ // `${` in a JS template literal starts an interpolation, so the sources
+ // here go to the parser as a raw array.
+ describe("${...}", () => {
+ const cmd = (source: string) => JSON.parse(parse({ raw: [source] })).stmts[0].exprs[0].cmd;
+ const args = (source: string) => cmd("echo " + source).name_and_args.slice(1);
+ const compound = (atoms: object[], glob_hint = false) => ({
+ compound: { atoms, brace_expansion_hint: false, glob_hint },
+ });
+ const param = (name: string, op: string, word: object[]) => ({ param: { name, op, word } });
+
+ test("${NAME} is the atom of $NAME", () => {
+ expect(args("${X} ${1} a${X}b")).toEqual([
+ { simple: { Var: "X" } },
+ { simple: { VarArgv: 1 } },
+ compound([{ Text: "a" }, { Var: "X" }, { Text: "b" }]),
+ ]);
+ });
+
+ test.each([":-", "-", ":+", "+", ":=", "=", ":?", "?"])("${NAME%sword}", op => {
+ expect(args("${X" + op + "a}")).toEqual([{ simple: param("X", op, [{ Text: "a" }]) }]);
+ });
+
+ test("the parameter, the quotes and the parts of the word", () => {
+ expect(args('"${1:?no $Y}" a${X:-}b ${X:-\'\'"q"$(echo) c}')).toEqual([
+ { simple: { param: { argv: 1, op: ":?", word: [{ Text: "no " }, { Var: "Y" }] } } },
+ compound([{ Text: "a" }, param("X", ":-", []), { Text: "b" }]),
+ {
+ simple: param("X", ":-", [
+ { quoted_empty: {} },
+ { Text: "q" },
+ { cmd_subst: { script: { stmts: [{ exprs: [{ cmd: cmd("echo") }] }] }, quoted: false } },
+ { param_space: " " },
+ { Text: "c" },
+ ]),
+ },
+ ]);
+ });
+
+ test("a * in the word gives the atom the glob hint", () => {
+ expect(args("${X:-*} x${Y:+${X:-a **}} ${X:-a b}")).toEqual([
+ compound([param("X", ":-", [{ asterisk: {} }])], true),
+ compound(
+ [
+ { Text: "x" },
+ param("Y", ":+", [param("X", ":-", [{ Text: "a" }, { param_space: " " }, { double_asterisk: {} }])]),
+ ],
+ true,
+ ),
+ { simple: param("X", ":-", [{ Text: "a" }, { param_space: " " }, { Text: "b" }]) },
+ ]);
+ expect(args('"${X:-a b}" "${X:-*}"')).toEqual([
+ { simple: param("X", ":-", [{ Text: "a b" }]) },
+ { simple: param("X", ":-", [{ Text: "*" }]) },
+ ]);
+ });
+
+ // As in sh: the `~` is `$HOME` when it is the whole word or a `/` follows it.
+ test("a tilde starts the word", () => {
+ expect(args("${X:-~} ${X:-~/a~} ${X:=~}")).toEqual([
+ { simple: param("X", ":-", [{ tilde: {} }]) },
+ { simple: param("X", ":-", [{ tilde: {} }, { Text: "/a~" }]) },
+ { simple: param("X", ":=", [{ tilde: {} }]) },
+ ]);
+ expect(args('${X:-a~} ${X:-~a} ${X:-~$Y} ${X:-~ a} ${X:-~""} ${X:-~"/a"} "${X:-~}"')).toEqual([
+ { simple: param("X", ":-", [{ Text: "a~" }]) },
+ { simple: param("X", ":-", [{ Text: "~a" }]) },
+ { simple: param("X", ":-", [{ Text: "~" }, { Var: "Y" }]) },
+ { simple: param("X", ":-", [{ Text: "~" }, { param_space: " " }, { Text: "a" }]) },
+ { simple: param("X", ":-", [{ Text: "~" }, { quoted_empty: {} }]) },
+ { simple: param("X", ":-", [{ Text: "~" }, { Text: "/a" }]) },
+ { simple: param("X", ":-", [{ Text: "~" }]) },
+ ]);
+ });
+
+ test("in an assignment value and a redirect target", () => {
+ const parsed = cmd("A=${X:-a} B=b${X:+c} echo > ${Y:-f}");
+ expect(parsed.assigns).toEqual([
+ { label: "A", value: { simple: param("X", ":-", [{ Text: "a" }]) } },
+ { label: "B", value: compound([{ Text: "b" }, param("X", ":+", [{ Text: "c" }])]) },
+ ]);
+ expect(parsed.redirect_file).toEqual({ atom: { simple: param("Y", ":-", [{ Text: "f" }]) } });
+ });
+
+ // A form whose word does not parse makes the parser read the script with every `${` as text.
+ test("a script that does not parse with a form is parsed as text", () => {
+ const source = "A=${X:-a; echo b} > f";
+ expect(JSON.parse(parse({ raw: [source] })).stmts[0].exprs[0].assign).toEqual([
+ { label: "A", value: compound([{ Text: "$" }, { brace_begin: {} }, { Text: "X:-a" }]) },
+ ]);
+ // With a `?` form in the script, the error of the pass that read the forms is the result.
+ expect(() => parse({ raw: ["echo ${G?}; " + source] })).toThrow(
+ 'expected a command or assignment but got: "Redirect"',
+ );
+ });
+ });
+
describe("if_clause", () => {
test("basic", () => {
const expected = {
diff --git a/test/js/bun/shell/yield.test.ts b/test/js/bun/shell/yield.test.ts
index 29d96e378199..b6780a53a658 100644
--- a/test/js/bun/shell/yield.test.ts
+++ b/test/js/bun/shell/yield.test.ts
@@ -138,7 +138,7 @@ describe("yield", async () => {
Bun.gc(true);
console.log(JSON.stringify(out));
`),
- env: bunEnv,
+ env: { ...bunEnv, U: undefined },
stdout: "pipe",
stderr: "pipe",
});
@@ -178,6 +178,14 @@ describe("yield", async () => {
);
});
+ // The `$(...)` is in the word of a `${NAMEword}`, which expands only because U is not set.
+ test.concurrent("in a $(...) in the word of a ${NAME:-word}", async () => {
+ await expectRejection(
+ '$`echo ${{ raw: "${U:-a $(echo x > " }}${new Blob(["x"])}${{ raw: "; echo y) b}" }}; echo no`.quiet().nothrow()',
+ builtin,
+ );
+ });
+
// This one always rejected (the error comes straight out of `.run()`), but
// the interpreter was never finished and kept the event loop alive forever.
test.concurrent("first command, and the process still exits", async () => {
@@ -207,6 +215,31 @@ describe("yield", async () => {
});
});
+ // The message of a failed assignment value is not a thrown error: the exit code is 1, as with a stderr
+ // that takes the write, and the script continues.
+ test.concurrent.if(isLinux)("a failed write of the error of an assignment value is exit code 1", async () => {
+ await using proc = Bun.spawn({
+ cmd: child(`
+ const run = script => settle($\`\${{ raw: script }}\`.nothrow());
+ console.log(JSON.stringify([
+ await run("A=\${U:?x}"),
+ await run("A=\${U:?x} true"),
+ await run("A=\${U:?x}; A=\${U:?x} true; true"),
+ ]));
+ `),
+ env: { ...bunEnv, U: undefined },
+ stdout: "pipe",
+ stderr: Bun.file("/dev/full"),
+ });
+
+ const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
+
+ expect({ result: stdout.trim(), exitCode }).toEqual({
+ result: JSON.stringify(["resolved 1", "resolved 1", "resolved 0"]),
+ exitCode: 0,
+ });
+ });
+
// Once every member of a failed script has exited, the interpreter must
// become collectable, not stay pinned until the process exits.
test.concurrent("failed scripts are collected", async () => {