Skip to content

shell: end the statement at the newline that ends a comment - #41468

Open
robobun wants to merge 1 commit into
mainfrom
robobun/184eba09/shell-comment-newline
Open

robobun wants to merge 1 commit into
mainfrom
robobun/184eba09/shell-comment-newline

Conversation

@robobun

@robobun robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A # comment that follows a command on the same line swallows the newline. The words on the next line are appended to the current command. rm -rf build # clean followed by cp -R src build runs the single command rm -rf build cp -R src build and deletes src.
  • The cause is eat_comment in src/shell_parser/parse.rs:3472. It consumes characters up to and including the \n but never pushes Token::Newline. The lexer then continues the same simple command on the next line. A comment on its own line is not affected because the newline before it already delimited the statement.

Fix

  • eat_comment pushes Token::Newline when it consumes the newline. The next line starts a new statement, like an unquoted newline outside a comment.
  • A backslash inside a comment is literal in bash. The scanner no longer skips an escaped newline, so # note \ followed by a newline ends the comment too.
  • Verified: test/js/bun/shell/lex.test.ts (5 new token cases, 4 fail on 1.4.3) and test/js/bun/shell/bunshell.test.ts (3 new cases, all fail on 1.4.3). Also parse.test.ts, shell-seq-condexpr.test.ts, bunshell-file.test.ts.

Background

  • The shell lexer turns the script text into a flat token list. Token::Newline and Token::Semicolon end a statement. Token::Delimit ends a word.
  • # starts a comment only when it is preceded by whitespace and is outside quotes. The word before the # was already delimited by that whitespace, so the comment site only needs the statement delimiter.
  • The output string index j is not advanced by comment characters, so word_start stays correct after the comment.
Notes

Repro on 1.4.3:

import { $ } from "bun";
console.log(JSON.stringify((await $`echo one # note
echo two`.quiet()).stdout.toString()));

Before: "one echo two\n". After: "one\ntwo\n". CRLF line endings behave the same.

Consecutive Newline tokens (a comment on its own line now yields two) are already handled: parse_stmt returns an empty statement and the script loop skips newlines.

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

  • Run on-demand review

On-demand reviews are free for the next 15 days. After that, they cost $0.25 per reviewed file.

Or wait 15 minutes for your next included review.

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: e36c1851-e963-4cc9-b8e4-24ec7f959f32

📥 Commits

Reviewing files that changed from the base of the PR and between f42e980 and ae330d7.

📒 Files selected for processing (3)
  • src/shell_parser/parse.rs
  • test/js/bun/shell/bunshell.test.ts
  • test/js/bun/shell/lex.test.ts

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
if peeked.char == u32::from(b'\n') {
while let Some(c) = self.eat() {
if c.char == u32::from(b'\n') {
self.tokens.push(Token::Newline);

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.

🔴 Emitting Token::Newline from eat_comment() regresses scripts that put a trailing comment between an operator and its right operand: cmd1 && # note\ncmd2 (and ||, |) parsed and ran on the base branch because the swallowed newline left the operator adjacent to cmd2, but now the parser sees DoubleAmpersand, Newline and parse_simple_cmd fails with "expected a command or assignment"; bash accepts this idiom. Fix: after consuming &&/|| in parse_binary() (line 875) and | in parse_pipeline() (line 908), call self.skip_newlines() so a newline — from a comment or bare — before the right operand is accepted.

Extended reasoning...

Base: echo a && # x\necho b lexes to […, DoubleAmpersand, Text:echo, Delimit, Text:b, …] because the old eat_comment consumed the \n without emitting a token; parse_binary (873) matches && at 875 and parse_pipeline immediately parses echo b — the script runs echo a && echo b, matching bash. After this change the same input lexes to […, DoubleAmpersand, Newline, Text:echo, …]; parse_binary consumes && and calls parse_pipeline → parse_compound_cmd → parse_simple_cmd, whose while-condition at 1302 sees peek()==Newline so assigns stays empty, then at_end at 1328 is true, and line 1332 raises expected a command or assignment. || shares the same path; | hits the identical failure via the loop at 908 → parse_compound_cmd. The parser has no skip_newlines() after these operators (unlike the top-level script loop at 825), so any script using the common bash pattern of annotating pipeline stages (cat f | # source\ngrep x) that ran on 1.4.3 now throws a parse error.

Verification: normal — merging breaks input that works on base and matches bash. Trace on HEAD for echo a && # x\necho b: - Lexer at parse.rs:2536 calls eat_comment(), which now pushes Token::Newline (parse.rs:3478). Token stream becomes …, DoubleAmpersand, Newline, Text("echo"), …. - parse_binary (parse.rs:875) consumes DoubleAmpersand and immediately calls parse_pipeline() (parse.rs:885)…

Comment on lines +932 to +935
TestBuilder.command /* sh */ `echo one # note \\
echo two`
.stdout("one\ntwo\n")
.runAsTest("backslash inside a comment does not continue the line");

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.

🟡 (optional) This test does not exercise the backslash-newline path it names: Bun.$ reads TemplateStringsArray.raw, so \\ in the template reaches the shell as two backslashes followed by an unescaped LF, and eat_comment sees {char:'\\',escaped:true} then an unescaped '\n' — the same path as the first test. Reverting only the c.escaped removal in eat_comment (keeping the Newline push) would still let this test pass. Fix: send a single backslash before the newline so the lexer sees an escaped LF — write a lone \ before the literal line break (raw templates preserve \<LF> as backslash+LF), or pass the script via { raw: ["echo one # note \\\necho two"] }.

Extended reasoning...

BunShell in src/js/builtins/shell.ts calls createParsedShellScript(first.raw, ...), so the template's raw text is what the lexer parses. The source characters \\ are two backslashes in .raw; the following literal newline is an unescaped LF. In ShellCharIter::read_char the first \ peeks the second and yields {char:'\\', escaped:true} (consuming both), then the LF yields {char:'\n', escaped:false}. eat_comment therefore hits the plain-newline branch — identical to the "trailing comment ends the statement" case above — and never touches the code path guarded by the removed if peeked.escaped { continue; }. The lex.test.ts case at line 861 (a cooked JS string "... \\\n ..." → one backslash + LF) does cover it, but this end-to-end case duplicates the first bunshell case instead of covering the escaped-newline behavior its name claims, so a regression that re-introduces \-newline continuation inside comments would not be caught by bunshell.test.ts.

Verification: nit — the analysis is correct; this end-to-end case does not reach the escaped-newline branch it names, though the same path IS covered at the lexer level. Chain of evidence: 1. TestBuilder forwards the TemplateStringsArray unchanged to Bun.$: test/js/bun/shell/test_builder.ts:40 this._scriptStr = _scriptStr; and :243 Bun.$(this._scriptStr, ...). 2. Bun.$ parses .raw, not the cooked…

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

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