Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
76c483c
shell: normalize CRLF line endings in the shell lexer
robobun Jul 5, 2026
f16c43a
shell: trim CRLF lexer comments to house style
robobun Jul 5, 2026
8b84ab6
shell: adapt CRLF handler to break_word(AddDelimiter), add mixed-EOL …
robobun Sep 25, 2026
522c2ff
shell: cut CRLF lexer comments to the non-obvious invariants
robobun Sep 25, 2026
9f83ed7
shell: one-line the escaped-CR comment
robobun Sep 25, 2026
d840d39
test(run-shell): pin the inner bun to bunExe() in the mixed-EOL fixture
robobun Sep 25, 2026
a68604c
shell: treat an unquoted tab as a word breaker
robobun Sep 25, 2026
b594d3d
shell: a backslash-newline inside a word joins it instead of splitting
robobun Sep 25, 2026
ea267c0
[autofix.ci] apply automated fixes
autofix-ci[bot] Sep 25, 2026
c5b3b80
shell: one-line the backslash-newline comment
robobun Sep 25, 2026
703cef4
test: use the echo builtin in the continuation-join test, Windows has…
robobun Sep 25, 2026
6970871
shell: a trailing comment ends the line instead of swallowing the new…
robobun Sep 25, 2026
8136431
shell: a backslash-newline inside a variable name continues the name
robobun Sep 25, 2026
7f3b097
test: format lex.test.ts
robobun Sep 25, 2026
3902b61
shell: a newline after |, && or || continues the command
robobun Sep 25, 2026
ae8a8ad
shell: a backslash does not continue a comment
robobun Sep 25, 2026
f4c6412
shell: an escaped character ends a variable name
robobun Sep 25, 2026
5bb9e12
shell: an interpolated value after \<newline> does not extend a varia…
robobun Sep 25, 2026
2faef9e
shell: a # starts a comment wherever a new token starts
robobun Sep 25, 2026
779690e
shell: a # after ** or an interpolated object stays literal
robobun Sep 25, 2026
d77a897
shell: a comment inside backticks ends at the closing backtick
robobun Sep 25, 2026
e93b4d7
shell: split the eat_comment comment into one line per check
robobun Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/runtime/shell/shell_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -619,12 +619,22 @@ impl<'a> ShellSrcBuilder<'a> {
}

fn outbuf_ends_with_var_ref(&self) -> bool {
match self
.outbuf
let mut buf: &[u8] = self.outbuf;
// The lexer continues a variable name across `\<LF>` and `\<CR><LF>`.
loop {
if let Some(rest) = buf.strip_suffix(b"\\\n") {
buf = rest;
} else if let Some(rest) = buf.strip_suffix(b"\\\r\n") {
buf = rest;
} else {
break;
}
}
match buf
.iter()
.rposition(|b| !(b.is_ascii_alphanumeric() || *b == b'_'))
{
Some(i) => self.outbuf[i] == b'$',
Some(i) => buf[i] == b'$',
None => false,
}
}
Expand Down
123 changes: 104 additions & 19 deletions src/shell_parser/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,8 @@ impl<'bump> Parser<'bump> {
}
};

// POSIX grammar: `and_or AND_IF linebreak pipeline`.
self.skip_newlines();
let right = self.parse_pipeline()?;

let binary = self.allocate(ast::Binary { op, left, right });
Expand All @@ -906,6 +908,8 @@ impl<'bump> Parser<'bump> {
pipeline_items.push(item);

while self.r#match(TokenTag::Pipe) {
// POSIX grammar: `pipe_sequence '|' linebreak command`.
self.skip_newlines();
expr = self.parse_compound_cmd()?;
let item = match expr.as_pipeline_item() {
Some(i) => i,
Expand Down Expand Up @@ -2524,16 +2528,13 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
{
break 'escaped;
}
let whitespace_preceding = if let Some(prev) = self.chars.prev {
ShellCharIter::<ENCODING>::is_whitespace(prev)
} else {
true
};
if !whitespace_preceding {
// A comment starts only where a new token starts (POSIX 2.3 rule 9).
if !self.at_token_start() {
break 'escaped;
}
Comment thread
robobun marked this conversation as resolved.
Resolved
self.break_word(AddDelimiter::AfterText)?;
self.eat_comment();
if self.eat_comment() {
self.tokens.push(Token::Newline);
}
Comment thread
robobun marked this conversation as resolved.
Resolved
fell_through = true;
}
c if c == u32::from(b';') => {
Expand All @@ -2558,6 +2559,22 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
self.tokens.push(Token::Newline);
fell_through = true;
}
// CRLF normalization; `\r` stays literal inside quotes (bash/dash).
c if c == u32::from(b'\r') => {
const _: () = assert!(SPECIAL_CHARS_TABLE.is_set(b'\r' as usize));
if self.chars.state == CharState::Single
|| self.chars.state == CharState::Double
{
break 'escaped;
}
if let Some(next) = self.peek() {
if !next.escaped && next.char == u32::from(b'\n') {
fell_through = true;
break 'escaped;
}
}
break 'escaped;
}
Comment thread
robobun marked this conversation as resolved.
Resolved
// glob asterisks
c if c == u32::from(b'*') => {
const _: () = assert!(SPECIAL_CHARS_TABLE.is_set(b'*' as usize));
Expand Down Expand Up @@ -2858,8 +2875,9 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
fell_through = true;
}
// 3. Word breakers
c if c == u32::from(b' ') => {
c if c == u32::from(b' ') || c == u32::from(b'\t') => {
const _: () = assert!(SPECIAL_CHARS_TABLE.is_set(b' ' as usize));
const _: () = assert!(SPECIAL_CHARS_TABLE.is_set(b'\t' as usize));
if self.chars.state == CharState::Normal {
self.break_word(AddDelimiter::AfterWord)?;
fell_through = true;
Expand All @@ -2876,14 +2894,24 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
// `fell_through` marks cases that should re-enter the loop;
// cases that break 'escaped fall through to appendCharToStrPool below.
}
// Treat newline preceded by backslash as whitespace
// `\<newline>` is removed before tokenizing (POSIX 2.2.1): no word break.
else if char == u32::from(b'\n') {
debug_assert!(input.escaped);
if self.chars.state != CharState::Double {
self.break_word(AddDelimiter::AfterWord)?;
}
continue;
}
// A bare `\<CR>` in double quotes is literal `\` + CR (POSIX): restore the backslash read_char() consumed.
else if char == u32::from(b'\r') {
debug_assert!(input.escaped);
if let Some(next) = self.peek() {
if !next.escaped && next.char == u32::from(b'\n') {
let _ = self.eat();
continue;
}
}
if self.chars.state == CharState::Double {
self.append_char_to_str_pool(u32::from(b'\\'))?;
}
}

self.append_char_to_str_pool(char)?;
}
Expand Down Expand Up @@ -3004,6 +3032,35 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
Ok(())
}

/// True at the start of input and right after a word separator or an operator: where a `#` starts a comment.
fn at_token_start(&self) -> bool {
if self.j != self.word_start {
return false;
}
if let Some(prev) = self.chars.prev {
if !prev.escaped && ShellCharIter::<ENCODING>::is_whitespace(prev) {
return true;
}
}
let Some(last) = self.tokens.last() else {
return true;
};
matches!(
last.tag(),
TokenTag::Delimit
| TokenTag::Semicolon
| TokenTag::Newline
| TokenTag::Pipe
| TokenTag::DoublePipe
| TokenTag::Ampersand
| TokenTag::DoubleAmpersand
| TokenTag::OpenParen
| TokenTag::CloseParen
| TokenTag::CmdSubstBegin
| TokenTag::CmdSubstQuoted
)
}

fn eat_simple_redirect(&mut self, dir: RedirectDirection) -> ast::RedirectFlags {
let is_double = self.eat_simple_redirect_operator(dir);

Expand Down Expand Up @@ -3402,6 +3459,27 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
let char = result.char;
let escaped = result.escaped;

// `\<newline>` is removed before tokenizing (POSIX 2.2.1): the name continues on the next line.
if escaped && (char == u32::from(b'\n') || char == u32::from(b'\r')) {
Comment thread
robobun marked this conversation as resolved.
Resolved
let snap = self.make_snapshot();
let _ = self.eat();
if char == u32::from(b'\n') {
continue;
}
if let Some(next) = self.peek() {
if !next.escaped && next.char == u32::from(b'\n') {
let _ = self.eat();
continue;
}
Comment thread
robobun marked this conversation as resolved.
Resolved
}
self.backtrack(&snap);
return Ok(TextRange { start, end: self.j });
}
// Any other escaped char ends the name: `$HOME\foo` is `${HOME}foo`.
if escaped {
return Ok(TextRange { start, end: self.j });
}

if i == 0 {
match char {
c if c == u32::from(b'=') => {
Expand Down Expand Up @@ -3469,15 +3547,21 @@ impl<'bump, const ENCODING: StringEncoding> Lexer<'bump, ENCODING> {
self.chars.peek()
}

fn eat_comment(&mut self) {
while let Some(peeked) = self.eat() {
if peeked.escaped {
continue;
/// Returns true when the comment ended at a newline (not EOF); the caller emits the `Newline` token it consumed.
fn eat_comment(&mut self) -> bool {
let in_backtick = self.in_subshell == Some(SubShellKind::Backtick);
while let Some(peeked) = self.peek() {
// The first unescaped backtick closes the substitution (POSIX 2.6.3).
if in_backtick && !peeked.escaped && peeked.char == u32::from(b'`') {
return false;
}
let _ = self.eat();
// A backslash does not continue a comment (bash, dash).
if peeked.char == u32::from(b'\n') {
break;
return true;
}
}
Comment thread
robobun marked this conversation as resolved.
Resolved
false
}
}

Expand Down Expand Up @@ -3750,12 +3834,13 @@ impl<'a, const ENCODING: StringEncoding> ShellCharIter<'a, ENCODING> {
Src::Unicode(u) => u.index_next().map(|v| v.char),
}?;
match peeked {
// Backslash only applies to these characters
// Backslash only applies to these characters (`\r` for CRLF line-continuation).
c if c == u32::from(b'$')
|| c == u32::from(b'`')
|| c == u32::from(b'"')
|| c == u32::from(b'\\')
|| c == u32::from(b'\n')
|| c == u32::from(b'\r')
|| c == u32::from(b'#') =>
{
char = peeked;
Expand Down
Loading
Loading