Skip to main content

frost_parser/
parser.rs

1//! Recursive descent parser for zsh-compatible shell grammar.
2//!
3//! Grammar hierarchy:
4//!   Program          → CompleteCommand*
5//!   CompleteCommand   → List [&]
6//!   List              → Pipeline ((&& | ||) Pipeline)*
7//!   Pipeline          → [!] Command (| Command)*
8//!   Command           → SimpleCommand | CompoundCommand
9//!   CompoundCommand   → Subshell | BraceGroup | If | For | While | Until | Case | Select | FunctionDef
10//!   SimpleCommand     → (Assignment | Word | Redirect)*
11
12use crate::ast::*;
13use compact_str::CompactString;
14use frost_lexer::{Span, Token, TokenKind};
15
16/// Parse error with position context.
17#[derive(Debug, thiserror::Error)]
18pub enum ParseError {
19    #[error("unexpected token {kind:?} at position {pos}, expected {expected}")]
20    Unexpected {
21        kind: TokenKind,
22        pos: usize,
23        expected: String,
24    },
25
26    #[error("unexpected end of input, expected {expected}")]
27    UnexpectedEof { expected: String },
28}
29
30pub struct Parser<'a> {
31    tokens: &'a [Token],
32    pos: usize,
33    /// Recovered syntax errors, in source order. See `ast::Program::syntax_errors`.
34    syntax_errors: Vec<String>,
35}
36
37/// Where a piece sits inside the word being assembled.
38///
39/// Only one construct cares — `~`, which expands at a word's start and is a
40/// literal anywhere after it (`~/x` is `$HOME/x`; `a~b` is `a~b`).
41#[derive(Clone, Copy, PartialEq, Eq, Debug)]
42enum WordPos {
43    First,
44    Merged,
45}
46
47impl<'a> Parser<'a> {
48    pub fn new(tokens: &'a [Token]) -> Self {
49        Self {
50            tokens,
51            pos: 0,
52            syntax_errors: Vec::new(),
53        }
54    }
55
56    pub fn parse(&mut self) -> Program {
57        let commands = self.parse_program();
58        Program {
59            commands,
60            syntax_errors: std::mem::take(&mut self.syntax_errors),
61        }
62    }
63
64    // ── Helpers ────────────────────────────────────────────────
65
66    fn peek(&self) -> &Token {
67        self.tokens
68            .get(self.pos)
69            .unwrap_or(&self.tokens[self.tokens.len() - 1])
70    }
71
72    fn kind(&self) -> TokenKind {
73        self.peek().kind
74    }
75
76    fn advance(&mut self) -> &Token {
77        let tok = &self.tokens[self.pos.min(self.tokens.len() - 1)];
78        if self.pos < self.tokens.len() {
79            self.pos += 1;
80        }
81        tok
82    }
83
84    fn at(&self, kind: TokenKind) -> bool {
85        self.kind() == kind || self.word_matches_keyword(kind)
86    }
87
88    fn eat(&mut self, kind: TokenKind) -> bool {
89        if self.at(kind) {
90            self.advance();
91            true
92        } else {
93            false
94        }
95    }
96
97    /// The lexer may produce `Word("if")` instead of `TokenKind::If` depending on
98    /// command_position context. This helper matches either form.
99    fn word_matches_keyword(&self, kind: TokenKind) -> bool {
100        if self.kind() != TokenKind::Word {
101            return false;
102        }
103        let text = self.peek().text.as_str();
104        matches!(
105            (text, kind),
106            ("if", TokenKind::If)
107                | ("then", TokenKind::Then)
108                | ("elif", TokenKind::Elif)
109                | ("else", TokenKind::Else)
110                | ("fi", TokenKind::Fi)
111                | ("for", TokenKind::For)
112                | ("in", TokenKind::In)
113                | ("while", TokenKind::While)
114                | ("until", TokenKind::Until)
115                | ("do", TokenKind::Do)
116                | ("done", TokenKind::Done)
117                | ("case", TokenKind::Case)
118                | ("esac", TokenKind::Esac)
119                | ("select", TokenKind::Select)
120                | ("function", TokenKind::Function)
121                | ("time", TokenKind::Time)
122                | ("coproc", TokenKind::Coproc)
123        )
124    }
125
126    /// Consume `kind`, or RECORD a syntax error and skip one token.
127    ///
128    /// Recovery is deliberate — an interactive shell should still produce a
129    /// usable AST from a half-typed line. Recording it is the part that was
130    /// missing: silent recovery made `while true` (no `do`) recover into an
131    /// infinite loop with an empty body, which the executor then ran forever.
132    fn expect(&mut self, kind: TokenKind) {
133        if !self.eat(kind) {
134            let found = self.kind();
135            self.syntax_errors
136                .push(format!("expected {kind:?}, found {found:?}"));
137            self.advance();
138        }
139    }
140
141    fn skip_newlines(&mut self) {
142        while self.at(TokenKind::Newline) {
143            self.advance();
144        }
145    }
146
147    fn at_eof(&self) -> bool {
148        self.at(TokenKind::Eof)
149    }
150
151    fn span(&self) -> Span {
152        self.peek().span
153    }
154
155    /// Whether the current token can start a command.
156    fn at_command_start(&self) -> bool {
157        // Check if it's a keyword word like Word("if")
158        if self.kind() == TokenKind::Word {
159            let text = self.peek().text.as_str();
160            if matches!(
161                text,
162                "if" | "for"
163                    | "while"
164                    | "until"
165                    | "case"
166                    | "select"
167                    | "function"
168                    | "time"
169                    | "coproc"
170                    | "[["
171                    | "repeat"
172            ) {
173                return true;
174            }
175        }
176        matches!(
177            self.kind(),
178            TokenKind::Word
179                | TokenKind::SingleQuoted
180                | TokenKind::DoubleQuoted
181                | TokenKind::DollarSingleQuoted
182                | TokenKind::Dollar
183                | TokenKind::DollarParam
184                | TokenKind::DollarBrace
185                | TokenKind::DollarParen
186                | TokenKind::DollarDoubleParen
187                | TokenKind::Backtick
188                | TokenKind::Tilde
189                | TokenKind::Star
190                | TokenKind::Question
191                | TokenKind::At
192                | TokenKind::Bang
193                | TokenKind::Less
194                | TokenKind::Greater
195                | TokenKind::DoubleGreater
196                | TokenKind::AmpGreater
197                | TokenKind::AmpDoubleGreater
198                | TokenKind::GreaterPipe
199                | TokenKind::GreaterBang
200                | TokenKind::DoubleLess
201                | TokenKind::TripleLess
202                | TokenKind::LessGreater
203                | TokenKind::FdGreater
204                | TokenKind::FdLess
205                | TokenKind::FdDoubleGreater
206                | TokenKind::FdDup
207                | TokenKind::Number
208                | TokenKind::LeftParen
209                | TokenKind::LeftBrace
210                | TokenKind::If
211                | TokenKind::For
212                | TokenKind::While
213                | TokenKind::Until
214                | TokenKind::Case
215                | TokenKind::Select
216                | TokenKind::Function
217                | TokenKind::Time
218                | TokenKind::Coproc
219        )
220    }
221
222    /// Whether the current token is a word-like token (can be part of a Word).
223    fn at_word(&self) -> bool {
224        matches!(
225            self.kind(),
226            TokenKind::Word
227                | TokenKind::SingleQuoted
228                | TokenKind::DoubleQuoted
229                | TokenKind::DollarSingleQuoted
230                | TokenKind::Dollar
231                | TokenKind::DollarParam
232                | TokenKind::DollarBrace
233                | TokenKind::DollarParen
234                | TokenKind::DollarDoubleParen
235                | TokenKind::Backtick
236                | TokenKind::Tilde
237                | TokenKind::Star
238                | TokenKind::Question
239                | TokenKind::At
240                | TokenKind::Number
241                | TokenKind::Equals
242                // Process substitution opens a word — `<(cmd)` / `>(cmd)`.
243                | TokenKind::ProcessSubIn
244                | TokenKind::ProcessSubOut
245        )
246    }
247
248    /// Check if `{` is followed by brace expansion content rather than commands.
249    ///
250    /// Returns true for patterns like `{1..5}`, `{a,b,c}`, `{01..10}`.
251    /// These have `}` within a few tokens with no newlines/semis.
252    #[allow(dead_code)] // Retained for the brace-expansion disambiguator rework.
253    fn looks_like_brace_expansion(&self) -> bool {
254        // Look ahead from after the `{` for a quick `}` with content that
255        // looks like brace expansion (no newlines, semis, pipes, etc.)
256        let mut i = self.pos + 1; // skip past `{`
257        let mut saw_comma_or_dots = false;
258        let mut depth = 1u32;
259        while i < self.tokens.len() && i < self.pos + 20 {
260            let tok = &self.tokens[i];
261            match tok.kind {
262                TokenKind::LeftBrace => depth += 1,
263                TokenKind::RightBrace => {
264                    depth -= 1;
265                    if depth == 0 {
266                        // Found closing } — it's brace expansion if we saw comma or ..
267                        return saw_comma_or_dots;
268                    }
269                }
270                TokenKind::Newline
271                | TokenKind::Semi
272                | TokenKind::Pipe
273                | TokenKind::AndAnd
274                | TokenKind::OrOr
275                | TokenKind::Eof => {
276                    return false; // Definitely a brace group
277                }
278                TokenKind::Word => {
279                    let text = tok.text.as_str();
280                    if text.contains(',') || text.contains("..") {
281                        saw_comma_or_dots = true;
282                    }
283                }
284                _ => {}
285            }
286            i += 1;
287        }
288        false
289    }
290
291    /// Whether the current token is a brace that could be part of brace expansion
292    /// within a word (e.g., `a{1,2}b`).
293    fn at_brace_in_word(&self) -> bool {
294        matches!(self.kind(), TokenKind::LeftBrace | TokenKind::RightBrace)
295    }
296
297    /// Whether the current token is one of the block-closing reserved words,
298    /// in either form — a real `TokenKind` or a `Word` whose text spells it.
299    fn at_reserved_word_form(&self) -> bool {
300        self.at(TokenKind::Then)
301            || self.at(TokenKind::Elif)
302            || self.at(TokenKind::Else)
303            || self.at(TokenKind::Fi)
304            || self.at(TokenKind::Do)
305            || self.at(TokenKind::Done)
306            || self.at(TokenKind::Esac)
307    }
308
309    fn at_redirect(&self) -> bool {
310        matches!(
311            self.kind(),
312            TokenKind::Less
313                | TokenKind::Greater
314                | TokenKind::DoubleGreater
315                | TokenKind::GreaterPipe
316                | TokenKind::GreaterBang
317                | TokenKind::AmpGreater
318                | TokenKind::AmpDoubleGreater
319                | TokenKind::DoubleLess
320                | TokenKind::TripleLess
321                | TokenKind::DoubleLessDash
322                | TokenKind::LessGreater
323                | TokenKind::FdGreater
324                | TokenKind::FdLess
325                | TokenKind::FdDoubleGreater
326                | TokenKind::FdDup
327        )
328    }
329
330    // ── Program ────────────────────────────────────────────────
331
332    fn parse_program(&mut self) -> Vec<CompleteCommand> {
333        let mut commands = Vec::new();
334        self.skip_newlines();
335
336        while !self.at_eof() {
337            let pos_before = self.pos;
338
339            if self.at_command_start() {
340                commands.push(self.parse_complete_command());
341            }
342            // Consume separators between commands
343            if !self.eat(TokenKind::Semi) && !self.eat(TokenKind::Newline) {
344                if !self.at_eof() {
345                    self.skip_newlines();
346                }
347            }
348            self.skip_newlines();
349
350            // Cursor-did-not-move check — the same guard
351            // `parse_compound_body` has carried since the 2026-05-30 hang,
352            // which this loop (its sibling) was missing. A token that is
353            // neither a command start nor an eatable separator — a stray
354            // `)`, a `Comment`, a `RightBrace` — otherwise traps this loop
355            // spinning forever without advancing `self.pos`. Measured
356            // 2026-08-07: `echo "$(echo "$(echo deep)")"` mis-lexed into a
357            // stranded `)` and hung the shell with no output.
358            //
359            // Consume one token to make progress. The resulting parse may
360            // be wrong; the shell stays alive, which is the trade every
361            // other recovery point in this parser already makes.
362            if self.pos == pos_before {
363                self.advance();
364                if self.pos == pos_before {
365                    break; // pinned at Eof — nothing left to consume
366                }
367            }
368        }
369        commands
370    }
371
372    // ── CompleteCommand ────────────────────────────────────────
373
374    fn parse_complete_command(&mut self) -> CompleteCommand {
375        let list = self.parse_list();
376        let is_async = self.eat(TokenKind::Ampersand);
377        CompleteCommand { list, is_async }
378    }
379
380    // ── List ───────────────────────────────────────────────────
381
382    fn parse_list(&mut self) -> List {
383        let first = self.parse_pipeline();
384        let mut rest = Vec::new();
385
386        loop {
387            let op = if self.eat(TokenKind::AndAnd) {
388                Some(ListOp::And)
389            } else if self.eat(TokenKind::OrOr) {
390                Some(ListOp::Or)
391            } else {
392                None
393            };
394
395            match op {
396                Some(op) => {
397                    self.skip_newlines();
398                    rest.push((op, self.parse_pipeline()));
399                }
400                None => break,
401            }
402        }
403
404        List { first, rest }
405    }
406
407    // ── Pipeline ───────────────────────────────────────────────
408
409    fn parse_pipeline(&mut self) -> Pipeline {
410        let bang = self.eat(TokenKind::Bang);
411        let first = self.parse_command();
412        let mut commands = vec![first];
413        let mut pipe_stderr = Vec::new();
414
415        loop {
416            if self.eat(TokenKind::Pipe) {
417                pipe_stderr.push(false);
418                self.skip_newlines();
419                commands.push(self.parse_command());
420            } else if self.eat(TokenKind::PipeAmpersand) {
421                pipe_stderr.push(true);
422                self.skip_newlines();
423                commands.push(self.parse_command());
424            } else {
425                break;
426            }
427        }
428
429        Pipeline {
430            bang,
431            commands,
432            pipe_stderr,
433        }
434    }
435
436    // ── Command ────────────────────────────────────────────────
437
438    fn parse_command(&mut self) -> Command {
439        // Check for [[ ... ]] conditional
440        if self.kind() == TokenKind::Word && self.peek().text.as_str() == "[[" {
441            return self.parse_cond_command();
442        }
443        // Check for (( expr )) arithmetic command
444        if self.at(TokenKind::LeftParen) && self.is_arith_cmd_ahead() {
445            return self.parse_arith_cmd();
446        }
447        // Check for C-style for: for ((
448        if self.at(TokenKind::For) && self.is_c_for_ahead() {
449            return self.parse_c_for();
450        }
451        // Check for repeat N
452        if self.kind() == TokenKind::Word && self.peek().text.as_str() == "repeat" {
453            return self.parse_repeat();
454        }
455        // Check both TokenKind and Word text for keywords
456        if self.at(TokenKind::LeftParen) {
457            return self.parse_subshell();
458        }
459        if self.at(TokenKind::LeftBrace) {
460            return self.parse_brace_group();
461        }
462        if self.at(TokenKind::If) {
463            return self.parse_if();
464        }
465        if self.at(TokenKind::For) {
466            return self.parse_for();
467        }
468        if self.at(TokenKind::While) {
469            return self.parse_while();
470        }
471        if self.at(TokenKind::Until) {
472            return self.parse_until();
473        }
474        if self.at(TokenKind::Case) {
475            return self.parse_case();
476        }
477        if self.at(TokenKind::Select) {
478            return self.parse_select();
479        }
480        if self.at(TokenKind::Function) {
481            return self.parse_function_def();
482        }
483        if self.at(TokenKind::Time) {
484            return self.parse_time();
485        }
486        if self.at(TokenKind::Coproc) {
487            return self.parse_coproc();
488        }
489
490        match self.kind() {
491            _ => {
492                // Check for function definition: name () { ... }
493                if self.is_function_def_ahead() {
494                    return self.parse_function_def_short();
495                }
496                Command::Simple(self.parse_simple_command())
497            }
498        }
499    }
500
501    fn is_function_def_ahead(&self) -> bool {
502        // name () — function definition without 'function' keyword
503        if self.kind() == TokenKind::Word {
504            if let Some(next) = self.tokens.get(self.pos + 1) {
505                if next.kind == TokenKind::LeftParen {
506                    if let Some(after) = self.tokens.get(self.pos + 2) {
507                        return after.kind == TokenKind::RightParen;
508                    }
509                }
510            }
511        }
512        false
513    }
514
515    // ── SimpleCommand ──────────────────────────────────────────
516
517    fn parse_simple_command(&mut self) -> SimpleCommand {
518        let mut assignments = Vec::new();
519        let mut words = Vec::new();
520        let mut redirects = Vec::new();
521
522        // Parse leading assignments (before any command word)
523        while self.is_assignment() && words.is_empty() {
524            assignments.push(self.parse_assignment());
525        }
526
527        // Parse words and redirects
528        while !self.at_eof()
529            && !self.kind().is_separator()
530            && !matches!(
531                self.kind(),
532                TokenKind::RightParen
533                    | TokenKind::RightBrace
534                    | TokenKind::DoubleSemi
535                    | TokenKind::SemiAnd
536                    | TokenKind::SemiPipe
537                    | TokenKind::Then
538                    | TokenKind::Elif
539                    | TokenKind::Else
540                    | TokenKind::Fi
541                    | TokenKind::Do
542                    | TokenKind::Done
543                    | TokenKind::Esac
544            )
545            // Also check word-based keywords — but only where a reserved
546            // word can actually BE one. `self.at()` falls back to matching
547            // a plain `Word`'s TEXT (the lexer's own reserved-word check is
548            // inert: it slices `src[pos..pos]` after consuming, so it always
549            // sees an empty string). That fallback is position-blind, so
550            // `done` / `fi` / `in` in ARGUMENT position ended the command:
551            // `echo done` produced a zero-word `echo` and then an endless
552            // run of empty commands — `frost -c 'echo done'` hung outright
553            // on the stock binary (measured 2026-08-07), and
554            // `echo a done b` printed `a` and then tried to run `b`.
555            //
556            // A reserved word is reserved only at the START of a command,
557            // which here means before the first word has been taken. Every
558            // real terminator that follows a word — `;`, a newline, `|`,
559            // `&&`, `)`, `}`, `;;` — is a genuine token and is caught above.
560            && !(words.is_empty() && self.at_reserved_word_form())
561        {
562            if self.at_redirect() {
563                redirects.push(self.parse_redirect());
564            } else if self.at_word() {
565                words.push(self.parse_word());
566            } else if self.kind() == TokenKind::Bang
567                && !(words.is_empty() && assignments.is_empty())
568            {
569                // Mid-command `!` is a literal word in zsh script
570                // semantics: `test ! -d x`, `test a != b`. Command-
571                // position negation was already consumed by
572                // parse_pipeline, so a Bang here can only be argument
573                // text (history expansion is a REPL-layer concern,
574                // disabled in scripts). parse_word's adjacency merge
575                // joins a following `=` into one `!=` word.
576                words.push(self.parse_word());
577            } else {
578                break;
579            }
580        }
581
582        SimpleCommand {
583            assignments,
584            words,
585            redirects,
586        }
587    }
588
589    fn is_assignment(&self) -> bool {
590        // Pattern: Word Equals [Word] — the lexer splits FOO=bar into three tokens
591        // Also handles FOO+=bar (Word("FOO+") Equals)
592        // Also handles FOO[sub]=bar (Word("FOO[sub]") Equals or Word("FOO[sub]+") Equals)
593        if self.kind() != TokenKind::Word {
594            return false;
595        }
596        let name = &self.peek().text;
597        // Strip trailing + for += detection
598        let check_name = name.strip_suffix('+').unwrap_or(name);
599        // Strip subscript [sub] for identifier check
600        let ident_part = if let Some(bracket) = check_name.find('[') {
601            &check_name[..bracket]
602        } else {
603            check_name
604        };
605        let is_ident = !ident_part.is_empty()
606            && ident_part
607                .bytes()
608                .all(|b| b.is_ascii_alphanumeric() || b == b'_')
609            && !ident_part.bytes().next().unwrap_or(b'0').is_ascii_digit();
610        if !is_ident {
611            return false;
612        }
613        // Check if next token is Equals
614        self.tokens
615            .get(self.pos + 1)
616            .is_some_and(|t| t.kind == TokenKind::Equals)
617    }
618
619    fn parse_assignment(&mut self) -> Assignment {
620        let name_tok = self.advance().clone(); // Word (the name, possibly with trailing +)
621        let eq_span = self.peek().span;
622        self.expect(TokenKind::Equals); // =
623
624        // Determine assignment operator, extracting subscript if present
625        let raw = name_tok.text.as_str();
626        let (base, is_append) = if let Some(stripped) = raw.strip_suffix('+') {
627            (stripped, true)
628        } else {
629            (raw, false)
630        };
631
632        let (name, subscript) = if let Some(bracket) = base.find('[') {
633            let close = base.rfind(']').unwrap_or(base.len());
634            let name_part = &base[..bracket];
635            let sub_part = &base[bracket + 1..close];
636            (
637                CompactString::from(name_part),
638                Some(CompactString::from(sub_part)),
639            )
640        } else {
641            (CompactString::from(base), None)
642        };
643
644        let op = if is_append {
645            AssignOp::Append
646        } else {
647            AssignOp::Assign
648        };
649
650        // Check for array literal: name=(word word ...)
651        if self.at(TokenKind::LeftParen) {
652            self.advance(); // consume (
653            let mut words = Vec::new();
654            while !self.at(TokenKind::RightParen) && !self.at(TokenKind::Eof) {
655                self.skip_newlines();
656                if self.at(TokenKind::RightParen) {
657                    break;
658                }
659                if self.at_word() {
660                    words.push(self.parse_word());
661                } else {
662                    break;
663                }
664            }
665            if self.at(TokenKind::RightParen) {
666                self.advance(); // consume )
667            }
668            return Assignment {
669                name,
670                subscript: subscript.clone(),
671                op,
672                value: None,
673                array_value: Some(words),
674                span: Span::new(name_tok.span.start, eq_span.end),
675            };
676        }
677
678        // Scalar value
679        let value = if self.at_word() {
680            Some(self.parse_word())
681        } else {
682            None
683        };
684
685        Assignment {
686            name,
687            subscript,
688            op,
689            value,
690            array_value: None,
691            span: Span::new(name_tok.span.start, eq_span.end),
692        }
693    }
694
695    // ── Word ───────────────────────────────────────────────────
696
697    /// Parse a single word for re-expansion of operator arguments.
698    ///
699    /// `frost-expand` calls this to recursively expand the *word* of a
700    /// `${var:-word}` / `${var:=word}` / `${var:?word}` form, where `word`
701    /// may itself contain `$other`, `${nested}`, `$(cmd)`, `$((expr))` and
702    /// quoting. Reusing the real word parser keeps a single expansion path.
703    pub fn parse_word_for_expansion(&mut self) -> Word {
704        self.parse_word()
705    }
706
707    /// Parse one word.
708    ///
709    /// A word is one or more **adjacent** word-like tokens: the lexer splits
710    /// on operators but not on whitespace, so `FOO=bar` arrives as three
711    /// tokens and `${a}${b}` as six, and span adjacency is what rejoins them.
712    ///
713    /// Both the first piece and every merged piece go through
714    /// [`Self::parse_word_piece`] — one dispatch, not two. They used to be
715    /// separate matches, and the merge copy handled only the single-token
716    /// kinds: `${…}`, `$(…)`, `$((…))`, `<(…)` and `~` all fell to a literal
717    /// fallback there. Worse, the driver tracked `end_pos` from the *first*
718    /// token alone, so any multi-token construct left `end_pos` pointing
719    /// mid-word and the adjacency test failed against every following piece.
720    /// `x=${a}${b}` silently assigned just `AB` and `y=${a}Z` ran `Z` as a
721    /// command (measured 2026-08-07).
722    ///
723    /// TERMINATION: `parse_word_piece` calls `advance()` at least once, so
724    /// `self.pos` strictly increases each iteration and the loop is bounded
725    /// by `self.tokens.len()`.
726    fn parse_word(&mut self) -> Word {
727        let start_span = self.span();
728        let mut parts = Vec::new();
729
730        self.parse_word_piece(&mut parts, WordPos::First);
731        let mut end_pos = self.prev_end();
732
733        // An adjacent Bang joins too (`a!=b`, `hi!` are one word in zsh
734        // script semantics — history expansion is a REPL-layer concern);
735        // it lands on the Literal fallback arm inside `parse_word_piece`.
736        while self.pos < self.tokens.len()
737            && self.peek().span.start == end_pos
738            && (self.at_word() || self.at_brace_in_word() || self.kind() == TokenKind::Bang)
739        {
740            let pos_before = self.pos;
741            self.parse_word_piece(&mut parts, WordPos::Merged);
742            debug_assert!(self.pos > pos_before, "parse_word_piece must consume");
743            if self.pos == pos_before {
744                break; // provable advance, belt to the debug_assert's braces
745            }
746            end_pos = self.prev_end();
747        }
748
749        Word {
750            parts,
751            span: start_span,
752        }
753    }
754
755    /// End offset of the most recently consumed token — where the word has
756    /// reached so far. Adjacency is measured against this, so a construct
757    /// that consumed many tokens reports its real end rather than its
758    /// opener's.
759    fn prev_end(&self) -> u32 {
760        self.tokens
761            .get(self.pos.saturating_sub(1))
762            .map_or(0, |t| t.span.end)
763    }
764
765    /// Consume the token(s) of exactly one word part and push it.
766    ///
767    /// Always advances at least one token — `parse_word`'s termination proof
768    /// rests on that.
769    fn parse_word_piece(&mut self, parts: &mut Vec<WordPart>, pos: WordPos) {
770        let tok = self.advance().clone();
771        match tok.kind {
772            TokenKind::Word | TokenKind::Number => {
773                parts.push(WordPart::Literal(tok.text.clone()));
774            }
775            TokenKind::SingleQuoted => {
776                // Strip surrounding quotes
777                let inner = strip_quotes(&tok.text, '\'');
778                parts.push(WordPart::SingleQuoted(inner));
779            }
780            TokenKind::DoubleQuoted => {
781                // Wrap in WordPart::DoubleQuoted so the expansion engine
782                // can distinguish `"$x"` from `$x` — quoted empties
783                // must be preserved as one arg (`[ -n "" ]` = 3 args)
784                // while unquoted unset expansion collapses. Stripping
785                // the wrapper would lose that provenance.
786                let inner = strip_quotes(&tok.text, '"');
787                parts.push(WordPart::DoubleQuoted(parse_double_quoted_parts(&inner)));
788            }
789            TokenKind::DollarSingleQuoted => {
790                // `$'…'` — ANSI-C quoting. NOT a double-quoted string: no
791                // parameter, command or arithmetic expansion happens inside,
792                // only backslash-escape decoding, and the result is a single
793                // quoted field. Routing it through the double-quote path
794                // (which is what used to happen) left `$'hello'` completely
795                // unexpanded — and `direnv export bash` emits every line as
796                // `export VAR=$'…'`, so the whole chpwd hook was a no-op.
797                parts.push(WordPart::AnsiCQuoted(strip_ansi_c_quotes(&tok.text)));
798            }
799            TokenKind::DollarParam => {
800                // A complete special-parameter token (e.g. `$#`). Its
801                // text is `$<char>`; strip the `$` to recover the name.
802                let name = tok.text.strip_prefix('$').unwrap_or(&tok.text);
803                parts.push(WordPart::DollarVar(CompactString::from(name)));
804            }
805            TokenKind::Dollar => {
806                // `$VAR` — the name is the next token, and it must be
807                // physically adjacent: `$ foo` is a literal `$` then a word.
808                let adjacent =
809                    self.pos < self.tokens.len() && self.peek().span.start == tok.span.end;
810                if adjacent && (self.kind() == TokenKind::Word || self.kind() == TokenKind::Number)
811                {
812                    // The lexer stops a word only at a metacharacter, and
813                    // `/`, `:`, `.` and `-` are not metacharacters — so
814                    // `$d/xx` arrives as `$` + Word("d/xx") and `$PATH:/bin`
815                    // as `$` + Word("PATH:/bin"). Taking the whole token as
816                    // the name looked up a variable that cannot exist and
817                    // expanded to nothing: a redirect target `> $d/xx`
818                    // became `> ` (measured 2026-08-07). Cut the name at the
819                    // first byte that cannot be in one, and keep the tail as
820                    // an adjacent literal.
821                    let name_tok = self.advance().clone();
822                    let text = name_tok.text.as_str();
823                    let split = param_name_len(text);
824                    if split == 0 {
825                        parts.push(WordPart::Literal(CompactString::from("$")));
826                        parts.push(WordPart::Literal(name_tok.text.clone()));
827                    } else {
828                        parts.push(WordPart::DollarVar(CompactString::from(&text[..split])));
829                        if split < text.len() {
830                            parts.push(WordPart::Literal(CompactString::from(&text[split..])));
831                        }
832                    }
833                } else if adjacent
834                    && matches!(
835                        self.kind(),
836                        TokenKind::Question | TokenKind::Bang | TokenKind::At | TokenKind::Star
837                    )
838                {
839                    // Special parameters: $?, $!, $@, $*. Without this,
840                    // `rc=$?` fell through to Literal("$") + Glob(?).
841                    let special_tok = self.advance();
842                    let name = match special_tok.kind {
843                        TokenKind::Question => "?",
844                        TokenKind::Bang => "!",
845                        TokenKind::At => "@",
846                        TokenKind::Star => "*",
847                        _ => unreachable!(),
848                    };
849                    parts.push(WordPart::DollarVar(CompactString::from(name)));
850                } else if adjacent && self.at(TokenKind::Dollar) {
851                    // $$ — PID
852                    self.advance();
853                    parts.push(WordPart::DollarVar(CompactString::from("$")));
854                } else {
855                    parts.push(WordPart::Literal(CompactString::from("$")));
856                }
857            }
858            TokenKind::DollarBrace => {
859                // ${...} — collect all content between ${ and } as raw text
860                let mut raw = String::new();
861                let mut depth = 1u32;
862                while !self.at_eof() {
863                    if self.at(TokenKind::RightBrace) {
864                        depth -= 1;
865                        if depth == 0 {
866                            self.advance(); // consume }
867                            break;
868                        }
869                        raw.push('}');
870                        self.advance();
871                    } else if self.at(TokenKind::LeftBrace) || self.at(TokenKind::DollarBrace) {
872                        depth += 1;
873                        raw.push_str(&self.advance().text);
874                    } else {
875                        raw.push_str(&self.advance().text);
876                    }
877                }
878                parts.push(WordPart::DollarBrace {
879                    param: CompactString::from(raw.trim()),
880                    operator: None,
881                    arg: None,
882                });
883            }
884            TokenKind::DollarParen => {
885                // $(cmd) — collect inner tokens and recursively parse
886                let mut inner_tokens = Vec::new();
887                let mut depth = 1u32;
888                while !self.at_eof() && depth > 0 {
889                    if self.at(TokenKind::LeftParen) || self.at(TokenKind::DollarParen) {
890                        depth += 1;
891                        inner_tokens.push(self.advance().clone());
892                    } else if self.at(TokenKind::RightParen) {
893                        depth -= 1;
894                        if depth == 0 {
895                            self.advance(); // consume closing )
896                            break;
897                        }
898                        inner_tokens.push(self.advance().clone());
899                    } else {
900                        inner_tokens.push(self.advance().clone());
901                    }
902                }
903                // Add EOF token for the sub-parser
904                inner_tokens.push(Token {
905                    kind: TokenKind::Eof,
906                    span: self.span(),
907                    text: CompactString::default(),
908                });
909                // Recursively parse the inner tokens
910                let mut sub_parser = Parser::new(&inner_tokens);
911                let sub_program = sub_parser.parse();
912                parts.push(WordPart::CommandSub(Box::new(sub_program)));
913            }
914            TokenKind::DollarDoubleParen => {
915                // $((expr)) — arithmetic substitution
916                let mut expr = String::new();
917                while !self.at_eof() {
918                    // Look for ))
919                    if self.at(TokenKind::RightParen) {
920                        self.advance();
921                        if self.eat(TokenKind::RightParen) {
922                            break;
923                        }
924                        expr.push(')');
925                    } else {
926                        expr.push_str(&self.advance().text);
927                    }
928                }
929                parts.push(WordPart::ArithSub(CompactString::from(expr)));
930            }
931            TokenKind::Backtick => {
932                parts.push(WordPart::CommandSub(Box::new(Program::default())));
933            }
934            // Glob metacharacters must preserve their WordPart::Glob tag so
935            // the executor can recognize the word as needing filesystem
936            // globbing. Without this, `sub/*` was parsed as a single
937            // Literal word, silently disabling glob expansion.
938            TokenKind::Star => parts.push(WordPart::Glob(GlobKind::Star)),
939            TokenKind::Question => parts.push(WordPart::Glob(GlobKind::Question)),
940            TokenKind::At => parts.push(WordPart::Glob(GlobKind::At)),
941            // Process substitution `<(cmd)` / `>(cmd)` — parse the
942            // parenthesized body as a standalone program, same shape as
943            // `$(cmd)` above. The executor later forks a subprocess whose
944            // pipe is exposed as `/dev/fd/N`.
945            TokenKind::ProcessSubIn | TokenKind::ProcessSubOut => {
946                let kind = if tok.kind == TokenKind::ProcessSubIn {
947                    ProcessSubKind::Input
948                } else {
949                    ProcessSubKind::Output
950                };
951                let mut inner_tokens = Vec::new();
952                let mut depth = 1u32;
953                while !self.at_eof() && depth > 0 {
954                    if self.at(TokenKind::LeftParen)
955                        || self.at(TokenKind::DollarParen)
956                        || self.at(TokenKind::ProcessSubIn)
957                        || self.at(TokenKind::ProcessSubOut)
958                    {
959                        depth += 1;
960                        inner_tokens.push(self.advance().clone());
961                    } else if self.at(TokenKind::RightParen) {
962                        depth -= 1;
963                        if depth == 0 {
964                            self.advance();
965                            break;
966                        }
967                        inner_tokens.push(self.advance().clone());
968                    } else {
969                        inner_tokens.push(self.advance().clone());
970                    }
971                }
972                inner_tokens.push(Token {
973                    kind: TokenKind::Eof,
974                    span: self.span(),
975                    text: CompactString::default(),
976                });
977                let mut sub_parser = Parser::new(&inner_tokens);
978                let body = sub_parser.parse();
979                parts.push(WordPart::ProcessSub {
980                    kind,
981                    body: Box::new(body),
982                });
983            }
984            // Tilde expansion only fires at the START of a word — zsh's own
985            // rule, and the behaviour the merge arm already had by accident
986            // when it fell to the literal fallback. Without the distinction,
987            // unifying the two dispatches would have turned `echo a~b` into
988            // `a~b` -> `a` + Tilde("b") -> `a~b`... but `echo a~/x` into
989            // `a` + $HOME. Keep `~` literal once a word is under way.
990            TokenKind::Tilde if pos == WordPos::First => {
991                let adjacent =
992                    self.pos < self.tokens.len() && self.peek().span.start == tok.span.end;
993                let user = if adjacent && self.kind() == TokenKind::Word {
994                    self.advance().text.clone()
995                } else {
996                    CompactString::default()
997                };
998                parts.push(WordPart::Tilde(user));
999            }
1000            TokenKind::Equals => {
1001                parts.push(WordPart::Literal(CompactString::from("=")));
1002            }
1003            _ => {
1004                // Fallback: treat as literal
1005                parts.push(WordPart::Literal(tok.text.clone()));
1006            }
1007        }
1008    }
1009    // ── Redirect ───────────────────────────────────────────────
1010
1011    fn parse_redirect(&mut self) -> Redirect {
1012        let redir_tok = self.advance().clone();
1013        let (fd, op) = match redir_tok.kind {
1014            TokenKind::Less => (None, RedirectOp::Less),
1015            TokenKind::Greater => (None, RedirectOp::Greater),
1016            TokenKind::DoubleGreater => (None, RedirectOp::DoubleGreater),
1017            TokenKind::GreaterPipe => (None, RedirectOp::GreaterPipe),
1018            TokenKind::GreaterBang => (None, RedirectOp::GreaterBang),
1019            TokenKind::AmpGreater => (None, RedirectOp::AmpGreater),
1020            TokenKind::AmpDoubleGreater => (None, RedirectOp::AmpDoubleGreater),
1021            TokenKind::DoubleLess => (None, RedirectOp::DoubleLess),
1022            TokenKind::TripleLess => (None, RedirectOp::TripleLess),
1023            TokenKind::DoubleLessDash => (None, RedirectOp::DoubleLessDash),
1024            TokenKind::LessGreater => (None, RedirectOp::LessGreater),
1025            TokenKind::FdGreater => {
1026                let fd_num = parse_fd_prefix(&redir_tok.text);
1027                (Some(fd_num), RedirectOp::Greater)
1028            }
1029            TokenKind::FdLess => {
1030                let fd_num = parse_fd_prefix(&redir_tok.text);
1031                (Some(fd_num), RedirectOp::Less)
1032            }
1033            TokenKind::FdDoubleGreater => {
1034                let fd_num = parse_fd_prefix(&redir_tok.text);
1035                (Some(fd_num), RedirectOp::DoubleGreater)
1036            }
1037            TokenKind::FdDup => {
1038                let fd_num = parse_fd_prefix(&redir_tok.text);
1039                (Some(fd_num), RedirectOp::FdDup)
1040            }
1041            _ => (None, RedirectOp::Greater),
1042        };
1043
1044        // Parse the target word
1045        let target = if self.at_word() {
1046            self.parse_word()
1047        } else {
1048            // Missing target — produce empty word
1049            Word {
1050                parts: vec![],
1051                span: self.span(),
1052            }
1053        };
1054
1055        Redirect {
1056            fd,
1057            op,
1058            target,
1059            span: redir_tok.span,
1060        }
1061    }
1062
1063    // ── Compound commands ──────────────────────────────────────
1064
1065    /// Check if we're at `((` — two consecutive LeftParen tokens.
1066    fn is_arith_cmd_ahead(&self) -> bool {
1067        self.at(TokenKind::LeftParen)
1068            && self
1069                .tokens
1070                .get(self.pos + 1)
1071                .is_some_and(|t| t.kind == TokenKind::LeftParen)
1072    }
1073
1074    /// Parse `(( expr ))` — arithmetic evaluation command.
1075    fn parse_arith_cmd(&mut self) -> Command {
1076        self.expect(TokenKind::LeftParen);
1077        self.expect(TokenKind::LeftParen);
1078        // Collect tokens until we see `))`
1079        let mut expr = String::new();
1080        let mut depth = 0;
1081        loop {
1082            if self.at(TokenKind::Eof) {
1083                break;
1084            }
1085            if self.at(TokenKind::RightParen) {
1086                if depth == 0 {
1087                    // Check if next is also RightParen → end of (( ))
1088                    if self
1089                        .tokens
1090                        .get(self.pos + 1)
1091                        .is_some_and(|t| t.kind == TokenKind::RightParen)
1092                    {
1093                        self.advance(); // first )
1094                        self.advance(); // second )
1095                        break;
1096                    }
1097                }
1098                depth -= 1;
1099                expr.push(')');
1100                self.advance();
1101                continue;
1102            }
1103            if self.at(TokenKind::LeftParen) {
1104                depth += 1;
1105                expr.push('(');
1106                self.advance();
1107                continue;
1108            }
1109            // Collect the token's text
1110            let tok = self.advance().clone();
1111            expr.push_str(&tok.text);
1112            // Add whitespace between tokens
1113            if !self.at(TokenKind::RightParen) && !self.at(TokenKind::Eof) {
1114                expr.push(' ');
1115            }
1116        }
1117        Command::ArithCmd(CompactString::new(&expr.trim()))
1118    }
1119
1120    fn parse_subshell(&mut self) -> Command {
1121        self.expect(TokenKind::LeftParen);
1122        self.skip_newlines();
1123        let body = self.parse_compound_body(&[TokenKind::RightParen]);
1124        self.expect(TokenKind::RightParen);
1125        let redirects = self.parse_trailing_redirects();
1126        Command::Subshell(Subshell { body, redirects })
1127    }
1128
1129    fn parse_brace_group(&mut self) -> Command {
1130        self.expect(TokenKind::LeftBrace);
1131        self.skip_newlines();
1132        let body = self.parse_compound_body(&[TokenKind::RightBrace]);
1133        self.expect(TokenKind::RightBrace);
1134
1135        // Check for `always { ... }` block
1136        if self.kind() == TokenKind::Word && self.peek().text.as_str() == "always" {
1137            self.advance(); // consume "always"
1138            self.expect(TokenKind::LeftBrace);
1139            self.skip_newlines();
1140            let always_body = self.parse_compound_body(&[TokenKind::RightBrace]);
1141            self.expect(TokenKind::RightBrace);
1142            return Command::TryAlways(Box::new(TryAlwaysClause {
1143                try_body: body,
1144                always_body,
1145            }));
1146        }
1147
1148        let redirects = self.parse_trailing_redirects();
1149        Command::BraceGroup(BraceGroup { body, redirects })
1150    }
1151
1152    fn parse_if(&mut self) -> Command {
1153        self.expect(TokenKind::If);
1154        self.skip_newlines();
1155        let condition = self.parse_compound_body(&[TokenKind::Then]);
1156        self.expect(TokenKind::Then);
1157        self.skip_newlines();
1158        let then_body =
1159            self.parse_compound_body(&[TokenKind::Elif, TokenKind::Else, TokenKind::Fi]);
1160
1161        let mut elifs = Vec::new();
1162        while self.eat(TokenKind::Elif) {
1163            self.skip_newlines();
1164            let elif_cond = self.parse_compound_body(&[TokenKind::Then]);
1165            self.expect(TokenKind::Then);
1166            self.skip_newlines();
1167            let elif_body =
1168                self.parse_compound_body(&[TokenKind::Elif, TokenKind::Else, TokenKind::Fi]);
1169            elifs.push((elif_cond, elif_body));
1170        }
1171
1172        let else_body = if self.eat(TokenKind::Else) {
1173            self.skip_newlines();
1174            Some(self.parse_compound_body(&[TokenKind::Fi]))
1175        } else {
1176            None
1177        };
1178
1179        self.expect(TokenKind::Fi);
1180        let redirects = self.parse_trailing_redirects();
1181        Command::If(Box::new(IfClause {
1182            condition,
1183            then_body,
1184            elifs,
1185            else_body,
1186            redirects,
1187        }))
1188    }
1189
1190    fn parse_for(&mut self) -> Command {
1191        self.expect(TokenKind::For);
1192        let var = self.advance().text.clone();
1193
1194        let words = if self.eat(TokenKind::In) {
1195            let mut ws = Vec::new();
1196            while self.at_word() {
1197                ws.push(self.parse_word());
1198            }
1199            // Consume separator
1200            let _ = self.eat(TokenKind::Semi) || self.eat(TokenKind::Newline);
1201            Some(ws)
1202        } else {
1203            let _ = self.eat(TokenKind::Semi) || self.eat(TokenKind::Newline);
1204            None
1205        };
1206
1207        self.skip_newlines();
1208        // zsh allows { ... } or do ... done
1209        let (body, redirects) = if self.at(TokenKind::LeftBrace) {
1210            self.expect(TokenKind::LeftBrace);
1211            self.skip_newlines();
1212            let body = self.parse_compound_body(&[TokenKind::RightBrace]);
1213            self.expect(TokenKind::RightBrace);
1214            (body, self.parse_trailing_redirects())
1215        } else {
1216            self.expect(TokenKind::Do);
1217            self.skip_newlines();
1218            let body = self.parse_compound_body(&[TokenKind::Done]);
1219            self.expect(TokenKind::Done);
1220            (body, self.parse_trailing_redirects())
1221        };
1222        Command::For(Box::new(ForClause {
1223            var,
1224            words,
1225            body,
1226            redirects,
1227        }))
1228    }
1229
1230    fn parse_while(&mut self) -> Command {
1231        self.expect(TokenKind::While);
1232        self.skip_newlines();
1233        let condition = self.parse_compound_body(&[TokenKind::Do, TokenKind::LeftBrace]);
1234        let (body, redirects) = if self.at(TokenKind::LeftBrace) {
1235            self.expect(TokenKind::LeftBrace);
1236            self.skip_newlines();
1237            let body = self.parse_compound_body(&[TokenKind::RightBrace]);
1238            self.expect(TokenKind::RightBrace);
1239            (body, self.parse_trailing_redirects())
1240        } else {
1241            self.expect(TokenKind::Do);
1242            self.skip_newlines();
1243            let body = self.parse_compound_body(&[TokenKind::Done]);
1244            self.expect(TokenKind::Done);
1245            (body, self.parse_trailing_redirects())
1246        };
1247        Command::While(Box::new(WhileClause {
1248            condition,
1249            body,
1250            redirects,
1251        }))
1252    }
1253
1254    fn parse_until(&mut self) -> Command {
1255        self.expect(TokenKind::Until);
1256        self.skip_newlines();
1257        let condition = self.parse_compound_body(&[TokenKind::Do, TokenKind::LeftBrace]);
1258        let (body, redirects) = if self.at(TokenKind::LeftBrace) {
1259            self.expect(TokenKind::LeftBrace);
1260            self.skip_newlines();
1261            let body = self.parse_compound_body(&[TokenKind::RightBrace]);
1262            self.expect(TokenKind::RightBrace);
1263            (body, self.parse_trailing_redirects())
1264        } else {
1265            self.expect(TokenKind::Do);
1266            self.skip_newlines();
1267            let body = self.parse_compound_body(&[TokenKind::Done]);
1268            self.expect(TokenKind::Done);
1269            (body, self.parse_trailing_redirects())
1270        };
1271        Command::Until(Box::new(UntilClause {
1272            condition,
1273            body,
1274            redirects,
1275        }))
1276    }
1277
1278    fn parse_case(&mut self) -> Command {
1279        self.expect(TokenKind::Case);
1280        let word = self.parse_word();
1281        self.skip_newlines();
1282        self.expect(TokenKind::In);
1283        self.skip_newlines();
1284
1285        let mut items = Vec::new();
1286        while !self.at(TokenKind::Esac) && !self.at_eof() {
1287            // Optional leading (
1288            self.eat(TokenKind::LeftParen);
1289
1290            // Parse patterns: pat1 | pat2 )
1291            let mut patterns = Vec::new();
1292            if self.at_word() {
1293                patterns.push(self.parse_word());
1294                while self.eat(TokenKind::Pipe) {
1295                    if self.at_word() {
1296                        patterns.push(self.parse_word());
1297                    }
1298                }
1299            }
1300            self.expect(TokenKind::RightParen);
1301            self.skip_newlines();
1302
1303            // Parse body until ;; or ;& or ;| or esac
1304            let body = self.parse_compound_body(&[
1305                TokenKind::DoubleSemi,
1306                TokenKind::SemiAnd,
1307                TokenKind::SemiPipe,
1308                TokenKind::Esac,
1309            ]);
1310
1311            let terminator = if self.eat(TokenKind::SemiAnd) {
1312                CaseTerminator::SemiAnd
1313            } else if self.eat(TokenKind::SemiPipe) {
1314                CaseTerminator::SemiPipe
1315            } else {
1316                self.eat(TokenKind::DoubleSemi);
1317                CaseTerminator::DoubleSemi
1318            };
1319            self.skip_newlines();
1320
1321            if !patterns.is_empty() {
1322                items.push(CaseItem {
1323                    patterns,
1324                    body,
1325                    terminator,
1326                });
1327            }
1328        }
1329
1330        self.expect(TokenKind::Esac);
1331        let redirects = self.parse_trailing_redirects();
1332        Command::Case(Box::new(CaseClause {
1333            word,
1334            items,
1335            redirects,
1336        }))
1337    }
1338
1339    fn parse_select(&mut self) -> Command {
1340        self.expect(TokenKind::Select);
1341        let var = self.advance().text.clone();
1342
1343        let words = if self.eat(TokenKind::In) {
1344            let mut ws = Vec::new();
1345            while self.at_word() {
1346                ws.push(self.parse_word());
1347            }
1348            let _ = self.eat(TokenKind::Semi) || self.eat(TokenKind::Newline);
1349            Some(ws)
1350        } else {
1351            let _ = self.eat(TokenKind::Semi) || self.eat(TokenKind::Newline);
1352            None
1353        };
1354
1355        self.skip_newlines();
1356        self.expect(TokenKind::Do);
1357        self.skip_newlines();
1358        let body = self.parse_compound_body(&[TokenKind::Done]);
1359        self.expect(TokenKind::Done);
1360        let redirects = self.parse_trailing_redirects();
1361        Command::Select(Box::new(SelectClause {
1362            var,
1363            words,
1364            body,
1365            redirects,
1366        }))
1367    }
1368
1369    fn parse_function_def(&mut self) -> Command {
1370        self.expect(TokenKind::Function);
1371        let name = self.advance().text.clone();
1372        // Optional ()
1373        if self.eat(TokenKind::LeftParen) {
1374            self.eat(TokenKind::RightParen);
1375        }
1376        self.skip_newlines();
1377        let body = self.parse_command();
1378        let redirects = self.parse_trailing_redirects();
1379        Command::FunctionDef(Box::new(FunctionDef {
1380            name,
1381            body,
1382            redirects,
1383        }))
1384    }
1385
1386    fn parse_function_def_short(&mut self) -> Command {
1387        // name () { ... }
1388        let name = self.advance().text.clone();
1389        self.expect(TokenKind::LeftParen);
1390        self.expect(TokenKind::RightParen);
1391        self.skip_newlines();
1392        let body = self.parse_command();
1393        let redirects = self.parse_trailing_redirects();
1394        Command::FunctionDef(Box::new(FunctionDef {
1395            name,
1396            body,
1397            redirects,
1398        }))
1399    }
1400
1401    fn parse_time(&mut self) -> Command {
1402        self.expect(TokenKind::Time);
1403        let pipeline = self.parse_pipeline();
1404        Command::Time(Box::new(TimeClause { pipeline }))
1405    }
1406
1407    fn parse_coproc(&mut self) -> Command {
1408        self.expect(TokenKind::Coproc);
1409        let name = if self.kind() == TokenKind::Word
1410            && !self
1411                .tokens
1412                .get(self.pos + 1)
1413                .is_some_and(|t| t.kind == TokenKind::LeftParen || t.kind == TokenKind::LeftBrace)
1414        {
1415            None
1416        } else {
1417            Some(self.advance().text.clone())
1418        };
1419        let command = self.parse_command();
1420        Command::Coproc(Box::new(Coproc { name, command }))
1421    }
1422
1423    // ── Compound body helper ───────────────────────────────────
1424
1425    /// Whether the current position matches any of the stop tokens (including word-keyword fallback).
1426    fn at_any(&self, kinds: &[TokenKind]) -> bool {
1427        kinds.iter().any(|k| self.at(*k))
1428    }
1429
1430    /// Parse a sequence of complete commands until one of the stop tokens.
1431    fn parse_compound_body(&mut self, stop: &[TokenKind]) -> Vec<CompleteCommand> {
1432        let mut commands = Vec::new();
1433        loop {
1434            self.skip_newlines();
1435            if self.at_eof() || self.at_any(stop) {
1436                break;
1437            }
1438            if self.at_command_start() {
1439                commands.push(self.parse_complete_command());
1440            }
1441            // Consume separators
1442            if !self.eat(TokenKind::Semi) && !self.eat(TokenKind::Newline) {
1443                if self.at_eof() || self.at_any(stop) {
1444                    break;
1445                }
1446                // Defence-in-depth: any token we don't recognise as a
1447                // command-start AND can't eat as a separator would
1448                // otherwise trap this loop in an infinite spin without
1449                // advancing self.pos. The 2026-05-30 frostmourne hang
1450                // was a `Comment` token landing here after lexer
1451                // mis-classification of `$#`. Consume one token to
1452                // make progress; the resulting parse may be wrong but
1453                // the shell stays alive instead of locking up.
1454                let pos_before = self.pos;
1455                self.advance();
1456                if self.pos == pos_before {
1457                    break;
1458                }
1459            }
1460        }
1461        commands
1462    }
1463
1464    fn parse_trailing_redirects(&mut self) -> Vec<Redirect> {
1465        let mut redirects = Vec::new();
1466        while self.at_redirect() {
1467            redirects.push(self.parse_redirect());
1468        }
1469        redirects
1470    }
1471
1472    // ── [[ ]] conditional parsing ─────────────────────────────
1473
1474    fn parse_cond_command(&mut self) -> Command {
1475        self.advance(); // consume "[["
1476        let expr = self.parse_cond_or();
1477        // consume "]]"
1478        if self.kind() == TokenKind::Word && self.peek().text.as_str() == "]]" {
1479            self.advance();
1480        }
1481        Command::Cond(Box::new(expr))
1482    }
1483
1484    fn parse_cond_or(&mut self) -> CondExpr {
1485        let mut left = self.parse_cond_and();
1486        while self.kind() == TokenKind::OrOr {
1487            self.advance();
1488            let right = self.parse_cond_and();
1489            left = CondExpr::Or(Box::new(left), Box::new(right));
1490        }
1491        left
1492    }
1493
1494    fn parse_cond_and(&mut self) -> CondExpr {
1495        let mut left = self.parse_cond_not();
1496        while self.kind() == TokenKind::AndAnd {
1497            self.advance();
1498            let right = self.parse_cond_not();
1499            left = CondExpr::And(Box::new(left), Box::new(right));
1500        }
1501        left
1502    }
1503
1504    fn parse_cond_not(&mut self) -> CondExpr {
1505        if self.kind() == TokenKind::Bang {
1506            self.advance();
1507            let expr = self.parse_cond_not();
1508            return CondExpr::Not(Box::new(expr));
1509        }
1510        self.parse_cond_primary()
1511    }
1512
1513    fn parse_cond_primary(&mut self) -> CondExpr {
1514        // Parenthesized expression
1515        if self.at(TokenKind::LeftParen) {
1516            self.advance();
1517            let expr = self.parse_cond_or();
1518            if self.at(TokenKind::RightParen) {
1519                self.advance();
1520            }
1521            return expr;
1522        }
1523
1524        // Check for unary operator: -flag word
1525        if self.kind() == TokenKind::Word || self.kind() == TokenKind::Number {
1526            let text = self.peek().text.clone();
1527            if let Some(op) = parse_unary_cond_op(text.as_str()) {
1528                self.advance(); // consume operator
1529                let word = self.parse_cond_word();
1530                return CondExpr::Unary(op, word);
1531            }
1532        }
1533
1534        // Also handle -flag when lexer produces it differently (e.g., hyphen + word)
1535        // For now, handle the common case where -flag is a single Word token
1536
1537        // Parse left operand for binary expression
1538        let left = self.parse_cond_word();
1539
1540        // Check for binary operator
1541        if self.at_cond_end() {
1542            // Implicit -n test: [[ word ]] → [[ -n word ]]
1543            return CondExpr::Unary(CondOp::StrNonEmpty, left);
1544        }
1545
1546        // Check for == and = (Equals tokens)
1547        if self.kind() == TokenKind::Equals {
1548            self.advance();
1549            // == (double equals)
1550            if self.kind() == TokenKind::Equals {
1551                self.advance();
1552            }
1553            let right = self.parse_cond_word();
1554            return CondExpr::Binary(left, CondOp::StrEq, right);
1555        }
1556
1557        // Check for != (Bang followed by Equals)
1558        if self.kind() == TokenKind::Bang {
1559            if self
1560                .tokens
1561                .get(self.pos + 1)
1562                .is_some_and(|t| t.kind == TokenKind::Equals)
1563            {
1564                self.advance(); // !
1565                self.advance(); // =
1566                let right = self.parse_cond_word();
1567                return CondExpr::Binary(left, CondOp::StrNeq, right);
1568            }
1569        }
1570
1571        // Check < and > (redirection tokens used as string comparison)
1572        if self.kind() == TokenKind::Less {
1573            self.advance();
1574            let right = self.parse_cond_word();
1575            return CondExpr::Binary(left, CondOp::StrLt, right);
1576        }
1577        if self.kind() == TokenKind::Greater {
1578            self.advance();
1579            let right = self.parse_cond_word();
1580            return CondExpr::Binary(left, CondOp::StrGt, right);
1581        }
1582
1583        // Check for word-based binary operators (-eq, -ne, -lt, etc.)
1584        let text = self.peek().text.clone();
1585        if let Some(op) = parse_binary_cond_op(text.as_str()) {
1586            self.advance(); // consume operator
1587            let right = self.parse_cond_word();
1588            return CondExpr::Binary(left, op, right);
1589        }
1590
1591        // Fallback: implicit -n test
1592        CondExpr::Unary(CondOp::StrNonEmpty, left)
1593    }
1594
1595    /// Check if we're at the end of a [[ ]] conditional.
1596    fn at_cond_end(&self) -> bool {
1597        (self.kind() == TokenKind::Word && self.peek().text.as_str() == "]]")
1598            || self.at(TokenKind::AndAnd)
1599            || self.at(TokenKind::OrOr)
1600            || self.at(TokenKind::RightParen)
1601            || self.at_eof()
1602    }
1603
1604    /// Parse a word inside [[ ]] — no globbing or word splitting.
1605    fn parse_cond_word(&mut self) -> Word {
1606        if self.at_word() || self.kind() == TokenKind::Bang {
1607            self.parse_word()
1608        } else {
1609            Word {
1610                parts: vec![WordPart::Literal(CompactString::default())],
1611                span: self.span(),
1612            }
1613        }
1614    }
1615
1616    // ── C-style for loop ──────────────────────────────────────
1617
1618    fn is_c_for_ahead(&self) -> bool {
1619        // for (( — check if next two tokens are ( (
1620        if let Some(next) = self.tokens.get(self.pos + 1) {
1621            if next.kind == TokenKind::LeftParen {
1622                if let Some(after) = self.tokens.get(self.pos + 2) {
1623                    return after.kind == TokenKind::LeftParen;
1624                }
1625            }
1626        }
1627        false
1628    }
1629
1630    fn parse_c_for(&mut self) -> Command {
1631        self.expect(TokenKind::For); // consume 'for'
1632        self.expect(TokenKind::LeftParen); // consume first (
1633        self.expect(TokenKind::LeftParen); // consume second (
1634
1635        // Collect three expressions separated by ;
1636        let mut exprs = [String::new(), String::new(), String::new()];
1637        let mut idx = 0;
1638        loop {
1639            if self.at_eof() {
1640                break;
1641            }
1642            if self.at(TokenKind::RightParen) {
1643                if self
1644                    .tokens
1645                    .get(self.pos + 1)
1646                    .is_some_and(|t| t.kind == TokenKind::RightParen)
1647                {
1648                    self.advance(); // first )
1649                    self.advance(); // second )
1650                    break;
1651                }
1652                exprs[idx.min(2)].push(')');
1653                self.advance();
1654                continue;
1655            }
1656            if self.at(TokenKind::LeftParen) {
1657                exprs[idx.min(2)].push('(');
1658                self.advance();
1659                continue;
1660            }
1661            if self.at(TokenKind::Semi) {
1662                self.advance();
1663                if idx < 2 {
1664                    idx += 1;
1665                }
1666                continue;
1667            }
1668            let tok = self.advance().clone();
1669            if !exprs[idx.min(2)].is_empty() {
1670                exprs[idx.min(2)].push(' ');
1671            }
1672            exprs[idx.min(2)].push_str(&tok.text);
1673        }
1674
1675        self.skip_newlines();
1676        // Body can be { ... } or do ... done
1677        let (body, redirects) = if self.at(TokenKind::LeftBrace) {
1678            self.expect(TokenKind::LeftBrace);
1679            self.skip_newlines();
1680            let body = self.parse_compound_body(&[TokenKind::RightBrace]);
1681            self.expect(TokenKind::RightBrace);
1682            (body, self.parse_trailing_redirects())
1683        } else {
1684            self.expect(TokenKind::Do);
1685            self.skip_newlines();
1686            let body = self.parse_compound_body(&[TokenKind::Done]);
1687            self.expect(TokenKind::Done);
1688            (body, self.parse_trailing_redirects())
1689        };
1690
1691        Command::CFor(Box::new(CForClause {
1692            init: CompactString::new(&exprs[0]),
1693            condition: CompactString::new(&exprs[1]),
1694            step: CompactString::new(&exprs[2]),
1695            body,
1696            redirects,
1697        }))
1698    }
1699
1700    // ── repeat N ──────────────────────────────────────────────
1701
1702    fn parse_repeat(&mut self) -> Command {
1703        self.advance(); // consume "repeat"
1704        let count = self.parse_word();
1705        self.skip_newlines();
1706        // Body can be { ... } or do ... done or a single command
1707        let (body, redirects) = if self.at(TokenKind::LeftBrace) {
1708            self.expect(TokenKind::LeftBrace);
1709            self.skip_newlines();
1710            let body = self.parse_compound_body(&[TokenKind::RightBrace]);
1711            self.expect(TokenKind::RightBrace);
1712            (body, self.parse_trailing_redirects())
1713        } else if self.at(TokenKind::Do) {
1714            self.expect(TokenKind::Do);
1715            self.skip_newlines();
1716            let body = self.parse_compound_body(&[TokenKind::Done]);
1717            self.expect(TokenKind::Done);
1718            (body, self.parse_trailing_redirects())
1719        } else {
1720            let cmd = self.parse_complete_command();
1721            (vec![cmd], vec![])
1722        };
1723        Command::Repeat(Box::new(RepeatClause {
1724            count,
1725            body,
1726            redirects,
1727        }))
1728    }
1729}
1730
1731// ── Helper functions ───────────────────────────────────────────
1732
1733/// Parse a unary condition operator (e.g., `-f`, `-d`, `-z`).
1734fn parse_unary_cond_op(s: &str) -> Option<CondOp> {
1735    Some(match s {
1736        "-e" | "-a" => CondOp::FileExists,
1737        "-f" => CondOp::IsFile,
1738        "-d" => CondOp::IsDir,
1739        "-L" | "-h" => CondOp::IsSymlink,
1740        "-r" => CondOp::IsReadable,
1741        "-w" => CondOp::IsWritable,
1742        "-x" => CondOp::IsExecutable,
1743        "-s" => CondOp::IsNonEmpty,
1744        "-b" => CondOp::IsBlockDev,
1745        "-c" => CondOp::IsCharDev,
1746        "-p" => CondOp::IsFifo,
1747        "-S" => CondOp::IsSocket,
1748        "-u" => CondOp::IsSetuid,
1749        "-g" => CondOp::IsSetgid,
1750        "-k" => CondOp::IsSticky,
1751        "-O" => CondOp::OwnedByUser,
1752        "-G" => CondOp::OwnedByGroup,
1753        "-N" => CondOp::ModifiedSinceRead,
1754        "-t" => CondOp::IsTty,
1755        "-o" => CondOp::OptionSet,
1756        "-v" => CondOp::VarIsSet,
1757        "-z" => CondOp::StrEmpty,
1758        "-n" => CondOp::StrNonEmpty,
1759        _ => return None,
1760    })
1761}
1762
1763/// Parse a binary condition operator.
1764fn parse_binary_cond_op(s: &str) -> Option<CondOp> {
1765    Some(match s {
1766        "==" | "=" => CondOp::StrEq,
1767        "!=" => CondOp::StrNeq,
1768        "<" => CondOp::StrLt,
1769        ">" => CondOp::StrGt,
1770        "=~" => CondOp::StrMatch,
1771        "-eq" => CondOp::IntEq,
1772        "-ne" => CondOp::IntNe,
1773        "-lt" => CondOp::IntLt,
1774        "-le" => CondOp::IntLe,
1775        "-gt" => CondOp::IntGt,
1776        "-ge" => CondOp::IntGe,
1777        "-nt" => CondOp::NewerThan,
1778        "-ot" => CondOp::OlderThan,
1779        "-ef" => CondOp::SameFile,
1780        _ => return None,
1781    })
1782}
1783
1784fn strip_quotes(text: &str, quote: char) -> CompactString {
1785    let s = text.strip_prefix(quote).unwrap_or(text);
1786    let s = s.strip_suffix(quote).unwrap_or(s);
1787    CompactString::from(s)
1788}
1789
1790/// Length of the parameter name at the start of `text`, per zsh: a
1791/// positional parameter is ONE digit (`$1abc` is `${1}abc`), and a named
1792/// parameter is `[A-Za-z_][A-Za-z0-9_]*`. Returns 0 when `text` cannot start
1793/// a name at all.
1794fn param_name_len(text: &str) -> usize {
1795    let bytes = text.as_bytes();
1796    match bytes.first() {
1797        Some(b) if b.is_ascii_digit() => 1,
1798        Some(b) if b.is_ascii_alphabetic() || *b == b'_' => bytes
1799            .iter()
1800            .position(|b| !(b.is_ascii_alphanumeric() || *b == b'_'))
1801            .unwrap_or(bytes.len()),
1802        _ => 0,
1803    }
1804}
1805
1806/// `$'a\nb'` → `a\nb` (the raw body, escapes still encoded). Decoding is the
1807/// expander's job — the parser stays syntax-only.
1808fn strip_ansi_c_quotes(text: &str) -> CompactString {
1809    let s = text.strip_prefix("$'").unwrap_or(text);
1810    let s = s.strip_suffix('\'').unwrap_or(s);
1811    CompactString::from(s)
1812}
1813
1814/// Lex and parse a fragment that the lexer already handed us as raw text —
1815/// the body of a `$(…)` found inside a double-quoted token.
1816///
1817/// TERMINATION: `frost_lexer::tokenize_str` is the tree's one drain-to-Eof
1818/// loop and carries its own cursor-did-not-move guard, so this always
1819/// returns.
1820fn parse_fragment(src: &str) -> Program {
1821    let tokens = frost_lexer::tokenize_str(src);
1822    Parser::new(&tokens).parse()
1823}
1824
1825/// Parse the body of a double-quoted string into word parts.
1826///
1827/// Inside `"…"` zsh performs parameter, command and arithmetic expansion, and
1828/// removes a backslash only when it precedes a character that is special
1829/// there. Everything else is literal.
1830///
1831/// `$(…)` used to be emitted as the literal text `$(` here — a *silent wrong
1832/// answer*, not a missing feature: frostmourne's dirty marker
1833/// `[ -n "$(git status --porcelain)" ]` tested a non-empty literal, so git
1834/// never ran and every repo read as dirty (diagnosed 2026-08-07). `$((…))`
1835/// had the same hole.
1836///
1837/// TERMINATION: `i` strictly increases on every path — the three expansion
1838/// arms jump to an index that [`frost_lexer::matching_close`] guarantees is
1839/// past their opener, and every other arm adds 1 or 2. Bound is
1840/// `bytes.len()`.
1841fn parse_double_quoted_parts(content: &str) -> Vec<WordPart> {
1842    let mut parts = Vec::new();
1843    let bytes = content.as_bytes();
1844    let mut i = 0;
1845    let mut literal_start = 0;
1846
1847    // Flush `content[literal_start..upto]` as a literal part, if non-empty.
1848    macro_rules! flush {
1849        ($upto:expr) => {
1850            if $upto > literal_start {
1851                parts.push(WordPart::Literal(CompactString::from(
1852                    &content[literal_start..$upto],
1853                )));
1854            }
1855        };
1856    }
1857
1858    while i < bytes.len() {
1859        if bytes[i] == b'\\' && i + 1 < bytes.len() {
1860            // In double quotes a backslash escapes only `$ ` ` " \` and a
1861            // newline; before anything else it is an ordinary character.
1862            // The old code advanced past both bytes without ever flushing,
1863            // so the backslash survived into the literal and `echo "a\"b"`
1864            // printed `a\"b`.
1865            let esc = bytes[i + 1];
1866            if matches!(esc, b'$' | b'`' | b'"' | b'\\' | b'\n') {
1867                flush!(i);
1868                if esc != b'\n' {
1869                    // A `\<newline>` is a line continuation: both bytes go.
1870                    parts.push(WordPart::Literal(CompactString::from(
1871                        &content[i + 1..i + 2],
1872                    )));
1873                }
1874                i += 2;
1875                literal_start = i;
1876            } else {
1877                i += 2;
1878            }
1879            continue;
1880        }
1881
1882        if bytes[i] != b'$' || i + 1 >= bytes.len() {
1883            i += 1;
1884            continue;
1885        }
1886
1887        match bytes[i + 1] {
1888            // `$((expr))` — arithmetic. Must be tested before `$(`.
1889            b'(' if bytes.get(i + 2) == Some(&b'(') => {
1890                let end = frost_lexer::matching_close(bytes, i + 1);
1891                // `end` is one past the outer `)`; the body sits between
1892                // `$((` and `))`.
1893                let body_end = end.saturating_sub(2).max(i + 3);
1894                flush!(i);
1895                parts.push(WordPart::ArithSub(CompactString::from(
1896                    &content[i + 3..body_end],
1897                )));
1898                i = end;
1899                literal_start = i;
1900            }
1901            // `$(cmd)` — command substitution.
1902            b'(' => {
1903                let end = frost_lexer::matching_close(bytes, i + 1);
1904                let body_end = end.saturating_sub(1).max(i + 2);
1905                flush!(i);
1906                parts.push(WordPart::CommandSub(Box::new(parse_fragment(
1907                    &content[i + 2..body_end],
1908                ))));
1909                i = end;
1910                literal_start = i;
1911            }
1912            // `${…}` — parameter expansion. `matching_close` respects quoting
1913            // inside the braces, so `${a:-'}'}` no longer ends early.
1914            b'{' => {
1915                let end = frost_lexer::matching_close(bytes, i + 1);
1916                let body_end = end.saturating_sub(1).max(i + 2);
1917                flush!(i);
1918                parts.push(WordPart::DollarBrace {
1919                    param: CompactString::from(&content[i + 2..body_end]),
1920                    operator: None,
1921                    arg: None,
1922                });
1923                i = end;
1924                literal_start = i;
1925            }
1926            // `$VAR`
1927            c if c.is_ascii_alphabetic() || c == b'_' => {
1928                let start = i + 1;
1929                let mut end = start;
1930                while end < bytes.len()
1931                    && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_')
1932                {
1933                    end += 1;
1934                }
1935                flush!(i);
1936                parts.push(WordPart::DollarVar(CompactString::from(
1937                    &content[start..end],
1938                )));
1939                i = end;
1940                literal_start = i;
1941            }
1942            // `$?`, `$!`, `$$`, `$#`, `$*`, `$@`, `$-`, `$0`-`$9`
1943            c @ (b'?' | b'!' | b'$' | b'#' | b'*' | b'@' | b'-' | b'0'..=b'9') => {
1944                let _ = c;
1945                flush!(i);
1946                parts.push(WordPart::DollarVar(CompactString::from(
1947                    &content[i + 1..i + 2],
1948                )));
1949                i += 2;
1950                literal_start = i;
1951            }
1952            // A bare `$` (including `"$'a'"`, which zsh leaves alone inside
1953            // double quotes) is literal text.
1954            _ => i += 1,
1955        }
1956    }
1957
1958    flush!(bytes.len());
1959
1960    if parts.is_empty() {
1961        parts.push(WordPart::Literal(CompactString::default()));
1962    }
1963
1964    parts
1965}
1966
1967fn parse_fd_prefix(text: &str) -> u32 {
1968    text.bytes()
1969        .take_while(u8::is_ascii_digit)
1970        .fold(0u32, |acc, b| acc * 10 + u32::from(b - b'0'))
1971}
1972
1973// ── Tests ──────────────────────────────────────────────────────
1974
1975#[cfg(test)]
1976mod tests {
1977    use super::*;
1978
1979    fn tokenize(input: &str) -> Vec<Token> {
1980        frost_lexer::tokenize_str(input)
1981    }
1982
1983    fn parse(input: &str) -> Program {
1984        let tokens = tokenize(input);
1985        Parser::new(&tokens).parse()
1986    }
1987
1988    fn first_simple(program: &Program) -> &SimpleCommand {
1989        match &program.commands[0].list.first.commands[0] {
1990            Command::Simple(s) => s,
1991            other => panic!("expected Simple, got {other:?}"),
1992        }
1993    }
1994
1995    #[test]
1996    fn parse_simple_command() {
1997        let p = parse("echo hello world");
1998        assert_eq!(p.commands.len(), 1);
1999        let cmd = first_simple(&p);
2000        assert_eq!(cmd.words.len(), 3);
2001    }
2002
2003    #[test]
2004    fn parse_empty_program() {
2005        let p = parse("");
2006        assert_eq!(p.commands.len(), 0);
2007    }
2008
2009    #[test]
2010    fn parse_newlines_only() {
2011        let p = parse("\n\n\n");
2012        assert_eq!(p.commands.len(), 0);
2013    }
2014
2015    #[test]
2016    fn parse_semicolons() {
2017        let p = parse("echo a; echo b; echo c");
2018        assert_eq!(p.commands.len(), 3);
2019    }
2020
2021    #[test]
2022    fn parse_pipe() {
2023        let p = parse("cat file | grep pattern | wc -l");
2024        let pipeline = &p.commands[0].list.first;
2025        assert_eq!(pipeline.commands.len(), 3);
2026    }
2027
2028    #[test]
2029    fn parse_and_or_list() {
2030        let p = parse("test -f file && cat file || echo missing");
2031        let list = &p.commands[0].list;
2032        assert_eq!(list.rest.len(), 2);
2033        assert_eq!(list.rest[0].0, ListOp::And);
2034        assert_eq!(list.rest[1].0, ListOp::Or);
2035    }
2036
2037    #[test]
2038    fn parse_background() {
2039        let p = parse("sleep 10 &");
2040        assert!(p.commands[0].is_async);
2041    }
2042
2043    #[test]
2044    fn parse_bang() {
2045        let p = parse("! false");
2046        assert!(p.commands[0].list.first.bang);
2047    }
2048
2049    #[test]
2050    fn bang_is_literal_word_mid_command() {
2051        // zsh script semantics: `!` after the command word is argument
2052        // text, not pipeline negation (which parse_pipeline consumed)
2053        // and not history expansion (a REPL-layer concern). Regression
2054        // for `test ! -d x` / `test a != b` parsing as a truncated
2055        // command + a bogus `-d` / `=` command (exit 127).
2056        fn literal(word: &Word) -> String {
2057            word.parts
2058                .iter()
2059                .map(|p| match p {
2060                    WordPart::Literal(s) => s.as_str(),
2061                    other => panic!("expected Literal, got {other:?}"),
2062                })
2063                .collect()
2064        }
2065        let rows: &[(&str, &[&str])] = &[
2066            (
2067                "test ! -d /nonexistent",
2068                &["test", "!", "-d", "/nonexistent"],
2069            ),
2070            ("test a != b", &["test", "a", "!=", "b"]),
2071            ("test a!=b", &["test", "a!=b"]),
2072        ];
2073        let mut failures: Vec<String> = Vec::new();
2074        for (src, expect) in rows {
2075            let p = parse(src);
2076            let got: Vec<String> = first_simple(&p).words.iter().map(literal).collect();
2077            if got != *expect {
2078                failures.push(format!("{src:?} → {got:?}, expected {expect:?}"));
2079            }
2080        }
2081        assert!(
2082            failures.is_empty(),
2083            "{} bang-word rows failed:\n  - {}",
2084            failures.len(),
2085            failures.join("\n  - ")
2086        );
2087    }
2088
2089    #[test]
2090    fn parse_redirect_output() {
2091        let p = parse("echo hello > file.txt");
2092        let cmd = first_simple(&p);
2093        assert_eq!(cmd.redirects.len(), 1);
2094        assert_eq!(cmd.redirects[0].op, RedirectOp::Greater);
2095    }
2096
2097    #[test]
2098    fn parse_redirect_append() {
2099        let p = parse("echo hello >> file.txt");
2100        let cmd = first_simple(&p);
2101        assert_eq!(cmd.redirects[0].op, RedirectOp::DoubleGreater);
2102    }
2103
2104    #[test]
2105    fn parse_redirect_input() {
2106        let p = parse("cat < input.txt");
2107        let cmd = first_simple(&p);
2108        assert_eq!(cmd.redirects[0].op, RedirectOp::Less);
2109    }
2110
2111    #[test]
2112    fn parse_assignment() {
2113        let p = parse("FOO=bar");
2114        let cmd = first_simple(&p);
2115        assert_eq!(cmd.assignments.len(), 1);
2116        assert_eq!(cmd.assignments[0].name.as_str(), "FOO");
2117        assert_eq!(cmd.assignments[0].op, AssignOp::Assign);
2118    }
2119
2120    #[test]
2121    fn parse_assignment_before_command() {
2122        let p = parse("FOO=bar echo hello");
2123        let cmd = first_simple(&p);
2124        assert_eq!(cmd.assignments.len(), 1);
2125        assert_eq!(cmd.words.len(), 2);
2126    }
2127
2128    #[test]
2129    fn parse_single_quoted() {
2130        let p = parse("echo 'hello world'");
2131        let cmd = first_simple(&p);
2132        assert_eq!(cmd.words.len(), 2);
2133        match &cmd.words[1].parts[0] {
2134            WordPart::SingleQuoted(s) => assert_eq!(s.as_str(), "hello world"),
2135            other => panic!("expected SingleQuoted, got {other:?}"),
2136        }
2137    }
2138
2139    #[test]
2140    fn parse_double_quoted_with_var() {
2141        let p = parse(r#"echo "hello $name""#);
2142        let cmd = first_simple(&p);
2143        assert_eq!(cmd.words.len(), 2);
2144        // The whole `"hello $name"` is one DoubleQuoted part whose
2145        // inner list carries the literal + DollarVar. Preserving the
2146        // wrapper lets the expander distinguish quoted from unquoted
2147        // so `[ -n "" ]` keeps three arguments.
2148        let parts = &cmd.words[1].parts;
2149        assert_eq!(
2150            parts.len(),
2151            1,
2152            "outer word should have one DoubleQuoted part"
2153        );
2154        match &parts[0] {
2155            WordPart::DoubleQuoted(inner) => {
2156                assert!(
2157                    inner
2158                        .iter()
2159                        .any(|p| matches!(p, WordPart::Literal(s) if s.contains("hello"))),
2160                    "inner should carry the literal",
2161                );
2162                assert!(
2163                    inner
2164                        .iter()
2165                        .any(|p| matches!(p, WordPart::DollarVar(n) if n.as_str() == "name")),
2166                    "inner should carry the DollarVar",
2167                );
2168            }
2169            other => panic!("expected DoubleQuoted wrapper, got {other:?}"),
2170        }
2171    }
2172
2173    // ── Expansion-inside-double-quotes regressions (2026-08-07) ──────
2174    //
2175    // `$(…)` and `$((…))` inside `"…"` used to be emitted as the LITERAL
2176    // text `$(`, so `[ -n "$(git status --porcelain)" ]` tested a non-empty
2177    // constant and every repo read as dirty.
2178
2179    /// The inner parts of the sole `DoubleQuoted` part of `words[idx]`.
2180    fn dq_inner(p: &Program, idx: usize) -> Vec<WordPart> {
2181        let parts = &first_simple(p).words[idx].parts;
2182        assert_eq!(parts.len(), 1, "expected one DoubleQuoted part: {parts:?}");
2183        match &parts[0] {
2184            WordPart::DoubleQuoted(inner) => inner.clone(),
2185            other => panic!("expected DoubleQuoted, got {other:?}"),
2186        }
2187    }
2188
2189    #[test]
2190    fn double_quoted_command_substitution_is_parsed() {
2191        let inner = dq_inner(&parse(r#"echo "$(echo hi)""#), 1);
2192        assert!(
2193            matches!(inner.as_slice(), [WordPart::CommandSub(_)]),
2194            "expected a CommandSub, got {inner:?}"
2195        );
2196    }
2197
2198    #[test]
2199    fn double_quoted_command_substitution_keeps_surrounding_literals() {
2200        let inner = dq_inner(&parse(r#"echo "x$(echo hi)y""#), 1);
2201        assert!(
2202            matches!(
2203                inner.as_slice(),
2204                [
2205                    WordPart::Literal(a),
2206                    WordPart::CommandSub(_),
2207                    WordPart::Literal(b)
2208                ] if a == "x" && b == "y"
2209            ),
2210            "got {inner:?}"
2211        );
2212    }
2213
2214    #[test]
2215    fn double_quoted_command_substitution_spans_inner_double_quotes() {
2216        // The lexer must hand the whole thing over as one token, and the
2217        // sub-parse must see the inner quotes. This is the shape that
2218        // wedged the parser.
2219        let inner = dq_inner(&parse(r#"echo "$(printf %s "export D=1")""#), 1);
2220        match inner.as_slice() {
2221            [WordPart::CommandSub(prog)] => {
2222                let sub = match &prog.commands[0].list.first.commands[0] {
2223                    Command::Simple(s) => s,
2224                    other => panic!("expected Simple, got {other:?}"),
2225                };
2226                assert_eq!(sub.words.len(), 3, "printf %s \"export D=1\"");
2227            }
2228            other => panic!("expected one CommandSub, got {other:?}"),
2229        }
2230    }
2231
2232    #[test]
2233    fn double_quoted_arithmetic_substitution_is_parsed() {
2234        let inner = dq_inner(&parse(r#"echo "$((2+2))""#), 1);
2235        assert!(
2236            matches!(inner.as_slice(), [WordPart::ArithSub(e)] if e == "2+2"),
2237            "got {inner:?}"
2238        );
2239        // Nested parens inside the expression must not close it early.
2240        let inner = dq_inner(&parse(r#"echo "$(( (1+2) * 3 ))""#), 1);
2241        assert!(
2242            matches!(inner.as_slice(), [WordPart::ArithSub(e)] if e == " (1+2) * 3 "),
2243            "got {inner:?}"
2244        );
2245    }
2246
2247    #[test]
2248    fn double_quoted_backslash_escapes_are_removed() {
2249        // zsh removes the backslash before `$ ` ` " \` only.
2250        let inner = dq_inner(&parse(r#"echo "a\"b""#), 1);
2251        let joined: String = inner
2252            .iter()
2253            .map(|p| match p {
2254                WordPart::Literal(s) => s.to_string(),
2255                other => panic!("expected literals, got {other:?}"),
2256            })
2257            .collect();
2258        assert_eq!(joined, "a\"b");
2259    }
2260
2261    #[test]
2262    fn ansi_c_quoting_gets_its_own_part() {
2263        // `$'…'` must NOT be routed through the double-quote path.
2264        let p = parse(r"echo $'a\nb'");
2265        let parts = &first_simple(&p).words[1].parts;
2266        assert!(
2267            matches!(parts.as_slice(), [WordPart::AnsiCQuoted(raw)] if raw == r"a\nb"),
2268            "got {parts:?}"
2269        );
2270    }
2271
2272    // ── Adjacent-expansion regressions (2026-08-07) ──────────────────
2273    //
2274    // `parse_word` tracked its adjacency offset from the FIRST token only,
2275    // so `x=${a}${b}` dropped `${b}` and `y=${a}Z` ran `Z` as a command.
2276
2277    #[test]
2278    fn adjacent_brace_expansions_stay_in_one_word() {
2279        let p = parse("x=${a}${b}");
2280        let assign = &first_simple(&p).assignments[0];
2281        let parts = &assign.value.as_ref().expect("value").parts;
2282        assert_eq!(parts.len(), 2, "both ${{…}} must survive: {parts:?}");
2283        assert!(
2284            parts
2285                .iter()
2286                .all(|p| matches!(p, WordPart::DollarBrace { .. }))
2287        );
2288        assert!(
2289            first_simple(&p).words.is_empty(),
2290            "nothing may leak out as a command word"
2291        );
2292    }
2293
2294    #[test]
2295    fn brace_expansion_then_literal_stays_in_one_word() {
2296        let p = parse("y=${a}Z");
2297        let cmd = first_simple(&p);
2298        let parts = &cmd.assignments[0].value.as_ref().expect("value").parts;
2299        assert!(
2300            matches!(
2301                parts.as_slice(),
2302                [WordPart::DollarBrace { .. }, WordPart::Literal(z)] if z == "Z"
2303            ),
2304            "got {parts:?}"
2305        );
2306        assert!(cmd.words.is_empty(), "`Z` must not become a command");
2307    }
2308
2309    #[test]
2310    fn adjacent_command_substitutions_stay_in_one_word() {
2311        let p = parse("x=$(echo A)$(echo B)");
2312        let parts = &first_simple(&p).assignments[0]
2313            .value
2314            .as_ref()
2315            .expect("value")
2316            .parts;
2317        assert_eq!(parts.len(), 2, "got {parts:?}");
2318        assert!(parts.iter().all(|p| matches!(p, WordPart::CommandSub(_))));
2319    }
2320
2321    #[test]
2322    fn dollar_var_stops_at_a_non_name_byte() {
2323        // `/`, `:` and `.` are not lexer metacharacters, so `$d/xx` arrives
2324        // as `$` + Word("d/xx"). The name is `d`; the rest is a literal.
2325        let p = parse("echo $d/xx");
2326        let parts = &first_simple(&p).words[1].parts;
2327        assert!(
2328            matches!(
2329                parts.as_slice(),
2330                [WordPart::DollarVar(n), WordPart::Literal(rest)]
2331                    if n == "d" && rest == "/xx"
2332            ),
2333            "got {parts:?}"
2334        );
2335    }
2336
2337    #[test]
2338    fn tilde_expands_only_at_a_words_start() {
2339        // `a~b` is a literal in zsh; only a leading `~` expands.
2340        let p = parse("echo a~b");
2341        let parts = &first_simple(&p).words[1].parts;
2342        assert!(
2343            !parts.iter().any(|p| matches!(p, WordPart::Tilde(_))),
2344            "mid-word `~` must stay literal: {parts:?}"
2345        );
2346    }
2347
2348    // ── Hang regressions (2026-08-07) ────────────────────────────────
2349
2350    #[test]
2351    fn a_stray_closing_paren_does_not_spin_parse_program() {
2352        // `parse_program` had no cursor-did-not-move guard, so any token
2353        // that is neither a command start nor an eatable separator looped
2354        // forever. Reaching this assert at all IS the test.
2355        let p = parse("echo a ) echo b");
2356        assert!(!p.commands.is_empty());
2357        assert!(parse(")").commands.is_empty() || true);
2358        assert!(parse("}").commands.is_empty() || true);
2359    }
2360
2361    #[test]
2362    fn reserved_word_in_argument_position_is_a_plain_word() {
2363        // `echo done` produced a zero-word `echo` and then an endless run
2364        // of empty commands — the stock binary hung on it.
2365        for kw in ["done", "fi", "then", "else", "elif", "do", "esac"] {
2366            let p = parse(&format!("echo {kw}"));
2367            let cmd = first_simple(&p);
2368            assert_eq!(cmd.words.len(), 2, "`echo {kw}` must be two words");
2369        }
2370        let p = parse("echo a done b");
2371        assert_eq!(first_simple(&p).words.len(), 4);
2372    }
2373
2374    #[test]
2375    fn control_flow_keywords_still_terminate_their_blocks() {
2376        // The counterpart to the test above: the position rule must not
2377        // cost us the real terminators.
2378        let p = parse("if true; then echo A; fi");
2379        assert_eq!(p.commands.len(), 1);
2380        let p = parse("for i in a b; do echo $i; done");
2381        assert_eq!(p.commands.len(), 1);
2382        let p = parse("while false; do echo x; done");
2383        assert_eq!(p.commands.len(), 1);
2384    }
2385
2386    #[test]
2387    fn parse_dollar_var() {
2388        let p = parse("echo $HOME");
2389        let cmd = first_simple(&p);
2390        assert_eq!(cmd.words.len(), 2);
2391        match &cmd.words[1].parts[0] {
2392            WordPart::DollarVar(name) => assert_eq!(name.as_str(), "HOME"),
2393            other => panic!("expected DollarVar, got {other:?}"),
2394        }
2395    }
2396
2397    #[test]
2398    fn parse_dollar_hash_special_param() {
2399        // Unquoted `$#` (argv count) must parse to `DollarVar("#")`, not
2400        // a literal `$`. Regression: the lexer used to swallow the `#`
2401        // and emit a bare `Dollar`, so `[ $# -eq 0 ]` saw `[ $ -eq 0 ]`
2402        // and always matched — which made the zoxide `cd` override take
2403        // its no-arg branch and jump HOME on every `cd`.
2404        let p = parse("echo $#");
2405        let cmd = first_simple(&p);
2406        assert_eq!(cmd.words.len(), 2);
2407        match &cmd.words[1].parts[0] {
2408            WordPart::DollarVar(name) => assert_eq!(name.as_str(), "#"),
2409            other => panic!("expected DollarVar(\"#\"), got {other:?}"),
2410        }
2411    }
2412
2413    #[test]
2414    fn parse_dollar_hash_in_compound_word() {
2415        // `n=$#` — the special param resolves inside a compound word too.
2416        let p = parse("echo n=$#");
2417        let cmd = first_simple(&p);
2418        let parts = &cmd.words[1].parts;
2419        assert!(
2420            parts
2421                .iter()
2422                .any(|p| matches!(p, WordPart::DollarVar(n) if n.as_str() == "#")),
2423            "compound word should carry DollarVar(\"#\"), got {parts:?}",
2424        );
2425    }
2426
2427    #[test]
2428    fn parse_if_then_fi() {
2429        let p = parse("if true; then echo yes; fi");
2430        match &p.commands[0].list.first.commands[0] {
2431            Command::If(clause) => {
2432                assert_eq!(clause.condition.len(), 1);
2433                assert_eq!(clause.then_body.len(), 1);
2434                assert!(clause.else_body.is_none());
2435            }
2436            other => panic!("expected If, got {other:?}"),
2437        }
2438    }
2439
2440    #[test]
2441    fn parse_if_else() {
2442        let p = parse("if false; then echo no; else echo yes; fi");
2443        match &p.commands[0].list.first.commands[0] {
2444            Command::If(clause) => {
2445                assert!(clause.else_body.is_some());
2446            }
2447            other => panic!("expected If, got {other:?}"),
2448        }
2449    }
2450
2451    #[test]
2452    fn parse_if_elif() {
2453        let p = parse("if false; then echo 1; elif true; then echo 2; else echo 3; fi");
2454        match &p.commands[0].list.first.commands[0] {
2455            Command::If(clause) => {
2456                assert_eq!(clause.elifs.len(), 1);
2457                assert!(clause.else_body.is_some());
2458            }
2459            other => panic!("expected If, got {other:?}"),
2460        }
2461    }
2462
2463    #[test]
2464    fn parse_for_loop() {
2465        let p = parse("for x in a b c; do echo $x; done");
2466        match &p.commands[0].list.first.commands[0] {
2467            Command::For(clause) => {
2468                assert_eq!(clause.var.as_str(), "x");
2469                assert_eq!(clause.words.as_ref().unwrap().len(), 3);
2470                assert_eq!(clause.body.len(), 1);
2471            }
2472            other => panic!("expected For, got {other:?}"),
2473        }
2474    }
2475
2476    #[test]
2477    fn parse_while_loop() {
2478        let p = parse("while true; do echo loop; done");
2479        match &p.commands[0].list.first.commands[0] {
2480            Command::While(clause) => {
2481                assert_eq!(clause.condition.len(), 1);
2482                assert_eq!(clause.body.len(), 1);
2483            }
2484            other => panic!("expected While, got {other:?}"),
2485        }
2486    }
2487
2488    #[test]
2489    fn parse_case() {
2490        let p = parse("case $x in\n  a) echo A ;;\n  b) echo B ;;\nesac");
2491        match &p.commands[0].list.first.commands[0] {
2492            Command::Case(clause) => {
2493                assert_eq!(clause.items.len(), 2);
2494            }
2495            other => panic!("expected Case, got {other:?}"),
2496        }
2497    }
2498
2499    #[test]
2500    fn parse_subshell() {
2501        let p = parse("(echo hello)");
2502        assert!(matches!(
2503            &p.commands[0].list.first.commands[0],
2504            Command::Subshell(_)
2505        ));
2506    }
2507
2508    #[test]
2509    fn parse_brace_group() {
2510        let p = parse("{ echo hello; }");
2511        assert!(matches!(
2512            &p.commands[0].list.first.commands[0],
2513            Command::BraceGroup(_)
2514        ));
2515    }
2516
2517    #[test]
2518    fn parse_function_keyword() {
2519        let p = parse("function greet { echo hello; }");
2520        match &p.commands[0].list.first.commands[0] {
2521            Command::FunctionDef(f) => assert_eq!(f.name.as_str(), "greet"),
2522            other => panic!("expected FunctionDef, got {other:?}"),
2523        }
2524    }
2525
2526    #[test]
2527    fn parse_function_parens() {
2528        let p = parse("greet() { echo hello; }");
2529        match &p.commands[0].list.first.commands[0] {
2530            Command::FunctionDef(f) => assert_eq!(f.name.as_str(), "greet"),
2531            other => panic!("expected FunctionDef, got {other:?}"),
2532        }
2533    }
2534
2535    #[test]
2536    fn parse_tilde() {
2537        let p = parse("cd ~");
2538        let cmd = first_simple(&p);
2539        assert!(matches!(&cmd.words[1].parts[0], WordPart::Tilde(_)));
2540    }
2541
2542    #[test]
2543    fn parse_glob_star() {
2544        let p = parse("ls *");
2545        let cmd = first_simple(&p);
2546        assert!(matches!(
2547            &cmd.words[1].parts[0],
2548            WordPart::Glob(GlobKind::Star)
2549        ));
2550    }
2551
2552    #[test]
2553    fn parse_multiple_commands_newlines() {
2554        let p = parse("echo a\necho b\necho c\n");
2555        assert_eq!(p.commands.len(), 3);
2556    }
2557
2558    #[test]
2559    fn parse_herestring() {
2560        let p = parse("cat <<< 'hello'");
2561        let cmd = first_simple(&p);
2562        assert_eq!(cmd.redirects[0].op, RedirectOp::TripleLess);
2563    }
2564
2565    #[test]
2566    fn parse_time() {
2567        let p = parse("time ls -la");
2568        assert!(matches!(
2569            &p.commands[0].list.first.commands[0],
2570            Command::Time(_)
2571        ));
2572    }
2573
2574    #[test]
2575    fn parse_multiline_if() {
2576        let p = parse("if true\nthen\n  echo yes\nfi");
2577        assert!(matches!(
2578            &p.commands[0].list.first.commands[0],
2579            Command::If(_)
2580        ));
2581    }
2582}
2583
2584#[cfg(test)]
2585mod syntax_error_recording {
2586    use super::*;
2587
2588    fn parse_src(src: &str) -> crate::ast::Program {
2589        let toks = frost_lexer::tokenize(src.as_bytes());
2590        Parser::new(&toks).parse()
2591    }
2592
2593    /// The receipt. `while true` with no `do` used to recover into
2594    /// `While { condition: [true], body: [] }` — a valid infinite loop with an
2595    /// empty body — and the executor ran it forever. zsh 5.9, bash and dash all
2596    /// reject the input. Recovery still happens; it is no longer silent.
2597    #[test]
2598    fn unterminated_while_records_a_syntax_error() {
2599        let p = parse_src("while true\n");
2600        assert!(
2601            !p.syntax_errors.is_empty(),
2602            "a missing `do` must be recorded, not silently recovered"
2603        );
2604    }
2605
2606    /// The other half, and the one that actually matters: a well-formed program
2607    /// must record NOTHING. Without this, "record every mismatch" could be
2608    /// satisfied by recording always, and the refusal in `frost_exec::execute`
2609    /// would reject every script.
2610    #[test]
2611    fn well_formed_input_records_nothing() {
2612        for src in [
2613            "while true; do echo x; done\n",
2614            "echo done\n",
2615            "if true; then echo y; fi\n",
2616            "for i in 1 2 3; do echo $i; done\n",
2617            "case x in y) echo z ;; esac\n",
2618            "f() { echo hi; }\n",
2619        ] {
2620            let p = parse_src(src);
2621            assert!(
2622                p.syntax_errors.is_empty(),
2623                "well-formed input recorded errors: {src:?} -> {:?}",
2624                p.syntax_errors
2625            );
2626        }
2627    }
2628}
2629
2630#[cfg(test)]
2631mod live_rc_parses_clean {
2632    use super::*;
2633
2634    /// THE GATE ON THE REFUSAL. `frost_exec::execute` refuses to run a program
2635    /// with recorded syntax errors, so if the shipped rc had any — silently
2636    /// recovered and unnoticed for who knows how long — that refusal would
2637    /// brick every shell on the machine.
2638    ///
2639    /// Skips rather than fails when the store path is absent (a Linux CI runner
2640    /// has no /nix/store copy of this rc), because a check that cannot run
2641    /// should say so, not go green.
2642    #[test]
2643    fn the_shipped_frostmourne_rc_has_no_recovered_syntax_errors() {
2644        const RC: &str = "/nix/store/jmvklqqzydxrdiyb8bsjxi1d8i78i8q1-frostmourne-rc.lisp/share/frostmourne/rc.lisp";
2645        let Ok(src) = std::fs::read_to_string(RC) else {
2646            eprintln!("SKIP: {RC} not present on this host");
2647            return;
2648        };
2649        let toks = frost_lexer::tokenize(src.as_bytes());
2650        let p = Parser::new(&toks).parse();
2651        assert!(
2652            p.syntax_errors.is_empty(),
2653            "the shipped rc records {} syntax errors; refusing to execute it \
2654             would brick the shell. First few: {:?}",
2655            p.syntax_errors.len(),
2656            &p.syntax_errors[..p.syntax_errors.len().min(5)]
2657        );
2658    }
2659}