Skip to content

shell: flush the pending word in eat_subshell, so text before a backtick substitution stays in its word - #45295

Open
robobun wants to merge 1 commit into
mainfrom
robobun/edf9acac/shell-backtick-flush-word
Open

robobun wants to merge 1 commit into
mainfrom
robobun/edf9acac/shell-backtick-flush-word

Conversation

@robobun

@robobun robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Text before a backtick substitution in a word goes into the substitution. FOO=`echo hi`; echo "[$FOO]" prints [] and bun: command not found: hi. $(...) is correct.
  • The backtick arm of Lexer::lex (src/shell_parser/parse.rs:2630) calls eat_subshell with the word pending. The sublexer inherits it, so its first word is FOO=echo.

Fix

  • eat_subshell flushes the pending word: AddDelimiter::No for $( and backticks, AddDelimiter::AfterText for (. The No for Backtick is the fixing line.
  • The $( and ( arms lose their flush. That is a move: 0 of 172,474 dumped inputs without a backtick change.
  • Verified: test/js/bun/shell/{lex,exec}.test.ts, test/cli/run/run-shell.test.ts. 31 new cases fail without the change. Also ran test/js/bun/shell/.
  • Self-reviewed: 12 concerns raised, 11 addressed, 1 rejected (merge statistics of other PRs).

Background

Downsides

  • An empty body after text (X=``) now fails to parse, like X=$(). So does a redirect after an assignment. Before, both exited 0.
  • A backtick body after text now runs, as in bash, also for prose backticks in double quotes. A failed V=`cmd` && rest now runs rest, as $(...) does.
  • Per lex call (release, gdb single-step): a backtick substitution costs +1 break_word call, +39 instructions. $(: +0. (: at most +2. Allocations, stripped size: +0.
Notes

The contract. Text before an opening backtick stays in its word. The substitution body is lexed as before. This PR does not make each backtick form equal to bash (see "Not in this PR").

Symptoms on main 9bcbf1e. The exit code is 0 or 1. It depends on the body.

FOO=`echo hi`; echo "[$FOO]"      prints []       stderr: bun: command not found: hi        exit 0
FOO=`echo hi` && echo "[$FOO]"    prints nothing  stderr: bun: command not found: hi        exit 1
echo pre`echo m`post              prints post     stderr: bun: command not found: preecho   exit 0
export FOO=`echo hi`              prints the whole environment, stderr: bun: command not found: hi

With this PR, and in bash and dash, the four scripts print [hi], [hi], prempost and nothing.

No issue reports this bug. It shows in a comparison of Bun Shell with bash and dash.

Tokens (shellInternals.lex)

FOO=`echo hi`
  main: CmdSubstBegin Text("FOO=echo") Delimit Text("hi") Delimit CmdSubstEnd
  PR:   Text("FOO=") CmdSubstBegin Text("echo") Delimit Text("hi") Delimit CmdSubstEnd
echo "a`echo m`b"
  main: Text("echo") Delimit CmdSubstBegin CmdSubstQuoted Text("aecho") Delimit Text("m") Delimit CmdSubstEnd DoubleQuotedText("b")
  PR:   Text("echo") Delimit DoubleQuotedText("a") CmdSubstBegin CmdSubstQuoted Text("echo") Delimit Text("m") Delimit CmdSubstEnd DoubleQuotedText("b")

For these two scripts, the PR tokens are the tokens that the $(...) spelling gives on main.

Interpolated values in Bun.$. handle_js_string_ref leaves an interpolated value pending in the word. On main, a value directly before a { raw } backtick becomes the command word of the substitution:

const BT = { raw: "`" };
await $`echo ${"echo"}${BT} echo hi${BT}`; // main: "echo hi" (the value ran as a command). PR: "echohi"

docs/runtime/shell.mdx:567 says that interpolated values are literal strings. The after an interpolated value test covers this.

The move changes nothing else. A script dumped shellInternals.lex and shellInternals.parse for 456,177 inputs, each with the ASCII lexer and the WTF-8 lexer, on debug builds of main and of this PR.

  • Inputs: every string of length 1 to 5 over the characters a ` $ ( ) " ' = ; and space, every string of length 6 and 7 over a ` $ ( ) ", a matrix of 18,351 opener shapes, 84 sources from the shell tests, and 40 rows written by hand.
  • 0 of the 172,474 inputs without a backtick differ.
  • 10,227 inputs differ. Each has a backtick. In each, the only token change is text that moves from the first token inside a substitution to a token before its CmdSubstBegin. When the whole token moves, the Delimit after it goes away.
  • No input changes between a lex error and a lex success. No input with equal tokens changes its AST.
  • 3,786 inputs go from a parse success to expected a command or assignment but got: "CmdSubstEnd". The substitution body holds no command when the text is outside it: 3,397 have an empty or blank body and 389 have a body that is one unclosed ".
  • 62 parse runs go from an error to success, and 400 change the error text. Each is on an input whose tokens changed in the way above.
  • The inputs have no redirect and no &&. Those two cases are in the next block.

Behaviour changes (main → this PR, through bun exec)

X=``                                    exit 0 → exit 1: expected a command or assignment but got: "CmdSubstEnd"
echo pre``                              exit 0, stderr "command not found: pre" → exit 1, same parse error
X=`echo hi` 2>/dev/null; echo "[$X]"    exit 0, prints [] → exit 1: expected a command or assignment but got: "Redirect"
echo one; if false; then X=`echo hi` 2>/dev/null; fi; echo two
                                        prints one, two → exit 1, same parse error, nothing runs
V=`nosuchcmd -r x` && echo "tag [$V]"   exit 1, the chain stops → exit 0, prints "tag []"
echo "run `touch f` now"                stderr "command not found: run touch", no file → creates f
  • Empty body: both closing arms of Lexer::lex push a Delimit when the last token is CmdSubstBegin, and the parser then expects a command. X=$() and echo `` give this error on main. bash accepts an empty substitution.
  • Redirect: an assignment without a command name cannot take a redirect in Bun Shell. X=$(echo hi) 2>/dev/null and X=hi 2>/dev/null give this error on main. bash accepts them. A redirect inside the backticks works.
  • Chain: an assignment reports status 0 in Bun Shell. V=$(nosuchcmd -r x) && echo "tag [$V]" prints tag [] on main too. bash stops with 127. On main the backtick spelling stopped only because it ran the wrong command.
  • Prose: bash and dash create the file too. Main already runs the body when no text is before the backtick: echo "`touch f` now" creates f on main. Single quotes or \` keep a backtick literal.

Cost. Release builds of main 9bcbf1e and of this PR, one shellInternals.lex call per run. "Instructions" are the instructions executed inside bun_shell_parser functions, counted with gdb single-step. The counts repeat exactly.

Script break_word calls Instructions Vec grow_to calls mi_* calls
echo hello world 3 → 3 670 → 670 5 → 5 8 → 8
echo $FOO bar 4 → 4 1160 → 1160 5 → 5 7 → 7
echo $(echo m)post 5 → 5 1266 → 1266 6 → 6 9 → 9
echo pre$(echo m)post 5 → 5 1331 → 1331 6 → 6 9 → 9
(echo m) 4 → 4 855 → 854 4 → 4 5 → 5
echo a;(echo m) 6 → 6 1274 → 1276 6 → 6 9 → 9
echo `echo m`post 4 → 5 1142 → 1181 6 → 6 9 → 9
echo pre`echo m`post 4 → 5 1208 → 1246 6 → 6 9 → 9
FOO=`echo hi`; echo "[$FOO]" 9 → 10 2485 → 2532 7 → 7 11 → 11
  • Lexer code (lex x2, eat_subshell x2, break_word, from llvm-nm -S): 16,910 → 16,862 bytes.
  • break_word call sites: 54 → 54. Stripped binary: 105,604,840 → 105,604,840 bytes.
  • valgrind, perf, bloaty and strace are not installed where this ran.

Relation to open shell PRs

Not in this PR (the same on main, different from bash)

echo `echo hi;`                 parse error "CmdSubstEnd" (a body that ends in ; or a newline). echo $(echo hi;) works.
HOME=/h; echo `echo \$HOME`     prints $HOME. bash prints /h.
echo `echo \`echo inner\``      prints `echo inner`. bash prints inner.
echo "`echo \"q\"`"             prints "q". bash prints q.
echo a`echo b #c`d              Unclosed command substitution. bash prints abd.

Other checks

  • bun run rust:mordant: exit 0. cargo clippy -p bun_shell_parser --no-deps: no new warning.
  • The 31 new cases are 28 in lex.test.ts, 2 in exec.test.ts and 1 in run-shell.test.ts. They fail on a debug build of main. 4 more new cases pin the ( tokens and a backtick at the start of a word. They pass on both builds.
  • With the change: lex.test.ts 75 pass, exec.test.ts 21 pass, run-shell.test.ts 4 pass, parse.test.ts 18 pass, bunshell.test.ts 450 pass.
  • bunshell.test.ts has one group (stdin redirect from a zero-length buffer) that fails in some runs of the debug build, with and without the change.
  • The test/js/bun/shell/ directory on the debug build: 29 tests fail. 28 of the 29 also fail on a debug build of main on the same machine. The other one (ls > recursive > node_modules) passes alone on both builds.
  • How often real scripts use these forms is not measured here.

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

…ick substitution stays in its word

The lexer keeps the word it is reading as a pending range of the string
pool. A sublexer inherits that range. The backtick arm started the
sublexer without a flush, so the text before the backtick became the
start of the first word inside the substitution. FOO=`echo hi` ran
`hi` with FOO=echo in its environment and assigned nothing.

eat_subshell now flushes the pending word itself, for each kind of
subshell. The `$(` arm and the `(` arm no longer flush before the call.
The token streams of `$(...)` and `( ... )` do not change.
@robobun

robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:17 PM PT - Oct 14th, 2026

✅ @robobun, your commit 1485d26b3d3d6c56fb8fed3c45a446d6ccb8f6ca passed in Build #125668! 🎉


🧪   To try this PR locally:

bunx bun-pr 45295

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

bun-45295 --bun

@robobun

robobun commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How it was reproduced (Linux x64, a 1.4.3 canary release build and main 9bcbf1e):

  • bun exec 'FOO=`echo hi`; echo "[$FOO]"' prints [] and bun: command not found: hi. bash and dash print [hi].
  • bun exec 'echo pre`echo m`post' prints post and bun: command not found: preecho. bash and dash print prempost.
  • The 31 new cases in test/js/bun/shell/lex.test.ts, test/js/bun/shell/exec.test.ts and test/cli/run/run-shell.test.ts fail without the change (28, 2 and 1) and pass with it.

This PR supersedes #33547, which the stale job closed.

@coderabbitai

coderabbitai Bot commented Oct 15, 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: Advanced
  • Run ID: f60eb4dc-705c-4a16-8cb3-9e41cf6e12d9
📥 Commits

Reviewing files that changed from the base of the PR and between a9c57bd and 1485d26.

📒 Files selected for processing: 4
  • src/shell_parser/parse.rs
  • test/cli/run/run-shell.test.ts
  • test/js/bun/shell/exec.test.ts
  • 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; 6 remain after this review.

Walkthrough

The shell parser now flushes pending text in eat_subshell, using different delimiter policies for command substitutions and normal subshells. New lexer and shell execution tests cover backtick substitutions in multiple contexts.

Changes

Shell substitution parsing

Layer / File(s) Summary
Centralize subshell text flushing
src/shell_parser/parse.rs
eat_subshell flushes pending text with a delimiter policy based on the substitution type. Direct flushes at the call sites are removed.
Verify substitution tokenization
test/js/bun/shell/lex.test.ts
Lexer tests compare backtick and dollar substitutions across word, quote, nesting, redirect, and delimiter contexts.
Verify shell substitution execution
test/js/bun/shell/lex.test.ts, test/js/bun/shell/exec.test.ts, test/cli/run/run-shell.test.ts
Tests cover backtick substitutions in assignments and surrounding text, plus additional quoting, export, and redirect cases.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 1485d

The backtick substitution change has no identified merge-blocking issue; proceed with normal checks.

Pre-merge checks: Passed 4
Passed Passed checks: 4 passed
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 states the main shell lexer fix: flushing the pending word so text before backtick substitution remains in the same word.
Description check Passed The description explains the problem, implementation, behavior changes, limitations, and verification results. It does not use the template headings exactly, but it provides equivalent information and…
✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • 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 findings, I also checked the $( and ( arms for a behavior change from moving the flush into eat_subshell: the AddDelimiter chosen per kind matches the argument each removed call site passed, all three eat_subshell callers (src/shell_parser/parse.rs:2630, :2646, :2682) are covered, and the flush now preceding the MAX_SUBSHELL_DEPTH check only alters the token list on a lex that already returns an error.

Extended reasoning...

The change is a five-line move in the shell lexer's eat_subshell plus tests; it touches no auth, crypto, or permission surface. The remaining behavior changes are the ones the inline findings cover (empty backtick body, assignment-with-redirect, and assignment status in && chains), all of which the author also lists under Downsides, so a human should weigh whether those regressions are acceptable.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/shell_parser/parse.rs
Comment on lines +3115 to +3119
// The sublexer inherits `word_start` and `j`, so pending text would start its first word.
self.break_word(match kind {
SubShellKind::Dollar | SubShellKind::Backtick => AddDelimiter::No,
SubShellKind::Normal => AddDelimiter::AfterText,
})?;

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.

🔴 Scripts containing an assignment-only line such as VER=git describe 2>/dev/null now fail to parse as a whole, where on the base branch the script still ran. The new flush in eat_subshell (parse.rs:3115-3119) moves VER= out of the substitution, so the parser sees assignments followed by a Redirect token, which parse_cmd_or_assigns (parse.rs:1336-1346) cannot consume; the next statement then reports expected a command or assignment but got: "Redirect". This follows from the stated purpose and is pre-existing for X=$(cmd) 2>/dev/null and X=hi 2>/dev/null. Fix: let an assignment-only command carry a redirect (parse and discard or apply it, as bash does) so all three spellings run. [also at: src/shell_parser/parse.rs:3117 - Shell scripts that redirect a backtick assignment, like X=cmd 2>/dev/null, now fail to parse and nothing in the script runs, where the base ran the script to completion.]

Why this was flagged

Input: a shell script run via bun script.sh or bun exec containing V=cmd 2>/dev/null on any line, even in a branch that never executes. After the change, eat_subshell at src/shell_parser/parse.rs:3116 emits Text("V=") before CmdSubstBegin, so the statement lexes as an assignment followed by a Redirect token. In parse_cmd_or_assigns at src/shell_parser/parse.rs:1336-1346, parse_atom returns None on Redirect, assigns is non-empty, so it returns Assigns leaving the Redirect unconsumed; the following parse reports expected a command or assignment but got: "Redirect" and nothing in the script runs, exit 1. On the base branch the same script lexes the whole word into the substitution, runs the wrong inner command but continues, exit 0. The base already rejects the $(...) and plain spellings, but the backtick spelling is the common idiom in existing bash scripts, so the population hitting the parse failure grows with this change.

Verification: The flush at src/shell_parser/parse.rs:3115-3119 emits VER= as Text("VER=") ahead of CmdSubstBegin, so parse_simple_cmd returns Assigns at 1346 without consuming the Redirect and the next parse errors expected a command or assignment but got: "Redirect"; the whole script fails, exit 1, where the base ran it. The function at parse.rs:1336-1346 is parse_simple_cmd, not parse_cmd_or_assigns.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is correct. The PR lists it under Downsides and in the Notes.

  • The cause is in the parser, which this PR does not change. An assignment without a command name cannot take a redirect: parse_simple_cmd returns CmdOrAssigns::Assigns before parse_redirect() runs, and Expr::Assign has no field for a redirect. X=$(echo hi) 2>/dev/null and X=hi 2>/dev/null give the same parse error on main.
  • On main the backtick spelling did not work either. X=`echo hi` 2>/dev/null; echo "[$X]" printed [] and bun: command not found: hi, because it ran hi with X=echo. The script only looked correct when that line was in a branch that did not run.
  • Support for the redirect needs a decision about its semantics, so it is not in this PR. bash and dash differ: for X=$(ls /nonexistent) 2>/dev/null, bash prints the ls error and dash does not. Both create the file for X=hi > out.txt.

If a maintainer wants that support here, I can add it.

Comment thread src/shell_parser/parse.rs Resolved
Comment thread src/shell_parser/parse.rs Resolved
robobun added a commit that referenced this pull request Oct 21, 2026
…he next command

Three results of the change to comments, found in review:
- `then # note` + newline + `else` ran `else` as a command. The body
  loop of `parse_if_body` now skips line breaks before it tests for the
  word that closes the body, so an empty body over several lines parses
  like `then else` on one line.
- A backquote inside a backquoted comment closed the substitution, and
  the text after it started the first word of the next substitution.
  `eat_subshell` now flushes the pending word for every kind of
  subshell (the change of #45295).
- The two closers of a command substitution share one delimiter rule.
robobun added a commit that referenced this pull request Nov 9, 2026
…subshell

The closing arms no longer push a stray Delimit, so `$((1+2))` would
now parse as a subshell that runs `1+2`. Report arithmetic expansion
as not supported instead.

A backtick substitution did not flush the text before it, so the text
became part of the first word of its body. Bodies that end in `;`, a
newline, `)` or a JS object now parse, so flush in eat_subshell for
all three kinds. This is the source change of #45295.
robobun added a commit that referenced this pull request Nov 9, 2026
…he tests of #45295

No lexer site pushes a Delimit after a token that is not part of a
word any more, so the arm at the head of the parse_atom loop is dead:
it runs 26,963 times over 112,981 probe scripts on main and 0 times
here.

The arithmetic expansion error now says "yet" and gives the remedy for
arithmetic first.

Tests: the tests of #45295 for text before a backtick, with its empty
body row updated. One decision table per file in place of the
form-by-body product. New rows pin each clause of the `$((` guard:
`((echo a))`, an escaped paren on each side, and the forms that stay
a subshell. Three todo rows name results that are wrong for a reason
another change owns.

This branch has not been deployed

No deployments
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