Skip to content

shell: end a comment before its newline, accept a line break after &&, ||, | and inside [[ ]] - #41468

Open
robobun wants to merge 7 commits into
mainfrom
robobun/184eba09/shell-comment-newline
Open

robobun wants to merge 7 commits into
mainfrom
robobun/184eba09/shell-comment-newline

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A # comment at the end of a line swallows the newline, so the next line joins the command. rm -rf build # clean + newline + cp -R src build deletes src, and fi # inner + newline + else skips the else branch. Both exit 0.
  • Cause: Lexer::eat_comment (src/shell_parser/parse.rs:3482) consumes the newline and pushes no Token::Newline.

Fix

  • eat_comment stops before the newline or the closing backquote. The \n arm of lex stays the only producer of Token::Newline.
  • Forms that parsed only through the swallowed newline now accept a line break: after &&, ||, |, inside [[ ]], before a closing backquote, in an empty then body.
  • Correct because the tokens equal those of the script without the comment text. Verified: 181 new tests in lex.test.ts, parse.test.ts, bunshell.test.ts, run-shell.test.ts fail on main. 15 other shell test files pass.
  • Self-reviewed: 12 concerns raised, 11 addressed. Not taken: where a comment starts (Notes).

Background

Downsides

Notes

How it was found, and reach. No GitHub issue reports this. A fuzz comparison of the Bun shell with bash and dash found it, and the request was to complete this PR in place. Every entry that parses shell text has it: bun file.sh, bun exec, a Bun.$ template with more than one line, and a package.json script that the Bun shell runs (--shell=bun, the default on Windows).

Before and after. Release builds of main (9b4284f) and of this PR. ⏎ is a newline. bash 5.2.37 and dash 0.5.12 give the last column (dash has no [[ ]]).

script main this PR
if false; then ⏎ if true; then ⏎ echo both ⏎ fi # inner ⏎ else ⏎ echo none ⏎ fi prints nothing, exit 0 none
the same, outer condition true both, none, bun: command not found: else both
the same with elif true; then ⏎ echo elif before else none elif
if true; then ⏎ echo a # c ⏎ else ⏎ echo b ⏎ fi a else, b a
if true; then ⏎ echo a # c ⏎ fi Expected "else", "elif", or "fi" but got: Eof a
if true # c ⏎ then echo a; fi Expected "then" but got: Eof a
echo one # note ⏎ echo two one echo two one, two
FOO=bar # c ⏎ echo "[$FOO]" [] [bar]
echo one # note \ ⏎ echo two one one, two
echo `echo 1 # c` ⏎ echo `echo 2 # c` 1 echoecho 2 1, 2
echo `echo a # c` Unclosed command substitution a
echo a && ⏎ echo b expected a command or assignment a, b
[[ a == a ⏎ ]] && echo yes Expected "]]" but got: `\n` yes
echo `echo a ⏎ ` expected a command or assignment but got: "CmdSubstEnd" a
echo x`echo y` bun: command not found: xecho xy
echo $() expected a command or assignment but got: "CmdSubstEnd" an empty line

What the diff carries. The comment scanner is the fix. Each other part keeps a form that works on main only because the comment took its newline, or its closing backquote, with it.

  • Scanner: eat_comment finds the newline with strings::index_of_char_usize and moves the cursor to it. Outside backquotes a backslash is comment text. Inside backquotes (backquoted_comment_len, and the inherited in_backquote flag) a backslash still quotes the next character, as on main and in bash, and the closing backquote ends the comment.
  • skip_linebreak_after_operator: line breaks after &&, ||, |. A then, elif, else or fi on the next line stays the parse error of main. Without this guard, cmd && ⏎ else would run else as a command.
  • parse_cond_expr: line breaks after [[ and before ]]. A line break around the operator stays an error, as in bash.
  • delimit_before_cmd_subst_end: the ) of $( ) and the closing backquote share one rule. No delimiter follows a newline, a ; or an empty body.
  • parse_if_body: line breaks come before the test for the word that closes the body. then # note ⏎ else keeps an empty then body, like then else on one line.
  • eat_subshell flushes the pending word for every kind of subshell. This is the same text as the source change of shell: flush the pending word in eat_subshell, so text before a backtick substitution stays in its word #45295. The comment rule needs it: a comment that ends at a backquote leaves its other words in front of the next backquote, and on main those words become the first word of that substitution. With echo `echo a # remove it with `rm` -rf build ⏎ `, rm -rf build would run. bash and this PR print arm and report -rf: command not found.

Scripts that ran on main and now fail to parse. bash rejects the first six.

script main this PR
echo a # c ⏎ && echo b a, b expected a command or assignment but got: "DoubleAmpersand"
echo a # c ⏎ | cat a expected a command or assignment but got: "Pipe"
echo a > # c ⏎ file writes a to file Redirection with no file
[[ a # c ⏎ == a ]] true Expected a conditional expression operator, but got: `\n`
[[ a == # c ⏎ a ]] true Expected a word, but got: `\n`
[[ -n # c ⏎ a ]] true Expected a word, but got: `\n`
echo a # c ⏎ > out.log echo b prints b, writes a to out.log expected a command or assignment but got: "Redirect"

The last script is valid. bash prints a and writes b. A line that starts with a redirection does not parse on main either (related to #9455). The comment only hid that.

Other changes at run time. Each is the result of bash and dash, except where the text says so.

Not changed here. bunshell.test.ts has a todo test with the result of bash for each of the first three.

Other PRs.

Measurements. Release builds of main (9b4284f) and of this PR.

  • Size: .text 81,904,485 to 81,905,509 bytes (+1,024, llvm-size -A). File 107,493,088 to 107,497,184 bytes (+4,096, one page). bun_shell_parser symbols 80,774 to 81,991 bytes (nm -S): Lexer::lex +245 (ASCII) and +421 (UTF-8), parse_compound_cmd +181, skip_linebreak_after_operator 171 (new), delimit_before_cmd_subst_end 98 twice (new), Parser::match +37, parse_stmt +35, parse_pipeline +4, parse_if_body -73.
  • size_of the lexer: 200 to 200 bytes. The new bool fits in padding.
  • Token::Newline push sites: 1 to 1. skip_newlines() call sites: 4 to 8.
  • Tokens for 200 lines of plain commands, commands with a trailing comment, comment lines, empty lines: main 1801, 1601, 1001, 1201. This PR 1801, 1801, 1201, 1201. The streams without comments are byte-identical on both builds.
  • Against bash, 101 scripts: of 86 valid ones, main gives the same output and exit 0 for 36 and this PR for 81. The 5 others are the rules listed under "Not changed here". Of 15 invalid ones, main rejects 9 and this PR 15.
  • 100,000 random scripts without comments: 22,819 parse on main. 22,749 of them have the same tokens and tree on this PR. 68 differ by text in front of a backquote, which is now outside the substitution. 2 become parse errors: the text in front of the backquote hid an if with no then.
  • Wall-clock, nanoseconds for one in-process shellInternals.lex or .parse call. Each process takes the minimum of 60 calls, and each cell is the minimum over 15 processes. main and PR runs are interleaved, and main is sampled twice to show the spread.
workload main main again this PR
lex a comment of 1,000,000 bytes (ASCII) 2,870,829 2,851,999 57,570
the same with a non-ASCII character in the script 5,274,919 5,285,910 85,970
parse 2000 comment lines, each before echo a 726,620 718,060 610,659
parse 2000 lines a && b | c || d 2,007,160 1,971,820 2,037,680
parse 2000 lines echo a 572,710 557,410 573,970
parse an if with 2000 statements 563,640 553,400 589,340
parse 2000 lines [[ -n a ]] 431,660 428,980 441,920
parse 2000 lines echo `echo a` 1,298,940 1,284,570 1,292,330
parse 2000 lines echo $(echo a) 1,224,330 1,232,140 1,249,140

A second run of the same table moved single cells by up to 5% in both directions, on both builds. I found no stable slowdown. Instruction, branch and allocation counts would settle it, and perf, valgrind and strace are not installed here. By a read of the diff, a script with no comment pays: one call that tests one token per &&, || or |, three token tests per [[ ]], one bool per sublexer, and one match per closer. The diff adds no allocation.

Tests.

  • lex.test.ts: a table of 36 scripts, each with a comment. The tokens must equal those of the same script with the comment text removed. Each row runs a second time with a non-ASCII character in the comment, which selects the other lexer. More tables cover the closers, the flush, and the empty substitution.
  • parse.test.ts: 60 pairs (5 things in front of the comment, 12 next lines) whose tree must equal the tree without the comment, the line breaks after each operator, [[ ]], empty bodies, and each parse error with its exact message.
  • bunshell.test.ts: 28 tests through Bun.$. 25 print what bash prints. 3 have an empty body, which bash rejects. 6 more are todo tests for the rules that this PR does not change.
  • run-shell.test.ts: a script file, a package.json script and bun exec.
  • Fail-before: on a release build of main, 181 of the new tests fail (51 in lex.test.ts, 98 in parse.test.ts, 25 in bunshell.test.ts, 7 in run-shell.test.ts). The new tests that pass on main pin what must stay.
  • Each clause of the source change was switched off in a build of its own, 14 variants. Each variant fails at least one test.
  • Suites on the debug (ASAN) build: lex, parse, bunshell, exec, run-shell, and 14 more files of test/js/bun/shell/ pass. leak.test.ts and shell-load.test.ts reach their time limits on this host in a debug build. They run no comment and no line break after an operator.

Considered and not taken.

  • The rule for where a comment starts, in this PR. It classifies the last token at every unquoted #, and words such as --color=#fff and $(x)#frag depend on the result. It is a second change with its own tests, and Shell: handle CRLF, tab, comment, and backslash-newline line endings in the lexer #29670 has the code for it.
  • A newline that the lexer drops after an operator. The token stream would no longer follow the lines, and the parser already skips line breaks in four places.
  • A comment inside backquotes that runs to the end of the line, as on main. Two substitutions with a comment on two lines then run a different program with exit 0 (row 10 of the first table).

The comment scanner consumed the newline but never emitted the
Newline token. The next line's words were appended to the current
command. A backslash inside a comment is literal and does not
continue the line.
@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 3 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: d59c917d-c5eb-4361-afe3-11b34366bed1
📥 Commits

Reviewing files that changed from the base of the PR and between 71bc818 and 67f72f3.

📒 Files selected for processing: 5
  • src/shell_parser/parse.rs
  • test/cli/run/run-shell.test.ts
  • test/js/bun/shell/bunshell.test.ts
  • test/js/bun/shell/lex.test.ts
  • test/js/bun/shell/parse.test.ts
  • 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.

@github-actions github-actions Bot added the claude label Sep 6, 2026

@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.

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

Comment thread src/shell_parser/parse.rs Resolved
Comment thread test/js/bun/shell/bunshell.test.ts Resolved
@robobun

robobun commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Two inputs work on main only because the comment swallows its newline. With this change alone they fail:

# 1. a comment after && or |
echo a && # note
echo b
# main: prints a, b.   this PR: expected a command or assignment

# 2. a comment before a closing backtick
echo `echo a # note
`
# main: prints a.   this PR: expected a command or assignment but got: "CmdSubstEnd"
  1. A Newline token now follows &&, and parse_binary does not skip it. The same holds for || and |. I built this branch and ran it. echo a && + newline + echo b with no comment fails on main too. bash allows a newline after &&, || and |.
  2. The closing backtick pushes a Delimit after a Newline or a ;, and the parser reads it as another statement. This is the token stream of echo `echo a + newline + `, which fails on main today. The ) of $( ) already skips that delimiter.

#34901 includes this branch, because its statement rule needs the Newline token, and it has both fixes: skip_newlines() after &&, || and | in parse_binary and parse_pipeline, and one rule for the delimiter before CmdSubstEnd that both closers share. Take those two hunks here, or merge #34901, which contains this PR.

@robobun

robobun commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator Author

One more note, from a review of #34901 (which includes this branch): the bunshell.test.ts case "backslash inside a comment does not continue the line" does not reach the changed code. Bun.$ reads the raw template strings, so \\ in the template is two backslashes before an unescaped newline, and that input ended the comment on main too. The lex.test.ts case uses a single backslash and does cover it. A ${{ raw: "echo one # note \\\necho two" }} input would make the end-to-end case fail without the fix.

robobun added a commit that referenced this pull request Oct 19, 2026
This change is the word loop again: words after a redirection stay in
the command, an fd duplication takes no target, and a second
redirection is an error.

The rule that a statement holds one command turns every place where the
lexer emits no line end into a parse error. A comment did that, and so
does a CRLF line that ends in `)` or `]]`: main runs such a script and
prints one `command not found` line, the rule stopped the whole script.
The rule belongs on top of the lexer change that ends comment, CRLF and
tab lines with a Newline token (#29670). So this commit removes from
this branch: the rule in parse_stmt, the comment change merged from
#41468, the newline after `&&`, `||` and `|`, the shared closer of
command substitutions, and their tests.

Tests: an interpolated array after `>` only asserts that its tail does
not run, so the case holds when #45780 makes that input an ambiguous
redirect. A quoted word that touches an fd duplication has a case.
@robobun

robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my two notes above: #34901 no longer includes this branch. It is only the word loop again (bd91e39), so nothing there depends on this PR now.

The two inputs this change breaks alone still stand (a && # note + newline + b, and a comment before a closing backtick). #29670 has the same comment change with skip_newlines() after &&, || and |, and also handles CRLF and tab line ends. #38479 has the closer of a command substitution. This PR and #29670 overlap in eat_comment, so only one of them should carry that hunk.

… operator

The comment scanner stops at the newline that ends the comment. The
newline arm of the lexer stays the only producer of the Newline token
and of the word break. Inside backquotes a comment also ends at the
closing backquote, and a backslash still quotes the next character.

Three places parsed a comment at the end of the line only because the
comment consumed its newline. They now accept a line break:
- after `&&`, `||` and `|` (POSIX `linebreak`). A `then`, `elif`,
  `else` or `fi` on the next line stays a parse error.
- after `[[` and before `]]`.
- before a closing backquote, as before the `)` of `$( )`.
…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.
With the pending word flushed before a backquote, `` a=`` `` has an
empty body. The closer pushed a delimiter after `CmdSubstBegin`, and
the parser took it for a command. `$()` and an empty pair of backquotes
now parse, as in bash.
@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:11 AM PT - Oct 21st, 2026

✅ @robobun, your commit 67f72f31cadb71b33740e4878b027d1bd3bbdfba passed in Build #129883! 🎉


🧪   To try this PR locally:

bunx bun-pr 41468

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

bun-41468 --bun

@robobun robobun changed the title shell: end the statement at the newline that ends a comment shell: end a comment before its newline, accept a line break after &&, ||, | and inside [[ ]] Oct 21, 2026
@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Status of this PR

Reproduced on bun 1.4.3 and on a release build of main (9b4284f). Save this as x.sh and run bun x.sh:

if false; then
  if true; then
    echo both
  fi # inner
else
  echo none
fi

main prints nothing and exits 0. bash and dash print none. With the outer condition true, main prints both and none. The shorter form is echo one # note + newline + echo two, which prints one echo two.

The rework is pushed as 67f72f3. It covers the two inputs from my note of 2026-10-03 (a && # note + newline + b, and a comment before a closing backquote) and the test from my note of 2026-10-14. The description has the full list of what changed.

PR: #41468

@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.

I re-reviewed the latest revision and found no bugs; both points from my earlier review are now handled (operators accept a following linebreak via skip_linebreak_after_operator, and the backslash-newline tests now feed the lexer a real \ + LF). Because this reshapes lexer token boundaries and parser statement/clause termination for every Bun shell script, a human look would still be worthwhile.

What was reviewed:

  • eat_comment cursor arithmetic in both the ASCII and Unicode lexers: cursor_pos() is the byte index of the next unread char in both, and the end-of-input case lands on the existing past-the-end sentinel, so no out-of-range cursor.
  • backquoted_comment_len: the at + 2 skip after a backslash is safe because index_of_any_pos returns None once start >= len; the "backtick is comment text inside $( ) under backquotes" rule knowingly departs from bash but matches how this lexer already nests backticks there, and the test states that.
  • The break_word move into eat_subshell keeps the prior ordering relative to the depth check for $( and (, and the new Backtick-open call fixes x\...`gluing;delimit_before_cmd_subst_endnow suppressing Delimit for empty$()`/ `` is covered by parse tests showing an empty cmd_subst node.
  • parse_if_body hoisting skip_newlines before the loop and parse_cond_expr skipping newlines only after [[ and before ]] (not between operands) both have tests pinning the exact new behavior and error messages.
Extended reasoning...

The diff is confined to src/shell_parser/parse.rs (+94/-25) plus four test files: the lexer now stops a # comment before its newline (and, inside backquotes, at the closing backtick), moves the pre-subshell break_word into eat_subshell, and dedups the delimiter-before-close logic; the parser gains POSIX linebreak handling after &&/||/|, newline skipping inside [[ ]], and an empty-clause-body fix. No security-sensitive surface such as auth or crypto is touched, though the original bug could make a trailing comment feed the next line to rm -rf, so correctness of the lexer here matters for users. Deferring rather than approving because the change alters token boundaries and statement termination in a stateful hand-written lexer/parser used by every Bun shell script, and the backquote-comment rules are a deliberate divergence from bash that a maintainer should sign off on; no third-party objections are outstanding and both prior inline findings from this bot are addressed by the new commits.

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.

2 participants