Skip to content

shell: expand ${NAME} the same way as $NAME - #45032

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/5daefec0/shell-braced-var-expansion
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/5daefec0/shell-braced-var-expansion

Conversation

@robobun

@robobun robobun commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • eat_var reads {NAME} or {digit} from the source bytes, so ${NAME} and ${0}..${9} give the tokens of $NAME and $0..$9. Other $ forms lex as before.
  • outbuf_ends_with_var_ref also matches a script that ends in {NAME, so a value interpolated there stays data (Robustness and input-handling pass across install, shell, TLS/QUIC, HTTP/3, SQL and crypto #37669).
  • Verified: test/js/bun/shell/lex.test.ts, bunshell.test.ts and three more files: 66 new tests fail on main. Also ran all 43 shell test files on release builds.
  • Self-reviewed: 11 concerns raised, 10 addressed. Not done: a tracking issue for the other $ forms.

Background

  • The lexer turns $NAME into a Var token, which the parser and interpreter already expand.
  • Bun.$ splices a plain interpolated value into the shell source as text. Other values go in as a reference, which the lexer treats as data.
  • Considered: a port of shell: implement $?, $$, $#, and ${NAME}; reject unsupported expansions #33274 (its ${NAME} tests are reused). Its lex error for other bodies breaks working scripts.

Downsides

Notes

Routes. Every route to the lexer has the defect: bun exec, bun file.sh, bun run with Bun's shell (the default on Windows), and { raw } in Bun.$. In a Bun.$ template literal, ${...} is a JavaScript interpolation, so only { raw } carries the braced form there. The package.json route has a test with --shell=bun and, for parity, --shell=system.

Demand. No issue or discussion reports this. The defect was found by inspection during the work on #44904. Two places show it without a report: a transcript in #9716 (a comment of 2025-07-25) where bun bun.sh prints ${FOO}, and the package.json of #17123 with rm -rf "${ZERO_REPLICA_FILE}"*. On this machine, 12 of 3,817 distinct package.json scripts hold ${. This PR changes the tokens of 6 of them, for example commitlint --from=${CIRCLE_BRANCH} --to=${CIRCLE_SHA1} and git commit -m 'update changelog - '${npm_package_version}. The other 6 use an operator body. The PR does not make sh scripts run: Bun's shell still has no set, [, while or read.

Scope: 2 of 23 forms. The PR reads ${NAME} and ${digit}. These forms are not supported, before and after: ${10} and above, the operator bodies (${X:-d}, ${X:=d}, ${X+d}, ${#X}, ${!X}, ${X%p}, ${X#p}, ${X/a/b} and their variants), and the bare special parameters $?, $$, $#, $@, $*, $!, $-. Related: #17396 ($$), not fixed here.

  • They are not text. The $ is text and the rest is lexed as ordinary shell syntax, on main and here: echo ${X:->dflt} creates the file dflt}, echo ${X:-a;echo B} runs a second command, and ${X%.*} fails with no matches found.
  • So [ -n "${X:-}" ] still fails open.
  • A ${NAME} inside such a body now expands, as $NAME there does on main: ${X:-${Y}} gives the same tokens as ${X:-$Y}.
  • Token::VarArgv holds a u8 and as_human_readable has a 10-entry table, so ${10} needs more than the lexer.
  • The .env and .npmrc readers have their own ${...} grammars (dotenv skips the default of :- for an empty value, sh takes it), so no code is shared.
  • 12 rows of lex.test.ts record the tokens of these forms under the title "not supported, tokens unchanged". sh expands or rejects each of them.

#44904 and the merge order. This PR is a draft until #44904 lands. The safe order is #38002, then #44904, then this PR. #44904 makes a double-quoted expansion that is empty give one empty word. It waits for #38002, because on Windows an empty operand makes rm -rf "" remove the files of the cwd. Alone, this PR gives "${X}" the behaviour that "$X" has on main: the word is dropped. A guard such as [ -d "${X}" ] && rm -rf "${X}"/* is safe on main only because the text ${X} is not a directory. #44904 edits the three token pushes in the $ arm. This PR does not touch them. git merge-tree reports no conflict, and both orders give the same end state. One change is as in sh and stays with both PRs: with X not set, rm -rf "${X}"* removes every file in the directory. On main it fails with no matches found. The lex tests compare the braced spelling with the bare spelling, so they pass before and after #44904. Three .todo tests in bunshell.test.ts hold the sh answer for "${UNSET}". The PR that lands second turns them on.

Variable not set main this PR this PR + #44904 sh
echo a "${X}" b a ${X} b a b a b a b
[ -d "${X}" ] && echo yes || echo no no yes no no
cd "${X}" && pwd fails $HOME the cwd the cwd

85 whole-word idioms with "${X}" and X not set, each run with bun exec. A row matches when the exit status class, stdout and the created files equal those of bash 5.2.37. Bun's shell has no [ or test builtin, so the second line is what a machine with no such program sees (stock Windows).

Rows that match bash, of 85 main this PR this PR + #44904
[ and test on PATH 48 47 82
no [ or test on PATH 45 58 67

The merged build predates the last change to the builder guard, which bun exec does not use. With [ on PATH, 26 rows change from right to wrong (20 of them need [ or test) and 25 from wrong to right. Without it, 5 change to wrong (ls, rm -f, rm -rf, dirname, exit) and 18 to right. Each wrong row is wrong in the same way for "$X" on main.

#29670. It edits the same line of outbuf_ends_with_var_ref, so the PR that lands second has a conflict to resolve there. #29670 lets a name continue after a backslash-newline. The guard here looks only at the { and not at what is before it, so a value after $\<newline>{ stays data in both orders. One test holds that ("after a line continuation between $ and {").

Guard. It is true when the script ends in $NAME or {NAME. It does not look for a backslash or a quote, as before. A false positive costs one reference and changes token boundaries, not output: {${"a"},b}, {pre${"a"},b} and '{${"a"}}' have tests. A false negative would let a value name a variable. With the guard line as on main, 10 guard tests fail. The guard is a copy of a lexer rule that the builder must keep in step. If every value goes in as a reference, the guard and the keyword check go away. That changes X_${v}=bar and is a separate change.

Measured. Release builds with LTO, same configuration, Linux x64: main 6c1eb06 and this commit on top of it. (main has two newer commits, #44955 and #45008. Neither touches the shell.) Instruction counts come from qemu-x86_64 user mode with one instruction per block, filtered to the address range of each symbol. bloaty, valgrind, perf and strace are not on this machine. Sizes come from llvm-size and nm -S.

  • lexer instructions per statement, main to this PR (1,000 statements, Lexer<Ascii>::lex): $X 417.01 to 412.01, $1 372.01 to 373.01, $( 456.01 to 456.01, literal $ 394.01 to 398.01 (a$ x) and 453.01 to 450.01 (a$^ x). break_word and parse_atom do not change. The new arm is one compare. The other differences are the layout of the recompiled function.
  • echo ${X} x: 1,555 to 1,237 instructions in lex, break_word, parse_atom and the new helper. The bare echo $X x is 1,159.
  • Bun.$ builder: +3 instructions per plain interpolated value (157 to 160 in append_js_value_str), reference calls unchanged at 0.
  • a plain value after \${name or after { now goes in as a reference: +1 reference call and +2 allocations per tag call, output identical. Instructions per tag call: \${FOO + value 76,864 to 83,921 (+7,057), { + value 74,456 to 86,297 (+11,841). The control moved by 169. A reference costs the same on main: $ + value is 81,437 against 72,099 for a spliced value.
  • lexer output differs for 6,562 of 21,435,888 enumerated inputs (every string of length 7 or less over $ { } \ " ' X 1 , ( )). All 6,562 hold an unescaped ${NAME} or ${0-9}. 0 other differences. 3,738 more inputs hold one and are the same lex error on both builds.
  • release binary: +1,280 B .text, +0 B .rodata, the stripped file does not grow. Lexer::lex Ascii 7,044 to 7,073 B, Wtf8 6,774 to 6,786 B. The new helper is 464 B and 650 B.

Tests.

  • lex.test.ts: 21 rows where the braced form gives the tokens of the bare form, 6 rows where the braces end the name, 4 rows that stay text as in sh, 12 rows for forms that are not supported, 4 rows for an interpolated value.
  • bunshell.test.ts: 21 behaviour rows, 3 .todo rows for shell: keep an empty word that has a double-quoted expansion #44904, 12 guard rows (string, number, array, object with toString, Bun.file path, a strings array), 3 rows for a value after {. Each guard row also has a ${FOO} in the source that must expand, so it fails on main too.
  • env.positionals.test.ts, exec.test.ts, run-shell.test.ts: ${N}, bun exec, a .sh file, a package.json script.
  • Release builds, 43 shell test files and run-shell.test.ts: main 999 pass and 68 fail (the 66 new tests and 2 ls tests that need a user that is not root). This PR: 1,065 pass and 2 fail (the same ls tests).
  • Debug build: the five changed files pass. On this machine the timing tests of leak.test.ts, shell-hang.test.ts, shell-load.test.ts, shell-blocking-pipe.test.ts and one test of rm.test.ts time out on the debug build. They pass on both release builds.

no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/shell/bunshell.test.ts, test/cli/run/run-shell.test.ts

The lexer read only the bare form of a variable. For `${NAME}`, `eat_var`
returned an empty range. The `$` arm then appended a literal `$` and lexed
the braces as brace-expansion tokens.

`eat_var` now reads `{NAME}` or `{digit}` from the source bytes when an
unescaped `{` follows the `$`. It returns the same range as for the bare
form, so the `$` arm pushes the same `Var` or `VarArgv` token. Every other
`${...}` body consumes nothing and is lexed as before: the `$` is text and
the rest is ordinary shell syntax.

`ShellSrcBuilder::outbuf_ends_with_var_ref` also matches a script that
ends in `{NAME`. A value interpolated there goes to the lexer as a
reference, so it stays data and never names a variable.
@robobun

robobun commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:33 PM PT - Oct 11th, 2026

✅ @robobun, your commit f1c62b9e5244e56f6658a023a9f9162b81761911 passed in Build #124735! 🎉


🧪   To try this PR locally:

bunx bun-pr 45032

That installs a local version of the PR into your bun-45032 executable, so you can run:

bun-45032 --bun

@robobun

robobun commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on 1.4.3-canary.1+367d939d9 and on main 6c1eb06, beside bash 5.2.37 and dash 0.5.12:

bun exec 'X=5; echo ${X} "${X}" ${X}bar'    # Bun: ${X} ${X} ${X}bar    bash, dash: 5 5 5bar

The fix is now in #46691, which contains the change of this PR. This PR is closed in favor of #46691.

@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 2173e469-d2fd-4661-8f01-f2e6415489ef
📥 Commits

Reviewing files that changed from the base of the PR and between 39bbb71 and f1c62b9.

📒 Files selected for processing (9)
  • docs/runtime/shell.mdx
  • src/runtime/shell/shell_body.rs
  • src/shell_parser/parse.rs
  • test/cli/run/run-shell.test.ts
  • test/js/bun/shell/bunshell.test.ts
  • test/js/bun/shell/env.positionals.test.ts
  • test/js/bun/shell/exec.test.ts
  • test/js/bun/shell/fixtures/positionals3.bun.sh
  • test/js/bun/shell/lex.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 5 remain after this review.

Walkthrough

The shell parser now recognizes ${NAME} and ${0} through ${9}. Runtime and test changes cover expansion across shell contexts and script execution. Documentation describes supported syntax and unsupported braced forms.

Changes

Braced Shell Variable Expansion

Layer / File(s) Summary
Braced-name parsing and tokenization
src/shell_parser/parse.rs, test/js/bun/shell/lex.test.ts
The lexer recognizes braced names made of ASCII letters, digits, and underscores, plus single-digit positional references. Tests cover token boundaries, escaped and quoted forms, malformed forms, and interpolated values.
Shell expansion and execution coverage
src/runtime/shell/shell_body.rs, test/js/bun/shell/bunshell.test.ts, test/js/bun/shell/env.positionals.test.ts, test/js/bun/shell/exec.test.ts, test/js/bun/shell/fixtures/positionals3.bun.sh, test/cli/run/run-shell.test.ts, docs/runtime/shell.mdx
Runtime handling and tests cover braced expansion in shell contexts, positional arguments, inline scripts, shell files, and package scripts. Documentation describes supported forms and limits.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to f1c62

The reviewed braced-variable behavior has no identified issue that needs fixing before merge.

Pre-merge checks: Passed 4
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly and concisely identifies the main change: expanding ${NAME} in the shell like $NAME.
Description check Passed The description fully explains the problem, implementation, scope, limitations, risks, merge order, and verification results. It does not use the exact template headings, but it covers both required t…
Full details: Description check

Explanation

The description fully explains the problem, implementation, scope, limitations, risks, merge order, and verification results. It does not use the exact template headings, but it covers both required topics: what the PR does and how it was verified.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline finding, I also checked two things and ruled them out: eat_braced_var_name is entered after a peek() (cursor still on {), matches only ASCII bytes, and repositions via bump_cursor_ascii the same way the existing JS-ref caller does, so self.j and the byte cursor stay in step on both the Ascii and Unicode encodings; and the widened outbuf_ends_with_var_ref guard only converts a spliced value into a reference, which changes token boundaries but not output for brace groups like {${"a"},b} (covered by tests).

Extended reasoning...

The change adds a ${NAME}/${digit} arm to the shell lexer in src/shell_parser/parse.rs, widens the Bun.$ builder guard in src/runtime/shell/shell_body.rs so values after ${ or {NAME are spliced as references rather than text, and ships docs plus lexer, behavioral, positional, and bun exec/.sh tests. It touches the interpolation boundary of Bun.$ (where a user value could otherwise become a variable name), and the guard looks correct for that. An inline finding already flags the user-visible behavior change for "${X}" with X unset, which the author also acknowledges in the description, so a human should weigh that trade-off.

Comment thread src/shell_parser/parse.rs Resolved
@robobun
robobun marked this pull request as draft October 16, 2026 08:19
robobun added a commit that referenced this pull request Oct 16, 2026
…ange)

This branch is parked. #45490 has the `$?` and `$$` part. `${NAME}` is
#45032. The two commits below this one are the earlier, larger shape.
A review of them found these items. Do them before this work is
proposed.

Commit 1 (expansions):
- `param_text_braces` goes up in any quote state and comes down only
  in the state of the word. `echo ${S:-"${Y%z}"} mid; echo second`
  prints `set` and drops the rest. Count only where the `}` arm would
  close the word.
- A word with no `}` must be a lexer error (`Unclosed parameter
  expansion`), as for `$(`. Delete the rollback, the `param_unclosed`
  memo and the `Eof` sentinel: 896 KB of unclosed heads lex in 5.4 s.
- A quoted word must stay in `CharState::Double` up to its `}`. An
  inner `"` only flips a bit that decides if `}` closes. `\}` is a
  `}` there. Today `"${X:-"$(echo p  q)"}"` gives 2 arguments (dash
  and bash: 1), `"${X:-"it's done"}"` stays text, and `"${X:-a\}b}"`
  gives `a\b}` (dash: `a}b`).
- `Step::EndField` next to `~`, `{a,b}` or `*`:
  `~/.cache${S:+ --force}` gives `[/.cache][~--force]`,
  `{a,b}${X:-c }` gives an extra empty argument, and
  `echo *.js${S:+ --flag}` fails with `no matches found: --flag`.
  Check the trailing break before the brace and glob passes, and
  apply the leading tilde to the first field.
- `${X:-"" b}` loses the empty argument: keep a quoted-empty flag for
  each field.
- `SubShellKind::ParamWord` makes `eat_subshell` hold an unreachable
  arm (mordant `param_wider_than_callers`): keep the word context in
  its own lexer field. `append_var_argv -> bool` folds a
  `self_exe_path()` failure into "not set" (mordant
  `error_collapsed_to_bool`).
- `ParamHead.skip` and the `open_params` stack of the parser cost 5
  instructions for each `parse_atom` call. `Expansion::next` can find
  the matching `ParamEnd` with a depth counter.
- `if !head.op.falls_back_to_value() { out.truncate(len) }` is dead.
- `ends_in_param_ref` copies the head grammar of the lexer by hand:
  add a generated test that a plain interpolated string and a string
  reference give the same non-text tokens after `$` and `${` plus
  each tail of up to 2 bytes of `${}?:-+a1\#!@`. `$\FOO${"BAR"}`
  expands `$FOOBAR` on main too.
- Tests to add: `-` and `+` with a set, an empty and an unset
  parameter and with argument numbers; nested forms where the used
  and the unused word differ; the word guards (`[[`, `]]`, `#`, a
  digit before `>`, a quoted `}`); the table again in a script with a
  non-ASCII byte; a checked-in dash reference table.
- With `${NAME}`, an unset `"${X}"` gives no word: `[ -d "${X}" ]`
  passes and `cd "${X}"` goes to `$HOME`. #44904 makes it one empty
  word, so it must land first.
- A prefix assignment is not seen: `A=x B=${A:-y} cmd` gives `y`.

Commit 2 (parse errors): no maintainer asked for it. If it returns:
one line for each error, a kind on `LexError`, and the hint about
`--shell=system` printed by the caller only for a package.json
script. It also rejects text that runs today: `"${{ x }}"`, `"$#"`,
`sh -c "echo hi $@" -- a b`.
@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

#46691 has this change. It reads ${NAME} and ${0} to ${9} like $NAME and $0 to $9, and it also reads the ${NAME<op>word} forms.

#46691 does not wait for #44904. An empty "${X}" gives the same result there as "$X" gives on main, and three .todo tests hold the sh result for #44904.

If #46691 merges, this PR can close.

@robobun

robobun commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #46691. It contains this change: it reads ${NAME} and ${0} to ${9} with the same lexer probe and the same tokens, and it keeps the tests of this PR, except three rows for the wider builder guard. It also adds the ${NAME<op>word} forms. The branch stays available. Reopen this PR if the smaller change is preferred.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant