Shell: handle CRLF, tab, comment, and backslash-newline line endings in the lexer
Fixes #29669
Problem
- A
.shfile with CRLF line endings fails underbun run ./repro.sh:bun: command not found: exportanderror: 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\ror\t. A\rfell through toappend_char_to_str_pooland was glued onto the word before it.
Fix
- Add a
\rarm: in Normal state,\r\nis one newline. Inside quotes the byte stays literal, as in bash and dash. A lone\rstays 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>lsis$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
# commentends at its newline (a trailing backslash does not continue it) and emits theNewlinetoken, so the next line is a new command. The parser skips newlines after|,&&, and||(POSIXlinebreak), socmd |on one line and the next command on the following line parse. Any escaped char ends a variable name:$HOME\foois${HOME}foo. A#starts a comment wherever a new token starts, soecho a;# noteno 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) andtest/js/bun/shell/lex.test.ts(token-level cases for CRLF, lone CR, continuations, tab, comments, plus sixBun.$cases). Also ranparse.test.tsandbunshell.test.ts.
Background
- The lexer is one
matchon the next char. An arm either doesbreak 'escaped(append the char to the current word) or setsfell_through(skip it).break_wordflushes the current word as aTexttoken.NewlineandSemicolontokens separate statements in the parser. read_charmarks a char asescapedwhen a backslash precedes it. In Double state only a short list of chars can be escaped ($, backtick,",\,\n,#).\rjoins that list so\<CR><LF>can be a continuation in double quotes.eat_varscans 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>. TheBun.$interpolation guard (outbuf_ends_with_var_refinshell_body.rs) looks past a trailing\<newline>for the same reason, so$FOO\<LF>${x}expandsFOOand keepsxa separate value.- Considered normalizing CRLF to LF when
run_command.rsreads the file: that leavesBun.$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\tbeing part of a word, on$FOO\<LF>lsexpanding as${FOO}ls, or on$HOME\fooexpandingHOMEfoo, changes meaning. All now match bash and dash. - None found for input without
\ror\tbeyond one table lookup per such byte: both were already inSPECIAL_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