Skip to content

Shell: handle CRLF, tab, comment, and backslash-newline line endings in the lexer - #29670

Open
robobun wants to merge 22 commits into
mainfrom
farm/8a526bfc/shell-crlf-line-endings
Open

robobun wants to merge 22 commits into
mainfrom
farm/8a526bfc/shell-crlf-line-endings

Conversation

@robobun

@robobun robobun commented Apr 24, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #29669

Problem

  • A .sh file with CRLF line endings fails under bun run ./repro.sh: bun: command not found: export and error: Script not found "build\r". Every Windows-edited script has this shape.
  • The shell lexer (src/shell_parser/parse.rs) has arms for \n, space, and #, but none for \r or \t. A \r fell through to append_char_to_str_pool and was glued onto the word before it.

Fix

  • Add a \r arm: in Normal state, \r\n is one newline. Inside quotes the byte stays literal, as in bash and dash. A lone \r stays literal too.
  • Treat \<CR><LF> like \<LF>: a line continuation in Normal and Double state, removed before tokenizing (POSIX 2.2.1). It joins the halves of a word and of a variable name ($FOO\<LF>ls is $FOOls, as in bash). Double state now lets a backslash escape \r. A bare \<CR> in double quotes keeps its backslash.
  • Sibling bugs in the same code: an unquoted tab breaks words like a space. A # comment ends at its newline (a trailing backslash does not continue it) and emits the Newline token, so the next line is a new command. The parser skips newlines after |, &&, and || (POSIX linebreak), so cmd | on one line and the next command on the following line parse. Any escaped char ends a variable name: $HOME\foo is ${HOME}foo. A # starts a comment wherever a new token starts, so echo a;# note no longer runs a command named #. Inside backticks a comment ends at the closing backtick.
  • Verified: test/cli/run/run-shell.test.ts (10 new tests, all fail on main) and test/js/bun/shell/lex.test.ts (token-level cases for CRLF, lone CR, continuations, tab, comments, plus six Bun.$ cases). Also ran parse.test.ts and bunshell.test.ts.

Background

  • The lexer is one match on the next char. An arm either does break 'escaped (append the char to the current word) or sets fell_through (skip it). break_word flushes the current word as a Text token. Newline and Semicolon tokens separate statements in the parser.
  • read_char marks a char as escaped when a backslash precedes it. In Double state only a short list of chars can be escaped ($, backtick, ", \, \n, #). \r joins that list so \<CR><LF> can be a continuation in double quotes.
  • eat_var scans a variable name after $. It now skips an escaped LF or CRLF and keeps scanning, and stops at any other escaped char, so the name matches what bash reads after it removes \<newline>. The Bun.$ interpolation guard (outbuf_ends_with_var_ref in shell_body.rs) looks past a trailing \<newline> for the same reason, so $FOO\<LF>${x} expands FOO and keeps x a separate value.
  • Considered normalizing CRLF to LF when run_command.rs reads the file: that leaves Bun.$ template strings and quoted CR bytes wrong. The lexer is the one place every shell source passes through.

Downsides

  • A Bun.$ template or script that relied on \t being part of a word, on $FOO\<LF>ls expanding as ${FOO}ls, or on $HOME\foo expanding HOMEfoo, changes meaning. All now match bash and dash.
  • None found for input without \r or \t beyond one table lookup per such byte: both were already in SPECIAL_CHARS, so the fast-path scanner stopped on them before this change too.
Notes

Behaviour compared against bash 5 and dash for every new test input. FOO=x; FOOls=y; echo "[$FOO\<LF>ls]" [$FOO\<LF>ls] [$\<LF>FOO] prints [y] [y] [x] in bash, dash, and this branch.

Earlier iterations of this PR patched the Zig lexer. Main replaced that file with the Rust lexer in src/shell_parser/parse.rs and the fix was ported.

The continuation-join test uses the echo builtin instead of printf: Windows has no printf, and the external one on CI printed n for \n.

Pre-existing and left alone: echo ** foo glues foo onto ** (break_word pushes no Delimit after DoubleAsterisk). Stock bun reports no matches found: **foo.

Operator continuation checked with echo a | # c, cat, echo b &&, echo c, false || # d, echo e on separate lines: bash, dash, and this branch print a b c e. Stock bun fails with expected a command or assignment.


no test proof · iteration 17 · 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

@robobun

robobun commented Apr 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:36 PM PT - Sep 25th, 2026

✅ @robobun, your commit e93b4d76890e4654f327bcafbfeae336bd0e7390 passed in Build #120693! 🎉


🧪   To try this PR locally:

bunx bun-pr 29670

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

bun-29670 --bun

@coderabbitai

coderabbitai Bot commented Apr 24, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Lexer and shell-escaping logic were changed to handle Windows CRLF: unescaped \r immediately before unescaped \n is discarded; escaped \r\n is consumed as a single line-continuation; double-quote/backslash parsing now allows escaping \r; \r is treated as a special character in shell generation. Tests for CRLF behaviors were added.

Changes

Cohort / File(s) Summary
Shell lexer & escaping
src/shell/shell.zig
Normalize CRLF handling: drop unescaped \r before unescaped \n in Normal state; consume \n after an escaped \r to treat \<CR><LF> as one escaped-continuation; allow escaping \r inside double quotes and backslash logic; add \r to shell SPECIAL_CHARS and update shell source generation accordingly.
CRLF handling tests
test/cli/run/run-shell.test.ts
Add tempDir-based fixtures and new run-shell tests verifying: trailing \r does not leak into args/output or cause “command not found”; backslash line-continuation across \<CR><LF> joins lines (including inside double quotes); a backslash before a bare \r in double quotes preserves \+\r; tests strip intermittent ASAN/JSC warnings from stderr and assert zero exit code.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The description references and addresses issue #29669. The stated fix matches the issue objectives.
Out of Scope Changes check ✅ Passed The changes remain within shell lexer behavior and its tests. The tab, comment, and continuation fixes are related lexer corrections, not unrelated scope expansion.
Title check ✅ Passed The title clearly identifies the main lexer changes for CRLF, tab, comments, and backslash-newline handling. It is specific and related to the changeset.
Description check ✅ Passed The description is detailed and on-topic. It explains the problem, fix, background, downsides, and verification results, which covers the required information even though it does not use the template …

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

Comment thread src/runtime/shell/shell.zig Resolved
Comment thread test/cli/run/run-shell.test.ts Resolved
Comment thread src/runtime/shell/shell.zig Resolved
Comment thread src/runtime/shell/shell.zig Resolved

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/cli/run/run-shell.test.ts`:
- Around line 56-59: The test currently asserts stderr is exactly empty after
collecting subprocess outputs (variables proc, stdout/stderr/exitCode in
run-shell.test.ts); change this to either remove the strict
expect(stderr).toBe("") check or filter out ASAN noise before asserting
emptiness by splitting stderr into lines and removing any lines that start with
"WARNING: ASAN interferes" (or similar ASAN prefix) and then asserting the
remaining joined string is empty; apply this same change to the other assertions
mentioned (the blocks around the usages at lines ~78-81, ~100-103, and ~122-125)
so any bunExe()/bunEnv spawned subprocesses ignore ASAN startup warnings when
checking stderr.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: eeaad6e3-063d-4cc5-954c-a2e278b1e488

📥 Commits

Reviewing files that changed from the base of the PR and between d89cd8b6722e418709d451b7d9d55b238e26460f and b946e9ac5c10deec48767c76f6640c562308003c.

📒 Files selected for processing (2)
  • src/shell/shell.zig
  • test/cli/run/run-shell.test.ts

Comment thread test/cli/run/run-shell.test.ts Resolved

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

Thanks — b946e9a addresses the last bare-CR-in-double-quotes edge case and all four scenarios now have tests; I don't see any remaining issues. Given how subtle the lexer state interactions turned out to be (three follow-up edge cases), I'd still like a human familiar with the shell lexer to give the final shape a once-over before merge.

Extended reasoning...

Overview

This PR fixes #29669 by teaching the Bun shell lexer (src/shell/shell.zig) to normalize CRLF line endings. It touches four spots: (1) a new unescaped '\r' case in the main dispatch that drops \r before \n in Normal state, (2) a new escaped-\r handler so \<CR><LF> works as a line continuation, (3) \r added to the Double-state backslash escape list so the continuation also works inside double quotes, with a compensating re-emit of the backslash when the CR is bare, and (4) \r added to SPECIAL_CHARS (required by assertSpecialChar and correct for the needsEscape* interpolation heuristics). Four new tests in test/cli/run/run-shell.test.ts cover each scenario.

Security risks

None identified. This is purely lexer tokenization of \r bytes; no auth, crypto, filesystem, or network surface is touched. The SPECIAL_CHARS addition only makes the interpolation-escaping heuristic slightly more conservative (values containing bare \r now get quoted), which is safe.

Level of scrutiny

Moderate-to-high. The shell lexer is a critical path — it runs for every bun run *.sh and every Bun.$ template literal — and the change is a state-machine modification with non-obvious interactions between read_char() escape handling, the !escaped dispatch block, and the escaped fall-through path. The fact that this took three follow-up commits (escaped-CR continuation, Double-state escape list, bare-CR backslash preservation) to reach a correct state confirms the area is subtle. Each of those was caught in review and is now covered by a regression test, but a maintainer who owns this lexer should confirm the final approach is the one they want (vs., say, normalizing CRLF→LF in a pre-pass before lexing).

Other factors

All four of my prior inline comments are resolved with code + tests. CodeRabbit had no actionable findings. There is no CODEOWNERS entry for src/shell/. The PR description reports lex.test.ts/parse.test.ts pass unchanged and bunshell.test.ts has identical pass/fail counts. I am deferring rather than approving solely because this is non-trivial lexer logic in a hot path, not because of any known defect.

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

All earlier feedback is addressed and I didn't find further issues — deferring final sign-off to a human since this touches the shell lexer's escape/quoting semantics and took a few iterations to get the edge cases right.

Extended reasoning...

Overview

This PR teaches the Bun shell lexer to normalize CRLF line endings: an unescaped \r immediately before \n in Normal state is dropped, \<CR><LF> is treated as a line continuation (both in Normal and Double-quoted state), and a backslash before a bare CR inside double quotes is re-emitted to preserve POSIX behaviour. \r is also added to SPECIAL_CHARS so the comptime assertSpecialChar check passes and shell-source generation quotes it. Four new integration tests in test/cli/run/run-shell.test.ts cover the basic CRLF case, backslash continuation, double-quoted continuation, and the bare-CR-in-double-quotes regression guard.

Security risks

None identified. This is purely lexer tokenization of CR bytes; it does not touch auth, network, filesystem permissions, or process spawning. The only behavioural change is that previously-broken CRLF scripts now tokenize the same as their LF equivalents.

Level of scrutiny

Moderate-to-high. src/shell/shell.zig's lexer is a hand-written state machine that backs both bun run *.sh and the Bun.$ tagged-template API, so tokenization changes have broad reach. The PR went through three correction rounds during review (escaped-CR continuation, dead Double-state guard, bare-CR backslash regression) — each was a real edge case, and each is now covered by a dedicated test. The final logic reads correctly to me and the bug-hunting pass on the current revision found nothing, but the iteration history suggests a maintainer familiar with the lexer should confirm the chosen semantics (particularly adding \r to the Double-state escape list at line 3715 and then compensating by re-emitting the backslash at 2893–2895, rather than peeking two bytes in read_char()).

Other factors

  • All four of my prior inline comments and CodeRabbit's ASAN-stderr note are resolved in the current diff.
  • CI failures on the latest build (socket.test.ts, bun-install-lifecycle-scripts.test.ts on Windows) are unrelated to the shell lexer.
  • No CODEOWNERS entry for src/shell/.
  • Tests use tempDir per repo convention and filter ASAN noise from stderr.

@robobun
robobun force-pushed the farm/8a526bfc/shell-crlf-line-endings branch from bf909fe to 862e141 Compare April 24, 2026 06:38

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

All earlier feedback (escaped-CR continuation, Double-state escape list, bare-CR backslash preservation, tempDir, ASAN filter) is addressed and the four new tests cover each case — I didn't find anything further, but the final shape touches escape handling across Normal/Double lexer states in four places, so a maintainer glance at the shell.zig changes is worth it before merge.

Extended reasoning...

Overview

This PR makes Bun's shell lexer normalize CRLF line endings so Windows-edited .sh files run correctly. It touches src/shell/shell.zig in four places: (1) a new unescaped '\r' case in the main char dispatch that drops \r before \n in Normal state, (2) a new escaped-'\r' handler so \<CR><LF> acts as a line continuation, (3) adding '\r' to the Double-state backslash escape list in read_char(), and (4) adding '\r' to SPECIAL_CHARS. Four new tests in test/cli/run/run-shell.test.ts cover plain CRLF, backslash continuation, continuation inside double quotes, and the bare-CR-in-double-quotes edge case.

Security risks

None identified. This is pure tokenizer line-ending handling with no auth, crypto, filesystem-path, or external-input-trust implications. The change only affects how \r bytes are treated relative to adjacent \n and backslash bytes.

Level of scrutiny

Moderate-to-high. The shell lexer backs both bun run *.sh and the Bun.$ API, and escape/quote-state handling is exactly the kind of code where subtle interactions hide. The change grew over several review iterations from a single switch arm into coordinated logic across the unescaped path, the escaped path, and read_char()'s Double-state escape list — each iteration fixing a real edge case I (or CodeRabbit) raised, all now covered by tests. I believe the current logic is correct, but lexer state-machine changes of this shape are worth a maintainer's eyes rather than a bot-only sign-off.

Other factors

All four of my prior inline comments and the CodeRabbit ASAN-stderr comment are resolved with corresponding commits and tests. No CODEOWNERS entry covers these files. The PR description reports existing lex.test.ts/parse.test.ts pass unchanged and bunshell.test.ts pass/fail split is identical.

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

All four rounds of prior feedback are addressed and the current lexer logic traces correctly for every CRLF/bare-CR/escaped/quoted combination I checked — but this is core shell-lexer state-machine code that needed three correction rounds to converge, so a maintainer familiar with shell.zig should give it a final look.

Extended reasoning...

Overview

This PR teaches Bun's shell lexer (src/shell/shell.zig) to normalize CRLF line endings so Windows-edited .sh files run correctly. It touches four spots: (1) a new unescaped-\r case in the main char dispatch that drops \r before \n in Normal state, (2) a new escaped-\r handler so \\<CR><LF> works as line-continuation, (3) \r added to the Double-state backslash escape list so the continuation also works inside double quotes, and (4) \r added to SPECIAL_CHARS so the comptime assertion passes and interpolated values containing CR get JS-ref-wrapped rather than inlined. Four new integration tests in test/cli/run/run-shell.test.ts cover the basic CRLF case plus each edge case raised during review.

Security risks

None identified. This is lexer tokenization for line endings; it doesn't touch auth, crypto, filesystem permissions, or network. The SPECIAL_CHARS addition makes interpolation strictly more conservative (more values get escaped via JS-ref), not less.

Level of scrutiny

Moderate-to-high. shell.zig is core runtime — the lexer feeds every .sh execution and every Bun.$ template. The change itself is small (~45 LoC of logic) but the state-machine interactions are subtle: getting it fully correct took three review rounds (escaped-path coverage, Double-state escape list, then the bare-CR-in-double-quotes backslash regression). Each was a real bug, each was fixed, and the final revision now handles every combination I traced (Normal/Double × escaped/unescaped × CRLF/bare-CR). That iteration history is exactly why a human maintainer should confirm the final shape rather than rely on bot sign-off alone.

Other factors

All four of my prior inline comments are resolved, the CodeRabbit ASAN-stderr flake concern is addressed via stripAsanWarning, the bug-hunting system found nothing new on the latest revision, and the new tests use the repo-preferred tempDir helper. The PR description's claim that bare \r is preserved is now actually true in all states. I'm deferring rather than approving solely because of the code path's criticality and the demonstrated subtlety, not because of any outstanding concern.

robobun added a commit that referenced this pull request Apr 24, 2026
…isting)

The integration test `test/integration/build-prefetch/prefetch.test.ts`
spawns `bunExe()` and asserts `stderr` is empty. Under ASAN builds
(`ASSERT_ENABLED`), JSC's `@assert(!dependency.isAsync)` in
`vendor/WebKit/Source/JavaScriptCore/builtins/ModuleLoader.js:544`
fires during module linking and prints an "ASSERTION FAILED" line to
stderr, flipping all four tests to red.

The assertion is a pre-existing JSC builtins issue in async-module
linking — reproducible on `main` with no local changes, and hitting
other unrelated PRs on shards that pick up this test (e.g. #29670).

Add it to `test/expectations.txt` under `[ ASAN ]` so it joins the
other tests that are skipped on ASAN lanes. Unblocks CI for #29678
without mixing unrelated fixes into that PR's code changes. Separate
commit so it can be reverted independently once the JSC assertion is
fixed.
robobun added a commit that referenced this pull request Apr 26, 2026
…isting)

The integration test `test/integration/build-prefetch/prefetch.test.ts`
spawns `bunExe()` and asserts `stderr` is empty. Under ASAN builds
(`ASSERT_ENABLED`), JSC's `@assert(!dependency.isAsync)` in
`vendor/WebKit/Source/JavaScriptCore/builtins/ModuleLoader.js:544`
fires during module linking and prints an "ASSERTION FAILED" line to
stderr, flipping all four tests to red.

The assertion is a pre-existing JSC builtins issue in async-module
linking — reproducible on `main` with no local changes, and hitting
other unrelated PRs on shards that pick up this test (e.g. #29670).

Add it to `test/expectations.txt` under `[ ASAN ]` so it joins the
other tests that are skipped on ASAN lanes. Unblocks CI for #29678
without mixing unrelated fixes into that PR's code changes. Separate
commit so it can be reverted independently once the JSC assertion is
fixed.
robobun added a commit that referenced this pull request Apr 28, 2026
…isting)

The integration test `test/integration/build-prefetch/prefetch.test.ts`
spawns `bunExe()` and asserts `stderr` is empty. Under ASAN builds
(`ASSERT_ENABLED`), JSC's `@assert(!dependency.isAsync)` in
`vendor/WebKit/Source/JavaScriptCore/builtins/ModuleLoader.js:544`
fires during module linking and prints an "ASSERTION FAILED" line to
stderr, flipping all four tests to red.

The assertion is a pre-existing JSC builtins issue in async-module
linking — reproducible on `main` with no local changes, and hitting
other unrelated PRs on shards that pick up this test (e.g. #29670).

Add it to `test/expectations.txt` under `[ ASAN ]` so it joins the
other tests that are skipped on ASAN lanes. Unblocks CI for #29678
without mixing unrelated fixes into that PR's code changes. Separate
commit so it can be reverted independently once the JSC assertion is
fixed.
@Jarred-Sumner
Jarred-Sumner force-pushed the farm/8a526bfc/shell-crlf-line-endings branch from 661fb0e to 8bc4823 Compare May 4, 2026 10:35
@robobun
robobun force-pushed the farm/8a526bfc/shell-crlf-line-endings branch from 8bc4823 to 65f2e8e Compare May 15, 2026 15:54

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

All three rounds of my earlier feedback are addressed and I found nothing new on this revision — deferring only because lexer state-machine changes across two parallel implementations (shell.zig + shell_parser/parse.rs) plus the SPECIAL_CHARS table addition feel worth one human pass before merge.

Extended reasoning...

Overview

The substantive change is CRLF normalization in Bun's shell lexer, implemented in parallel in src/runtime/shell/shell.zig and the Rust port src/shell_parser/parse.rs: an unescaped-\r case that swallows the CR when followed by LF, an escaped-\r handler so \\<CR><LF> acts as line-continuation (with a Double-state special case to re-emit the backslash for bare CR), \r added to the Double-state backslash escape list, and \r added to the SPECIAL_CHARS escaping table. Four new tests in test/cli/run/run-shell.test.ts cover the base case plus each edge case raised in earlier review rounds. The remaining ten .rs files are pure autofix.ci rustfmt reflows of #[cfg(...)] / #[cfg_attr(...)] attributes with zero semantic change.

Security risks

The shell lexer tokenizes user-supplied script content, so changes here are adjacent to command-injection surface. That said, this change only narrows what reaches appendCharToStrPool (dropping a CR that would otherwise be glued onto a token) and adds \r to the escaper's special-char set — neither widens the set of characters that can terminate a quote or start a substitution. I don't see an injection vector introduced here.

Level of scrutiny

Moderate. The fix itself is conceptually simple, but it touches a hand-rolled lexer state machine in two implementations and went through three review iterations on this PR, each of which surfaced a real correctness edge case (escaped-CR continuation, dead Double-state guard, bare-CR backslash regression). That history says the area is subtle enough that a maintainer familiar with the shell subsystem should confirm the final shape — particularly the SPECIAL_CHARS table addition, which also affects ShellSrcBuilder output escaping, and the Rust port's fell_through control-flow mapping of Zig's continue.

Other factors

All prior inline comments (mine and CodeRabbit's) are resolved in the current diff, and each fix has a dedicated regression test. The robobun build-status comment still shows linker/ci.ts failures on 65f2e8e, but the ci: retrigger after release-build infra flake commit and the -no-pie warning text indicate those are infrastructure flakes unrelated to this change.

@robobun
robobun force-pushed the farm/8a526bfc/shell-crlf-line-endings branch from 1f003f8 to e2957e8 Compare May 22, 2026 03:45
Comment thread src/runtime/shell/shell.zig Resolved
@robobun
robobun force-pushed the farm/8a526bfc/shell-crlf-line-endings branch from fbd86ba to 0380528 Compare May 27, 2026 21:21

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

All earlier feedback is addressed (escaped-CR continuation, double-quote escape list, bare-CR backslash preservation, ASAN filter, and the .zig revert) and I didn't find anything new — but since this touches the shell lexer's main char dispatch and the SPECIAL_CHARS set used for template-literal escaping, it's worth a human pass before merging.

Extended reasoning...

Overview

The PR now modifies exactly two files: src/shell_parser/parse.rs (the shipped Rust shell lexer) and test/cli/run/run-shell.test.ts. In parse.rs it (1) adds a '\r' arm to the unescaped char dispatch that discards a CR immediately preceding an unescaped LF in Normal state while leaving CR literal inside quotes and for bare CR; (2) adds an escaped-\r handler so \\<CR><LF> behaves as a line continuation in both Normal and Double state, with the bare-CR-in-double-quotes case re-emitting the swallowed backslash to preserve POSIX semantics; (3) adds '\r' to the Double-state backslash escape list in read_char(); and (4) adds '\r' to SPECIAL_CHARS (now [u8; 35]). The test file adds a stripAsanWarning helper and four integration tests covering each of those paths. The earlier shell.zig edits were reverted in 0380528, so the diff no longer touches reference-only code.

Security risks

Shell lexing determines word boundaries and therefore what gets executed, so any change here is security-adjacent. The specific change is narrowing — CR before LF is dropped rather than glued onto the preceding token — which reduces the surface for surprising tokenization rather than expanding it. Adding \r to SPECIAL_CHARS also flows into SPECIAL_CHARS_TABLE, which (a) gates the lexer's ASCII fast-path scan at parse.rs:2628 (necessary for the new arm to be reachable) and (b) feeds needs_escape_* so a \r in a JS value interpolated into a Bun.$ template now routes through the JS-string-ref path instead of being inlined raw. That second effect is a behavior change outside the .sh-file path and is the part most worth a human glance, though it appears strictly safer (matches how \n is already handled).

Level of scrutiny

Medium-high. The diff is small and well-commented, every edge case I raised over four prior review rounds now has both a code fix and a dedicated regression test, and I traced fell_through / break 'escaped semantics against the surrounding cases to confirm the new arm is consistent. But this is the production shell lexer's hot loop, and lexer edge cases are subtle enough that a maintainer familiar with parse.rs should sign off rather than relying solely on automated review.

Other factors

All six of my earlier inline comments are resolved and reflected in the current diff; CodeRabbit's ASAN-stderr concern is addressed via stripAsanWarning; no CODEOWNERS entry matches these paths; the bug-hunting pass on the current revision found nothing. Given the critical code path I'm deferring rather than approving.

@hyuzea

hyuzea commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

@robobun @Jarred-Sumner

here, i are executing scp'd js bin script from windows to linux

/usr/bin/env: ‘bun\r’: No such file or directory
/usr/bin/env: use -[v]S to pass options in shebang lines

@robobun

robobun commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the report, but this is a different layer than what this PR touches, and it's not something Bun can fix.

When you execute a script directly (./script.js with the exec bit), the OS kernel parses the shebang line, not Bun. With CRLF endings the kernel reads the interpreter line as /usr/bin/env bun\r and hands bun\r to /usr/bin/env, which then can't find an executable by that name. That's exactly your error:

/usr/bin/env: 'bun\r': No such file or directory

Bun is never launched here, so it has no chance to strip the \r. The same failure happens with any interpreter (node, python3, etc.) when a CRLF-terminated shebang is run this way.

This PR (#29670) fixes a related-but-separate case: Bun's own shell interpreter choking on CRLF when it reads a .sh file. The shebang/env path is upstream of that, in the kernel.

The fix is to give the file LF line endings:

dos2unix script.js      # or: sed -i 's/\r$//' script.js

And to keep it that way via .gitattributes:

*.js text eol=lf
*.sh text eol=lf

@robobun
robobun force-pushed the farm/8a526bfc/shell-crlf-line-endings branch from 0380528 to 3018ad9 Compare July 5, 2026 13:42
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main. The conflicts were non-trivial, so a summary of what I resolved:

  • src/runtime/shell/shell.zig (deleted on main): this PR predates Bun's Rust rewrite and originally edited the Zig lexer. Those Zig edits were dead code (added then reverted across earlier commits of this branch, net zero) and main has since deleted the file entirely. Resolved by accepting the deletion — the live fix is in the Rust lexer.
  • src/shell_parser/parse.rs: the four lexer changes (unescaped-\r dispatch arm, escaped-\r line-continuation handler, \r in the Double-state backslash escape list, and the bare-\<CR> backslash re-emit) apply cleanly. SPECIAL_CHARS conflicted because main independently added \r (plus \t and ?); took main's version since \r is already present there.
  • test/cli/run/run-shell.test.ts: my four CRLF tests coexist with main's new package script shell interpreter test; merged cleanly.

History was collapsed into a single commit since the intermediate Zig commits net to zero against the new base. Verified locally: all 7 run-shell tests and the lex/parse suites pass.

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

LGTM — all prior review rounds addressed, each edge case has a dedicated test; the remaining comment-length nit is non-blocking.

Extended reasoning...

Overview

This PR teaches the Bun shell lexer (src/shell_parser/parse.rs) to normalize CRLF line endings in .sh files so Windows-edited scripts run correctly. Three hunks: an unescaped-\r case in the main char dispatch (drops CR before LF in Normal state, preserves it in quotes and when standalone), an escaped-\r handler mirroring the existing escaped-\n line-continuation (with a Double-state backslash re-emit for bare CR), and \r added to the Double-state backslash escape list. Four new subprocess tests in test/cli/run/run-shell.test.ts cover basic CRLF, \<CR><LF> line-continuation, continuation inside double quotes, and the bare-\<CR>-in-double-quotes case that must not drop the backslash.

Security risks

None identified. This is a lexer whitespace/line-ending normalization change. It does not touch auth, crypto, network, filesystem paths, or command dispatch — it only affects whether a \r byte is treated as part of a newline vs. glued onto the preceding word. No new user-controlled data is interpreted; the input space is the same shell script bytes as before.

Level of scrutiny

Moderate. Lexer state-machine changes are subtle, and this PR went through three prior rounds of correctness feedback (escaped-CR path missing → dead Double-state guard → bare-CR backslash regression), each of which was fixed and given a dedicated test. I re-traced all six state combinations (unescaped/escaped × Normal/Double/bare-CR) against the current code and confirmed fell_through correctly gates the continue vs. fall-through-to-append at parse.rs:3143-3147, and that \r is present in SPECIAL_CHARS (parse.rs:4208) so the const assert and ASCII fast-path scanner are consistent. The .zig reference-file hunks were dropped per prior feedback; only the shipped Rust and tests remain.

Other factors

All previously-raised inline comments (mine and CodeRabbit's) are marked resolved and verified addressed in the current diff. The only new finding is a style nit on comment length (CLAUDE.md rule 13) with zero functional impact — it will land as a non-blocking inline suggestion. Test coverage is thorough: each review-round bug is now a permanent regression test, tests use tempDir/bunEnv/concurrent pipe drains per harness conventions, and stderr assertions filter the known ASAN warning. The change is narrowly scoped (~46 lines in one lexer function) and intentionally conservative — bare CR and quoted CR are left untouched, so the only behaviour change is exactly the CRLF-as-LF case the linked issue describes.

Comment thread src/shell_parser/parse.rs Resolved

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

LGTM — all prior feedback addressed and the comment trim in 5c41af4 covers the last nit.

Extended reasoning...

Overview

This PR adds CRLF handling to Bun's shell lexer (src/shell_parser/parse.rs) so Windows-edited .sh files work under bun run. Three targeted additions: (1) an unescaped-\r match arm that drops the CR when immediately followed by LF in Normal state (falls through to the existing \n handler), preserving it literally inside quotes and for bare CR; (2) an escaped-\r handler mirroring the escaped-\n line-continuation, with a Double-state guard that re-emits the backslash for bare \<CR> so POSIX semantics are preserved; (3) \r added to the Double-state backslash-escape list so \<CR><LF> inside double quotes reaches the handler. Four new tests in test/cli/run/run-shell.test.ts cover each case. Net ~40 lines of Rust plus ~85 lines of tests.

Security risks

None. This is line-ending normalization in a lexer — no auth, crypto, permissions, path traversal, or untrusted-size arithmetic. The change only affects how \r bytes tokenize; it cannot introduce injection since it strictly narrows what reaches append_char_to_str_pool (previously \r was always appended; now it's sometimes dropped).

Level of scrutiny

Medium — the shell lexer runs for every .sh file and Bun.$ template. However: the change activates only on \r bytes, which are absent from LF-terminated scripts (the overwhelming majority), and CRLF scripts were completely broken before (\r glued onto every token). The blast radius is therefore "previously-broken input now works" rather than "working input might change." I traced the control flow for all state × escaped × next-char combinations against the surrounding \n and fell_through machinery in parse.rs:2664-3174 and found no path that alters LF-only tokenization.

Other factors

This PR has been through five review rounds over three months; each concern I raised (escaped-CR continuation, dead Double-state guard, bare-CR backslash regression, .zig reference-file edits, over-length comments) was addressed with a follow-up commit and a corresponding test. The final commit 5c41af4 trims comments to the ≤3-line house rule per my last nit. The bug hunter found nothing on the current revision. SPECIAL_CHARS on main already contains \r (line 4196), so the compile-time assert holds. Tests use tempDir, drain pipes concurrently, filter ASAN stderr noise, and assert exact stdout — all matching repo conventions. The design choice to normalize CRLF (deviating from bash, which chokes on it) is the obviously correct product decision for a cross-platform shell and is stated plainly in the PR description.

@robobun

robobun commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 7f3b097 with three changes:

  • 703cef4: the continuation-join test uses the echo builtin. Windows has no printf, and the external one on CI printed n for \n. That was the only real failure in the last CI run.
  • 6970871: a trailing # comment no longer swallows the newline. eat_comment reports whether it ended at a newline and the # arm emits Newline when it did. Tests: echo start # begin followed by echo second (LF and CRLF) in run-shell.test.ts, plus token-level cases in lex.test.ts.
  • 8136431: eat_var skips an escaped LF or CRLF, so $FOO\<LF>ls is the variable FOOls, as in bash and dash.

Also added the token-level lex.test.ts cases for CRLF, lone CR, continuations in each quote state, tab, and two Bun.$ template cases. The PR body is rewritten for the current shape of the change.

Comment thread src/shell_parser/parse.rs Resolved
Comment thread src/shell_parser/parse.rs Resolved
Comment thread src/shell_parser/parse.rs Resolved
The POSIX grammar allows a linebreak after these operators and bash and
dash accept it. The parser went straight into the next command and
reported 'expected a command or assignment' on the Newline token. A
trailing '# comment' after the operator used to work only because the
lexer dropped the newline with the comment.
eat_comment skipped every escaped char, so '# note \' followed by a
newline swallowed the next line in LF files but not in CRLF files. bash
and dash end a comment at the newline. Now eat_comment stops at any
newline.
eat_var ate an escaped identifier char as part of the name, so
$HOME\foo expanded the variable HOMEfoo. bash and dash read $HOME and
then the word foo.
@robobun

robobun commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed f4c6412 with three more changes:

  • 3902b61: the parser skips newlines after |, &&, and || (POSIX linebreak). This fixes the regression from the previous push where echo a | # c followed by cat on the next line stopped parsing. It also fixes the pre-existing cmd | followed by a newline, which failed on stock bun with expected a command or assignment.
  • ae8a8ad: a backslash does not continue a # comment. LF and CRLF files now behave the same.
  • f4c6412: an escaped char ends a variable name, so $HOME\foo is ${HOME}foo as in bash and dash.

Each change has a fail-before test. Token cases are in lex.test.ts, script cases in run-shell.test.ts. bunshell.test.ts: 436 pass, 0 fail. The PR body is updated.

Comment thread src/shell_parser/parse.rs Resolved
Comment thread src/shell_parser/parse.rs Resolved
…ble name

The lexer now continues a variable name across an escaped newline, so
the interpolation guard in outbuf_ends_with_var_ref must look past
trailing backslash-newline sequences. Otherwise a plain value after
$FOO\<LF> was appended verbatim and lexed as part of the name.
The # arm only checked for preceding whitespace, so 'echo a;# note' ran
a command named #. POSIX 2.3 rule 9 starts a comment at the start of any
token, which includes the position after an operator. A # glued to a
word, a closing quote, a variable, or a backslash-newline stays literal.
The word-part token check moves out of break_word into
last_token_is_word_part so both sites share it.
@robobun

robobun commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 2faef9e with two more changes:

  • 5bb9e12: the Bun.$ interpolation guard looks past a trailing \<LF> or \<CR><LF>, so $FOO\<LF>${x} expands FOO and keeps the value separate. This was a regression of the variable-name continuation in the previous push.
  • 2faef9e: a # starts a comment wherever a new token starts, not only after whitespace. echo a;# note no longer runs a command named #. A # glued to a word, a closing quote, a variable, or a backslash-newline stays literal.

Both have fail-before tests in lex.test.ts. Every script in the tests prints the same output under bash, dash, and this branch. The PR body is updated.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/shell_parser/parse.rs Resolved
Comment thread src/shell_parser/parse.rs Resolved
The # arm now asks at_token_start: the word is empty and either the
previous char is unescaped whitespace or the last token is a separator
or an operator. That keeps # literal after ** and after a JS object
reference, which are not in the word-part set break_word uses. The
break_word refactor from the previous commit is reverted.
POSIX 2.6.3: the first unescaped backtick closes the substitution. bash
and dash print hi for echo `echo hi # note`. eat_comment ate the
backtick and the script failed with Unclosed command substitution.
Comment thread src/shell_parser/parse.rs Resolved
@robobun

robobun commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed e93b4d7 with two more changes:

  • 779690e: a # starts a comment only after unescaped whitespace or after a separator or operator token. **#x and ${obj}#x keep #x literal. The break_word refactor is reverted.
  • d77a897: inside backticks a comment ends at the first unescaped backtick (POSIX 2.6.3), so echo \echo hi # note`printshiinstead of failing withUnclosed command substitution`.

Both have fail-before tests in lex.test.ts. bunshell.test.ts: 436 pass, 0 fail. The PR body was briefly overwritten by an API error during an edit and is restored.

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

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

@robobun

robobun commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator Author

This PR breaks one form that main runs correctly. It needs a fix here, or #34901 or #38479 merged first.

A backtick substitution whose last line ends in a comment, with the closing backtick on the next line, no longer parses:

import { $ } from "bun";
await $`${{ raw: "echo `echo a # c\n`" }}`;
  • bash 5.2 prints a. So does bun 1.4.3-canary.1+367d939d9, whose shell lexer is the same as on main.
  • This head (e93b4d7689) merged into main 7583003d82 throws expected a command or assignment but got: "CmdSubstEnd".
  • The $(...) spelling works on both.

Cause: a comment now emits the Newline token that ends it. The closing-backtick arm of the lexer pushes a Delimit after every token that is not a Delimit, so the body ends in Newline Delimit CmdSubstEnd. The parser looks for a command at that Delimit. The ) arm of $(...) skips the Delimit after ;, a newline, or the end of the input. On main the same error already occurs for echo `echo a;` and for a newline before the closing backtick. The comment form works on main only because the comment swallows the newline.

Nine of 30 inputs of this shape regress: the comment after a command, after a pipeline, after &&, inside double quotes, and with more script after the substitution. #34901 and #38479 change the closing-backtick arm so that no Delimit follows a newline or a ;. I did not run this head with either of them.

@robobun

robobun commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator Author

This head fixes a report about where a # starts a comment. I built e93b4d7689 and ran it. No other open PR changes this check.

Line bash 5.2.37 1.4.3-canary.1+367d939d9 this head
echo one\ #two prints one #two prints one and a space prints one #two
touch Report\ #2.txt makes Report #2.txt makes Report with a trailing space makes Report #2.txt
echo three;#note exit 0 exit 1 exit 0
(echo four)#note exit 0 exit 1 exit 0

The cause on main is src/shell_parser/parse.rs:2525. It asks if the previous character is whitespace. It does not ask if that character was escaped, and it does not accept an operator. at_token_start() in this PR does both.

I compared 81 shell lines with bash. The canary matches bash on 49 and this head on 77. The four that still differ are not about where a comment starts: ${X}, $?, and two CRLF inputs that this PR reads as line endings on purpose.

The tests in this PR cover the operator half (;#, |#, )#). No test covers the escaped space or tab. Commit 28de5c7 on branch robobun/e18aec2d/shell-hash-after-escaped-space-tests adds that: three lexer rows and one Bun.$ case in test/js/bun/shell/lex.test.ts, 22 lines, test only. The four new tests fail on the canary and pass on this head. The commit sits directly on e93b4d7689, so git cherry-pick 28de5c77ca applies as is.

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.

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.

Bun Shell on Windows fails to handle CRLF line endings in .sh files, causing command resolution failure

2 participants