Skip to main content

kaish_kernel/
parser.rs

1//! Parser for kaish source code.
2//!
3//! Transforms a token stream from the lexer into an Abstract Syntax Tree.
4//! Uses chumsky for parser combinators with good error recovery.
5
6use crate::ast::{
7    Arg, Assignment, BinaryOp, CaseBranch, CaseStmt, Command, Expr, FileTestOp, ForLoop,
8    HereDocMeta, IfStmt, ListElem, Pipeline, PipelineStage, Program, RecordEntry, RecordKey,
9    Redirect, RedirectKind, SpannedPart, Stmt, StringPart, StringTestOp, TestCmpOp, TestExpr,
10    ToolDef, Value,
11    VarPath, VarSegment, WhileLoop,
12};
13use crate::lexer::{self, HereDocData, Token};
14use chumsky::error::RichReason;
15use chumsky::input::{MappedInput, Stream, ValueInput};
16use chumsky::prelude::*;
17
18/// Span type used throughout the parser.
19pub type Span = SimpleSpan;
20
21/// The token stream a cached parser reads.
22///
23/// `Stream` **owns** its tokens, so this type borrows nothing and its input
24/// lifetime is `'static` — which is the whole reason the grammar below can be
25/// built once instead of per call. A slice input borrows, so a parser over one
26/// carries the slice's lifetime and cannot outlive a single `parse`.
27///
28/// The `.map` is not decoration either. `Stream`'s own spans come from cursor
29/// positions, so a bare `Stream` would report *token indices* where the rest of
30/// kaish reports **byte offsets** — every diagnostic position and every
31/// `PlannedHeredoc::body_offset` would silently change meaning. Mapping each
32/// pair through keeps the lexer's byte spans.
33type ParserInput = MappedInput<'static, Token, Span, Stream<std::vec::IntoIter<(Token, Span)>>, PairFn>;
34
35/// The mapping above, as a function pointer rather than a closure: a closure's
36/// type cannot be named, and [`ParserInput`] has to be nameable to appear in
37/// the cached parser's type.
38type PairFn = fn((Token, Span)) -> (Token, Span);
39
40fn keep_pair(pair: (Token, Span)) -> (Token, Span) {
41    pair
42}
43
44thread_local! {
45    /// The whole combinator graph, built once per thread.
46    ///
47    /// `program_parser()` allocated ~840 times and ~163 KB **before reading a
48    /// single token**, on every `parse()` — 62% of the allocations in an
49    /// embedder's `execute()` round trip (GH #255). None of it depended on the
50    /// input, so all of it was rebuilt to be thrown away.
51    ///
52    /// Per-thread rather than one shared static: chumsky's `Boxed` holds an
53    /// `Rc`, so the built graph is not `Sync` and cannot live in a `OnceLock`.
54    /// A thread-local also avoids the lock a shared one would need, and the
55    /// kernel's worker threads each pay the build once.
56    static CACHED_PARSER: Boxed<
57        'static,
58        'static,
59        ParserInput,
60        Program,
61        extra::Err<Rich<'static, Token, Span>>,
62    > = program_parser().boxed();
63}
64
65/// Parse a raw `${...}` string into an Expr.
66///
67/// Handles:
68/// - Special variables: `${?}` → LastExitCode, `${$}` → CurrentPid
69/// - Simple paths: `${VAR}`, `${VAR.field}`, `${VAR[0]}` → VarRef
70/// - Default values: `${VAR:-default}` → VarWithDefault (with nested expansion support)
71fn parse_var_expr(raw: &str) -> Expr {
72    // Special case: ${?} is the last exit code (same as $?)
73    if raw == "${?}" {
74        return Expr::LastExitCode;
75    }
76
77    // Special case: ${$} is the current PID (same as $$)
78    if raw == "${$}" {
79        return Expr::CurrentPid;
80    }
81
82    // Check for default value syntax: ${VAR:-default}
83    // Need to find :- that's not inside a nested ${...}
84    if let Some(colon_idx) = find_default_separator(raw) {
85        // Extract the variable path (between ${ and :-) — may carry subscripts.
86        let path = parse_varpath(&format!("${{{}}}", &raw[2..colon_idx]));
87        // Extract default value (between :- and }) and recursively parse it,
88        // after stripping shell quoting from the word (quotes are syntax).
89        let default_str = &raw[colon_idx + 2..raw.len() - 1];
90        // TODO: this discards a real error. `parse_interpolated_string` now
91        // reports an unterminated `$(`, but this path returns `Expr` and has
92        // nowhere to put a failure, so `echo ${x:-$(echo hi}` still exits 0
93        // with the body kept as literal text — the same silent shape the
94        // quoted path just stopped doing. Closing it needs the check on the
95        // token stream, where `validate_interpolated_strings` already lives;
96        // it only inspects `Token::String` today and would have to read a
97        // `VarRef`'s default word too.
98        let default_word = unquote_default_word(default_str);
99        let default = parse_interpolated_string(&default_word)
100            .unwrap_or_else(|_| vec![StringPart::Literal(default_word.clone())]);
101        return Expr::VarWithDefault { path, default };
102    }
103
104    // Regular variable path
105    Expr::VarRef(parse_varpath(raw))
106}
107
108/// Detect bash's `${VAR:offset:length}` substring form and explain the kaish
109/// spelling; `None` if this is not that shape.
110///
111/// kaish slices with brackets — `${s[start:end]}`, end-exclusive, the same rule
112/// as a list slice — so bash's colon form means something different here and
113/// used to expand to nothing at all. Silently: `"${d:0:4}/file"` became
114/// `/file`, pointing a destructive command at the wrong path.
115///
116/// `var_content` is the inside of `${…}`. A colon inside brackets is a slice
117/// subscript (`${r[a:b]}`) and is left alone; only a colon at bracket depth 0
118/// is the bash form. `${VAR:-default}` is matched earlier and never gets here.
119pub(crate) fn bash_substring_hint(var_content: &str) -> Option<String> {
120    let mut depth = 0usize;
121    let colon = var_content.char_indices().find_map(|(i, c)| match c {
122        '[' => {
123            depth += 1;
124            None
125        }
126        ']' => {
127            depth = depth.saturating_sub(1);
128            None
129        }
130        ':' if depth == 0 => Some(i),
131        _ => None,
132    })?;
133    let (name, rest) = var_content.split_at(colon);
134    // `rest` still carries the colon we split on; strip exactly that one.
135    let after_offset = &rest[1..];
136    // `${v:0:5}` → `${v[0:5]}`; `${v::5}` (bash: offset omitted, so 0) →
137    // `${v[0:5]}`; a lone `${v:5}` (bash: from offset 5 to end) → `${v[5:]}`.
138    let suggestion = if let Some(length) = after_offset.strip_prefix(':') {
139        format!("${{{name}[0:{length}]}}")
140    } else if after_offset.contains(':') {
141        format!("${{{name}[{after_offset}]}}")
142    } else {
143        format!("${{{name}[{after_offset}:]}}")
144    };
145    Some(format!(
146        "${{{var_content}}}: kaish slices with brackets, not `:offset:length` — \
147         write {suggestion}. Brackets are start:end and end-exclusive, so \
148         ${{{name}[0:5]}} is the first five characters and ${{{name}[-3:]}} the last three."
149    ))
150}
151
152/// Remove shell quoting from a `${VAR:-WORD}` default word, bash-style, before
153/// the word is parsed for interpolation.
154///
155/// The quotes around a default word are syntax, not data: `${X:-"default"}`
156/// yields `default`, not `"default"`. Double quotes are stripped but `$`-style
157/// interpolation inside them stays active; single quotes are stripped and
158/// suppress interpolation (their `$` becomes a literal, via the lexer's
159/// `__KAISH_ESCAPED_DOLLAR__` marker that `parse_interpolated_string` turns
160/// back into a bare `$`). Unquoted text passes through unchanged.
161///
162/// A backslash-escaped quote unescapes to a bare quote character without
163/// toggling the quote-tracking state, but *which* quote is escapable depends on
164/// context, matching bash (GH #93 item 5): OUTSIDE any quotes both `\"` and
165/// `\'` escape (this is what makes the `'it'\''s'` → `it's` embedding idiom
166/// resolve); INSIDE double quotes only `\"` escapes, since `'` is an ordinary
167/// character there — a backslash before it stays literal (`"a\'b"` → `a\'b`). A
168/// run of backslashes immediately before an escapable quote is judged by parity
169/// (bash pairs them left-to-right): an odd run escapes the quote, an even run
170/// doesn't, and either way the run collapses to half as many literal
171/// backslashes. Backslashes not immediately followed by an escapable quote are
172/// untouched — general backslash-escape processing (`\\`, `\n`, ...) outside
173/// quote-adjacency is out of scope for this function.
174///
175/// Inside a single-quoted region shell rules apply verbatim: it is a LITERAL
176/// span with zero escape processing and zero interpolation. A backslash is a
177/// literal character and a `'` always closes the region (it is never escaped);
178/// only `$` is marked (`__KAISH_ESCAPED_DOLLAR__`) so it can't interpolate
179/// downstream. Only the delimiter quotes themselves are stripped — they are
180/// syntax, not data.
181fn unquote_default_word(word: &str) -> String {
182    let mut out = String::with_capacity(word.len());
183    let mut in_single = false;
184    let mut in_double = false;
185    let chars: Vec<char> = word.chars().collect();
186    let mut i = 0;
187    while i < chars.len() {
188        let ch = chars[i];
189        // Backslash-escape processing applies only OUTSIDE single quotes. In a
190        // single-quoted region a backslash is a literal character (handled by
191        // the `_` arm below) and a `'` always closes the span, per shell rules.
192        if ch == '\\' && !in_single {
193            let run_start = i;
194            while i < chars.len() && chars[i] == '\\' {
195                i += 1;
196            }
197            let run_len = i - run_start;
198            // Inside double quotes only `\"` escapes; `'` is an ordinary
199            // character there, so a preceding backslash stays literal.
200            let next_is_quote =
201                chars.get(i).is_some_and(|c| *c == '"' || (*c == '\'' && !in_double));
202            if next_is_quote {
203                if run_len / 2 > 0 {
204                    out.push_str(&"\\".repeat(run_len / 2));
205                }
206                if run_len % 2 == 1 {
207                    // Odd run: the quote is escaped — literal quote, no
208                    // toggle. Consume it here; the main loop below never
209                    // sees it.
210                    out.push(chars[i]);
211                    i += 1;
212                }
213                // Even run: the quote at chars[i] is unescaped and falls
214                // through to the normal toggle logic on the next iteration.
215            } else {
216                out.push_str(&"\\".repeat(run_len));
217            }
218            continue;
219        }
220        i += 1;
221        match ch {
222            // A quote delimiter toggles its mode and is itself dropped; the
223            // other quote kind is literal data while inside one.
224            '\'' if !in_double => in_single = !in_single,
225            '"' if !in_single => in_double = !in_double,
226            // `$` inside single quotes must not interpolate downstream.
227            '$' if in_single => out.push_str("__KAISH_ESCAPED_DOLLAR__"),
228            _ => out.push(ch),
229        }
230    }
231    out
232}
233
234/// Find the position of :- in a ${VAR:-default} expression, accounting for nested ${...}.
235fn find_default_separator(raw: &str) -> Option<usize> {
236    let bytes = raw.as_bytes();
237    let mut depth = 0;
238    let mut bracket_depth = 0;
239    let mut i = 0;
240
241    while i < bytes.len() {
242        if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
243            depth += 1;
244            i += 2;
245            continue;
246        }
247        if bytes[i] == b'}' && depth > 0 {
248            depth -= 1;
249            i += 1;
250            continue;
251        }
252        // Track `[...]` so a `:-` inside a subscript (e.g. the negative slice end
253        // in `${xs[0:-1]}`) is NOT mistaken for a default separator.
254        if bytes[i] == b'[' {
255            bracket_depth += 1;
256        } else if bytes[i] == b']' && bracket_depth > 0 {
257            bracket_depth -= 1;
258        }
259        // Only find :- at the top level (depth == 1 means we're inside the outer
260        // ${...}) and outside any subscript.
261        if depth == 1
262            && bracket_depth == 0
263            && i + 1 < bytes.len()
264            && bytes[i] == b':'
265            && bytes[i + 1] == b'-'
266        {
267            return Some(i);
268        }
269        i += 1;
270    }
271    None
272}
273
274/// Find the position of :- in variable content (without outer braces), accounting for nested ${...}.
275fn find_default_separator_in_content(content: &str) -> Option<usize> {
276    let bytes = content.as_bytes();
277    let mut depth = 0;
278    let mut bracket_depth = 0;
279    let mut i = 0;
280
281    while i < bytes.len() {
282        if i + 1 < bytes.len() && bytes[i] == b'$' && bytes[i + 1] == b'{' {
283            depth += 1;
284            i += 2;
285            continue;
286        }
287        if bytes[i] == b'}' && depth > 0 {
288            depth -= 1;
289            i += 1;
290            continue;
291        }
292        // Track `[...]` so a `:-` inside a subscript (e.g. the negative slice end
293        // in `${xs[0:-1]}`) is NOT mistaken for a default separator.
294        if bytes[i] == b'[' {
295            bracket_depth += 1;
296        } else if bytes[i] == b']' && bracket_depth > 0 {
297            bracket_depth -= 1;
298        }
299        // Find :- at the top level (depth == 0) and outside any subscript.
300        if depth == 0
301            && bracket_depth == 0
302            && i + 1 < bytes.len()
303            && bytes[i] == b':'
304            && bytes[i + 1] == b'-'
305        {
306            return Some(i);
307        }
308        i += 1;
309    }
310    None
311}
312
313/// Parse a raw `${...}` string into a VarPath.
314///
315/// The first segment is the root variable name; each `[...]` segment the lexer
316/// produced becomes the corresponding subscript (`Index`/`Key`/`Dynamic`/
317/// `Slice`). A dotted segment (`${a.b}`) is kept as a non-root `Field` so
318/// resolution can emit the brackets-only error — the lexer already split it out.
319/// A character that may appear after the first in a variable name: ASCII
320/// alphanumerics, `_`, or any non-ASCII scalar value. Mirrors the lexer's
321/// `SimpleVarRef` class exactly — the unquoted and interpolated doors to a name
322/// must agree on where the name ends, or `"$caf\u{e9}"` collects `caf` and
323/// substitutes a different variable than `$caf\u{e9}` does.
324fn is_name_char(c: char) -> bool {
325    c.is_ascii_alphanumeric() || c == '_' || !c.is_ascii()
326}
327
328/// A character that may start a variable name: as [`is_name_char`], minus the
329/// digits, which belong to the positional parameters (`$0`..`$9`).
330fn is_name_start(c: char) -> bool {
331    c.is_ascii_alphabetic() || c == '_' || !c.is_ascii()
332}
333
334
335/// The variable name a token spells, and whether it is an assignment *target*.
336///
337/// Five tokens can carry a name: `$x`, `${x}`, `${#x}`, an `Ident` that is the
338/// target of an assignment, and the `Ident` a `for` loop binds. The neighbors
339/// are what tell them apart — the same `Ident` in argument position is
340/// ordinary data and its bytes are its own, and `case subject in` puts a
341/// data word in front of the very `In` that marks a `for` variable.
342///
343/// The target flag exists for one rule: a dotted or hashed target is refused by
344/// the validator as `E017`/`E018`, which name the exact corrected spelling
345/// (`user[email]=x`). This scan runs first and would report a blander message
346/// for the same input, so it stands aside for that one shape and lets the
347/// better error win. Every other door — `for` included, and the runtime doors
348/// that never reach the validator at all — is refused here.
349fn name_in_token_kind<'a>(
350    tok: &'a Token,
351    prev: Option<&Token>,
352    next: Option<&Token>,
353) -> Option<(&'a str, bool)> {
354    match tok {
355        Token::SimpleVarRef(name) => Some((name.as_str(), false)),
356        Token::VarLength(inner) => Some((root_of(inner), false)),
357        Token::VarRef(raw) => raw
358            .strip_prefix("${")
359            .and_then(|s| s.strip_suffix('}'))
360            .map(|r| (root_of(r), false)),
361        // An assignment target — but only where a statement can start. The
362        // same `Ident`+`Eq` spelling is an ordinary argv `key=value` word in
363        // argument position (`echo k=v`), and that word is data: its bytes are
364        // its own, and refusing it for holding a character a *name* may not
365        // hold rejects a valid program.
366        Token::Ident(name)
367            if matches!(next, Some(Token::Eq))
368                && match prev {
369                    None => true,
370                    Some(p) => crate::lexer::is_statement_boundary(p) || matches!(p, Token::Local),
371                } =>
372        {
373            Some((name.as_str(), true))
374        }
375        // `for x in …` binds `x`. Keyed on the `For` before it, not the `In`
376        // after it: `case x in …` reads the same one token ahead, and that
377        // `x` is a subject to match, not a name.
378        Token::Ident(name) if matches!(prev, Some(Token::For)) => Some((name.as_str(), false)),
379        _ => None,
380    }
381}
382
383/// The first name inside an interpolated string that does not read as what it
384/// is. A quoted `"$x"` never becomes a name-carrying token of its own — the
385/// whole string is one `Token::String` — so without this the quoted spelling
386/// of a name is the one door that reads an invisible character in silence.
387fn bad_name_in_parts(parts: &[StringPart]) -> Option<crate::name::NameError> {
388    fn root(path: &VarPath) -> Option<&str> {
389        match path.segments.first() {
390            Some(VarSegment::Field(name)) => Some(name.as_str()),
391            _ => None,
392        }
393    }
394    for part in parts {
395        let bad = match part {
396            StringPart::Var(path) | StringPart::VarLength(path) => {
397                root(path).and_then(|n| crate::name::validate(n).err())
398            }
399            StringPart::VarWithDefault { path, default } => root(path)
400                .and_then(|n| crate::name::validate(n).err())
401                .or_else(|| bad_name_in_parts(default)),
402            // A command substitution's own statements were parsed by `parse`,
403            // which ran this same scan over them.
404            _ => None,
405        };
406        if bad.is_some() {
407            return bad;
408        }
409    }
410    None
411}
412
413/// The root of a variable path — everything before the first subscript or
414/// dotted field. Only the root is a name; a subscript is data.
415fn root_of(inner: &str) -> &str {
416    let end = inner.find(['[', '.', ':', '-']).unwrap_or(inner.len());
417    &inner[..end]
418}
419
420pub(crate) fn parse_varpath(raw: &str) -> VarPath {
421    let segment_strs = lexer::parse_var_ref(raw).unwrap_or_default();
422    let segments = segment_strs
423        .into_iter()
424        .enumerate()
425        .map(|(i, s)| {
426            if i == 0 {
427                // The root name (or the special `?`). Normalized like every
428                // other door to a name; a subscript below is data and is not.
429                VarSegment::Field(crate::ast::normalize_name(s))
430            } else if let Some(inner) = s.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
431                parse_subscript(inner)
432            } else {
433                // A dotted `.field` — carried through as a Field so resolution
434                // produces the "use ${name[field]}" error (brackets only).
435                VarSegment::Field(s)
436            }
437        })
438        .collect();
439    VarPath { segments }
440}
441
442/// Parse the interior of a `[...]` subscript into a `VarSegment`.
443///
444/// Classification is syntactic (the container's runtime type decides list-vs-
445/// record at resolution): `$var` → dynamic; a quoted string → literal key;
446/// `int:int` (either side optional) → slice; a bare integer → index; anything
447/// else → a literal bareword key.
448fn parse_subscript(inner: &str) -> VarSegment {
449    // Dynamic: `[$var]`.
450    if let Some(var) = inner.strip_prefix('$') {
451        return VarSegment::Dynamic(var.to_string());
452    }
453    // Quoted key: `["weird key"]` or `['weird key']`.
454    if inner.len() >= 2
455        && ((inner.starts_with('"') && inner.ends_with('"'))
456            || (inner.starts_with('\'') && inner.ends_with('\'')))
457    {
458        return VarSegment::Key(inner[1..inner.len() - 1].to_string());
459    }
460    // Slice: `a:b` where each side is empty or a valid integer. A colon that
461    // isn't a numeric slice falls through to a bareword key (`["a:b"]` covers
462    // colon-bearing keys explicitly).
463    if let Some((lhs, rhs)) = inner.split_once(':') {
464        // A slice bound is a number position: `[007:2]` sliced from 7 and
465        // silently returned an empty list. Refusing it falls through to a
466        // bareword key, which `scope.rs` reports against the container.
467        let bound = |s: &str| -> Option<Option<i64>> {
468            if s.is_empty() {
469                Some(None)
470            } else if lexer::is_leading_zero_numeral(s) {
471                None
472            } else {
473                s.parse::<i64>().ok().map(Some)
474            }
475        };
476        if let (Some(start), Some(end)) = (bound(lhs), bound(rhs)) {
477            return VarSegment::Slice(start, end);
478        }
479    }
480    // Integer index: `[0]`, `[-1]`. `[007]` is text and falls through to the
481    // bareword key below.
482    if !lexer::is_leading_zero_numeral(inner)
483        && let Ok(i) = inner.parse::<i64>()
484    {
485        return VarSegment::Index(i);
486    }
487    // Bareword literal key: `[name]`, `[content-type]`.
488    VarSegment::Key(inner.to_string())
489}
490
491/// Drop `Stmt::Empty` (bare newlines/semicolons) from a parsed `$()` body so an
492/// empty or whitespace-only substitution collapses to nothing runnable.
493fn strip_empty_stmts(statements: Vec<Stmt>) -> Vec<Stmt> {
494    statements
495        .into_iter()
496        .filter(|s| !matches!(s, Stmt::Empty))
497        .collect()
498}
499
500/// Parse an unquoted heredoc body's interpolation while tracking each part's
501/// byte offset in the source.
502///
503/// `base_offset` is added to every part's offset so callers can attribute
504/// positions to a larger source (e.g., heredoc body inside the original
505/// script). Returns parts in source order with offset+len populated.
506///
507/// **Heredoc-specific behaviour**: per POSIX, unquoted heredoc bodies process
508/// three backslash escapes — `\$` (suppress expansion), `\\` (literal
509/// backslash), and `\<newline>` (line continuation). All other backslashes
510/// are kept verbatim. This differs from [`parse_interpolated_string`], which
511/// is called on double-quoted string content where the lexer has already
512/// processed escapes via `__KAISH_ESCAPED_DOLLAR__`.
513///
514/// This sibling of [`parse_interpolated_string`] duplicates parsing logic
515/// for now; unifying them behind a position-tracking core is a follow-up
516/// cleanup. Behaviour MUST stay aligned for the non-escape paths — bug fixes
517/// for the shared interpolation logic here should land there as well.
518fn parse_interpolated_string_spanned(
519    s: &str,
520    base_offset: usize,
521) -> Result<Vec<SpannedPart>, String> {
522    let s = s.replace("__KAISH_ESCAPED_DOLLAR__", "\x00DOLLAR\x00");
523
524    let chars_vec: Vec<char> = s.chars().collect();
525    let mut i = 0;
526    let mut pos: usize = 0;
527
528    let mut parts: Vec<SpannedPart> = Vec::new();
529    let mut current_text = String::new();
530    let mut current_text_start: usize = pos;
531
532    let push_literal =
533        |current_text: &mut String, start: &mut usize, end: usize, parts: &mut Vec<SpannedPart>| {
534            if !current_text.is_empty() {
535                parts.push(SpannedPart {
536                    part: StringPart::Literal(std::mem::take(current_text)),
537                    offset: base_offset + *start,
538                    len: end - *start,
539                });
540                *start = end;
541            }
542        };
543
544    while i < chars_vec.len() {
545        let ch = chars_vec[i];
546
547        if ch == '\x00' {
548            // Escaped-dollar marker: \x00 DOLLAR \x00 → literal '$'
549            let start = pos;
550            i += 1;
551            pos += 1;
552            let mut marker = String::new();
553            while let Some(&c) = chars_vec.get(i) {
554                if c == '\x00' {
555                    i += 1;
556                    pos += 1;
557                    break;
558                }
559                marker.push(c);
560                i += 1;
561                pos += c.len_utf8();
562            }
563            if marker == "DOLLAR" {
564                if current_text.is_empty() {
565                    current_text_start = start;
566                }
567                current_text.push('$');
568            }
569        } else if ch == '\\' {
570            // POSIX heredoc-body escape processing for unquoted heredocs.
571            // Only `\$`, `\\`, and `\<newline>` are escapes; everything else
572            // keeps the backslash verbatim. Each case advances `pos` by the
573            // bytes consumed from the source so subsequent part offsets stay
574            // anchored to original-source coordinates.
575            let next = chars_vec.get(i + 1).copied();
576            match next {
577                Some('$') => {
578                    if current_text.is_empty() {
579                        current_text_start = pos;
580                    }
581                    current_text.push('$');
582                    i += 2;
583                    pos += 2;
584                }
585                Some('\\') => {
586                    if current_text.is_empty() {
587                        current_text_start = pos;
588                    }
589                    current_text.push('\\');
590                    i += 2;
591                    pos += 2;
592                }
593                Some('\n') => {
594                    // Line continuation: consume both bytes, emit nothing.
595                    // The literal run resumes on the next line.
596                    i += 2;
597                    pos += 2;
598                    if current_text.is_empty() {
599                        current_text_start = pos;
600                    }
601                }
602                Some('\r') => {
603                    // \<CR> or \<CR><LF>: line continuation
604                    i += 2;
605                    pos += 2;
606                    if chars_vec.get(i) == Some(&'\n') {
607                        i += 1;
608                        pos += 1;
609                    }
610                    if current_text.is_empty() {
611                        current_text_start = pos;
612                    }
613                }
614                _ => {
615                    // Other backslash sequences: keep `\` literally,
616                    // consume only the backslash. The next iteration will
617                    // process the following char on its own merits.
618                    if current_text.is_empty() {
619                        current_text_start = pos;
620                    }
621                    current_text.push('\\');
622                    i += 1;
623                    pos += 1;
624                }
625            }
626        } else if ch == '$' {
627            // Possible expansion. Save current run before peeking ahead.
628            let part_start = pos;
629            let next = chars_vec.get(i + 1).copied();
630
631            if next == Some('(') && chars_vec.get(i + 2) != Some(&'(') {
632                // $(...) command substitution
633                push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
634                i += 2; // consume "$("
635                pos += 2;
636                let mut cmd_content = String::new();
637                let mut depth = 1;
638                let mut closed = false;
639                while let Some(&c) = chars_vec.get(i) {
640                    i += 1;
641                    pos += c.len_utf8();
642                    if c == '(' {
643                        depth += 1;
644                        cmd_content.push(c);
645                    } else if c == ')' {
646                        depth -= 1;
647                        if depth == 0 {
648                            closed = true;
649                            break;
650                        }
651                        cmd_content.push(c);
652                    } else {
653                        cmd_content.push(c);
654                    }
655                }
656                if !closed {
657                    return Err("unterminated command substitution: missing `)`".to_string());
658                }
659                // Both silent fallbacks are closed here rather than by reusing
660                // `parse_interpolated_string`: a heredoc body is not the inside
661                // of a double-quoted string and may hold a raw `"`, so the
662                // string scanner mis-reads `stamp = "$(date +%s)"` as
663                // unterminated. The escape models genuinely differ, which is
664                // why this sibling exists at all.
665                let inserted = if let Ok(program) = parse(&cmd_content) {
666                    // The full statement block runs as the substitution body
667                    // (pipelines, `&&`/`||`, `;`/newline sequences, comments).
668                    let stmts = strip_empty_stmts(program.statements);
669                    if stmts.is_empty() {
670                        false
671                    } else {
672                        parts.push(SpannedPart {
673                            part: StringPart::CommandSubst(stmts),
674                            offset: base_offset + part_start,
675                            len: pos - part_start,
676                        });
677                        true
678                    }
679                } else {
680                    return Err(format!(
681                        "syntax error in command substitution: $({cmd_content})"
682                    ));
683                };
684                if inserted {
685                    // Successfully pushed a CommandSubst; the next literal
686                    // run will start after the closing ')'.
687                    current_text_start = pos;
688                } else {
689                    // Fall back to literal text. The literal run starts at
690                    // the leading '$' (set above only if current_text was
691                    // empty); leave current_text_start alone otherwise so we
692                    // don't lose an in-progress run.
693                    if current_text.is_empty() {
694                        current_text_start = part_start;
695                    }
696                    current_text.push_str("$(");
697                    current_text.push_str(&cmd_content);
698                    current_text.push(')');
699                }
700            } else if next == Some('{') {
701                push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
702                i += 2; // consume "${"
703                pos += 2;
704                let mut var_content = String::new();
705                let mut depth = 1;
706                while let Some(&c) = chars_vec.get(i) {
707                    i += 1;
708                    pos += c.len_utf8();
709                    if c == '{' && var_content.ends_with('$') {
710                        depth += 1;
711                        var_content.push(c);
712                    } else if c == '}' {
713                        depth -= 1;
714                        if depth == 0 {
715                            break;
716                        }
717                        var_content.push(c);
718                    } else {
719                        var_content.push(c);
720                    }
721                }
722                let part = if let Some(name) = var_content.strip_prefix('#') {
723                    // `${#x:-y}` has no meaning: bash rejects it as a bad
724                    // substitution, and the unquoted door here already refuses
725                    // it. Without this the `#` strip wins and the whole
726                    // `x:-y` becomes the path, which resolves to unset and
727                    // reports 0 — a wrong length, silently, in the quoted
728                    // spelling only.
729                    if find_default_separator_in_content(name).is_some() {
730                        return Err(format!(
731                            "${{#{name}}}: a length cannot carry a default — \
732                             ${{#NAME}} counts, ${{NAME:-default}} substitutes. \
733                             Write ${{#NAME}} on a name you have set, or test it \
734                             first."
735                        ));
736                    }
737                    StringPart::VarLength(parse_varpath(&format!("${{{name}}}")))
738                } else if var_content.starts_with("__ARITH:") && var_content.ends_with("__") {
739                    let expr = var_content
740                        .strip_prefix("__ARITH:")
741                        .and_then(|s| s.strip_suffix("__"))
742                        .unwrap_or("");
743                    StringPart::Arithmetic(expr.to_string())
744                } else if let Some(colon_idx) = find_default_separator_in_content(&var_content) {
745                    let path = parse_varpath(&format!("${{{}}}", &var_content[..colon_idx]));
746                    let default_str = &var_content[colon_idx + 2..];
747                    // Default value spans recursively kept relative to the
748                    // outer body — the inner parts get their own offsets via
749                    // the recursive call when needed. For now, the default's
750                    // parts are stored without spans (default is a Vec<StringPart>).
751                    // Propagated, not discarded. The twin in
752                    // `parse_interpolated_string` already uses `?` here; this
753                    // copy swallowed a malformed `$(` in the default word and
754                    // kept it as literal text, so a heredoc body carrying
755                    // `${x:-$(echo hi}` ran with the substitution silently
756                    // dropped.
757                    let default = parse_interpolated_string(&unquote_default_word(default_str))?;
758                    StringPart::VarWithDefault { path, default }
759                } else if let Some(msg) = bash_substring_hint(&var_content) {
760                    return Err(msg);
761                } else {
762                    StringPart::Var(parse_varpath(&format!("${{{}}}", var_content)))
763                };
764                parts.push(SpannedPart {
765                    part,
766                    offset: base_offset + part_start,
767                    len: pos - part_start,
768                });
769                current_text_start = pos;
770            } else if next.map(|c| c.is_ascii_digit()).unwrap_or(false) {
771                push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
772                i += 1; // consume '$'
773                pos += 1;
774                if let Some(&digit) = chars_vec.get(i) {
775                    let n = digit.to_digit(10).unwrap_or(0) as usize;
776                    i += 1;
777                    pos += digit.len_utf8();
778                    parts.push(SpannedPart {
779                        part: StringPart::Positional(n),
780                        offset: base_offset + part_start,
781                        len: pos - part_start,
782                    });
783                }
784                current_text_start = pos;
785            } else if next == Some('@') {
786                push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
787                i += 2; // consume "$@"
788                pos += 2;
789                parts.push(SpannedPart {
790                    part: StringPart::AllArgs,
791                    offset: base_offset + part_start,
792                    len: pos - part_start,
793                });
794                current_text_start = pos;
795            } else if next == Some('#') {
796                push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
797                i += 2; // consume "$#"
798                pos += 2;
799                parts.push(SpannedPart {
800                    part: StringPart::ArgCount,
801                    offset: base_offset + part_start,
802                    len: pos - part_start,
803                });
804                current_text_start = pos;
805            } else if next == Some('?') {
806                push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
807                i += 2; // consume "$?"
808                pos += 2;
809                parts.push(SpannedPart {
810                    part: StringPart::LastExitCode,
811                    offset: base_offset + part_start,
812                    len: pos - part_start,
813                });
814                current_text_start = pos;
815            } else if next == Some('$') {
816                push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
817                i += 2; // consume "$$"
818                pos += 2;
819                parts.push(SpannedPart {
820                    part: StringPart::CurrentPid,
821                    offset: base_offset + part_start,
822                    len: pos - part_start,
823                });
824                current_text_start = pos;
825            } else if next.map(is_name_start).unwrap_or(false) {
826                push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
827                i += 1; // consume '$'
828                pos += 1;
829                let mut var_name = String::new();
830                while let Some(&c) = chars_vec.get(i) {
831                    if is_name_char(c) {
832                        var_name.push(c);
833                        i += 1;
834                        pos += c.len_utf8();
835                    } else {
836                        break;
837                    }
838                }
839                parts.push(SpannedPart {
840                    part: StringPart::Var(VarPath::simple(var_name)),
841                    offset: base_offset + part_start,
842                    len: pos - part_start,
843                });
844                current_text_start = pos;
845            } else {
846                // Bare $ — treat as literal
847                if current_text.is_empty() {
848                    current_text_start = pos;
849                }
850                current_text.push(ch);
851                i += 1;
852                pos += 1;
853            }
854        } else {
855            if current_text.is_empty() {
856                current_text_start = pos;
857            }
858            current_text.push(ch);
859            i += 1;
860            pos += ch.len_utf8();
861        }
862    }
863
864    push_literal(&mut current_text, &mut current_text_start, pos, &mut parts);
865
866    Ok(parts)
867}
868
869fn parse_interpolated_string(s: &str) -> Result<Vec<StringPart>, String> {
870    // First, replace escaped dollar markers with a temporary placeholder
871    // The lexer uses __KAISH_ESCAPED_DOLLAR__ for \$ to prevent re-interpretation
872    let s = s.replace("__KAISH_ESCAPED_DOLLAR__", "\x00DOLLAR\x00");
873
874    let mut parts = Vec::new();
875    let mut current_text = String::new();
876    let mut chars = s.chars().peekable();
877
878    while let Some(ch) = chars.next() {
879        if ch == '\x00' {
880            // This is our escaped dollar marker - skip "DOLLAR" and the closing \x00
881            let mut marker = String::new();
882            while let Some(&c) = chars.peek() {
883                if c == '\x00' {
884                    chars.next(); // consume closing marker
885                    break;
886                }
887                if let Some(c) = chars.next() {
888                    marker.push(c);
889                }
890            }
891            if marker == "DOLLAR" {
892                current_text.push('$');
893            }
894        } else if ch == '$' {
895            // Check for command substitution $(...)
896            if chars.peek() == Some(&'(') {
897                // Command substitution $(...)
898                if !current_text.is_empty() {
899                    parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
900                }
901
902                // Consume the '('
903                chars.next();
904
905                // Find the matching ')' the same way the unquoted `$(...)`
906                // form does: tokenize what remains and walk it with
907                // `find_cmd_subst_close` (the plain-slice twin of
908                // `CmdSubstFrames`) instead of counting raw `(`/`)`
909                // characters. A per-character count can't tell a
910                // case-branch pattern's unpaired `)` (`case $x in a) …`)
911                // from a real close, and it also miscounts a literal
912                // `(`/`)` sitting inside a quoted argument of the
913                // substitution itself (`$(echo "(")`).
914                let remainder: String = chars.clone().collect();
915                let close = lexer::tokenize(&remainder).ok().and_then(|toks| {
916                    let toks: Vec<(Token, Span)> = toks
917                        .into_iter()
918                        .map(|sp| (sp.token, (sp.span.start..sp.span.end).into()))
919                        .collect();
920                    find_cmd_subst_close(&toks).map(|idx| toks[idx].1)
921                });
922                // No close — or a remainder that does not even tokenize —
923                // means the substitution ran past the closing quote. Report
924                // it before `parse` sees the body: the body can be a valid
925                // program on its own (`echo hi`), so falling back to it runs
926                // a substitution nobody closed, and the plan then renders a
927                // `)` the writer never typed.
928                let Some(rparen_span) = close else {
929                    return Err("unterminated command substitution: missing `)`".to_string());
930                };
931                let (cmd_content, consume_bytes) =
932                    (remainder[..rparen_span.start].to_string(), rparen_span.end);
933                let mut consumed = 0usize;
934                while consumed < consume_bytes {
935                    match chars.next() {
936                        Some(c) => consumed += c.len_utf8(),
937                        None => break,
938                    }
939                }
940
941                // Parse the command content as a full statement block
942                // (pipelines, `&&`/`||` chains, `;`/newline sequences, comments).
943                match parse(&cmd_content) {
944                    Ok(program) => {
945                        let stmts = strip_empty_stmts(program.statements);
946                        if stmts.is_empty() {
947                            // Nothing runnable (e.g. `$()` or only a comment) —
948                            // bash treats this as the empty string. Keep literal.
949                            current_text.push_str("$(");
950                            current_text.push_str(&cmd_content);
951                            current_text.push(')');
952                        } else {
953                            parts.push(StringPart::CommandSubst(stmts));
954                        }
955                    }
956                    Err(_) => {
957                        // A syntax error inside the substitution is loud, exactly
958                        // like the unquoted `$(...)` form — never silently demoted
959                        // to literal text.
960                        return Err(format!(
961                            "syntax error in command substitution: $({cmd_content})"
962                        ));
963                    }
964                }
965            } else if chars.peek() == Some(&'{') {
966                // Braced variable reference ${...}
967                if !current_text.is_empty() {
968                    parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
969                }
970
971                // Consume the '{'
972                chars.next();
973
974                // Collect until matching '}', tracking nesting depth
975                let mut var_content = String::new();
976                let mut depth = 1;
977                for c in chars.by_ref() {
978                    if c == '{' && var_content.ends_with('$') {
979                        depth += 1;
980                        var_content.push(c);
981                    } else if c == '}' {
982                        depth -= 1;
983                        if depth == 0 {
984                            break;
985                        }
986                        var_content.push(c);
987                    } else {
988                        var_content.push(c);
989                    }
990                }
991
992                // Parse the content for special syntax
993                let part = if let Some(name) = var_content.strip_prefix('#') {
994                    // Variable length: ${#VAR} / ${#path[sub]}
995                    // `${#x:-y}` has no meaning: bash rejects it as a bad
996                    // substitution, and the unquoted door here already refuses
997                    // it. Without this the `#` strip wins and the whole
998                    // `x:-y` becomes the path, which resolves to unset and
999                    // reports 0 — a wrong length, silently, in the quoted
1000                    // spelling only.
1001                    if find_default_separator_in_content(name).is_some() {
1002                        return Err(format!(
1003                            "${{#{name}}}: a length cannot carry a default — \
1004                             ${{#NAME}} counts, ${{NAME:-default}} substitutes. \
1005                             Write ${{#NAME}} on a name you have set, or test it \
1006                             first."
1007                        ));
1008                    }
1009                    StringPart::VarLength(parse_varpath(&format!("${{{name}}}")))
1010                } else if var_content.starts_with("__ARITH:") && var_content.ends_with("__") {
1011                    // Arithmetic expression: ${__ARITH:expr__}
1012                    let expr = var_content
1013                        .strip_prefix("__ARITH:")
1014                        .and_then(|s| s.strip_suffix("__"))
1015                        .unwrap_or("");
1016                    StringPart::Arithmetic(expr.to_string())
1017                } else if let Some(colon_idx) = find_default_separator_in_content(&var_content) {
1018                    // Variable with default: ${VAR:-default} - recursively parse the default
1019                    let path = parse_varpath(&format!("${{{}}}", &var_content[..colon_idx]));
1020                    let default_str = &var_content[colon_idx + 2..];
1021                    let default = parse_interpolated_string(&unquote_default_word(default_str))?;
1022                    StringPart::VarWithDefault { path, default }
1023                } else if let Some(msg) = bash_substring_hint(&var_content) {
1024                    return Err(msg);
1025                } else {
1026                    // Regular variable: ${VAR} or ${VAR.field}
1027                    StringPart::Var(parse_varpath(&format!("${{{}}}", var_content)))
1028                };
1029                parts.push(part);
1030            } else if chars.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
1031                // Positional parameter $0-$9
1032                if !current_text.is_empty() {
1033                    parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
1034                }
1035                if let Some(digit) = chars.next() {
1036                    let n = digit.to_digit(10).unwrap_or(0) as usize;
1037                    parts.push(StringPart::Positional(n));
1038                }
1039            } else if chars.peek() == Some(&'@') {
1040                // All arguments $@
1041                if !current_text.is_empty() {
1042                    parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
1043                }
1044                chars.next(); // consume '@'
1045                parts.push(StringPart::AllArgs);
1046            } else if chars.peek() == Some(&'#') {
1047                // Argument count $#
1048                if !current_text.is_empty() {
1049                    parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
1050                }
1051                chars.next(); // consume '#'
1052                parts.push(StringPart::ArgCount);
1053            } else if chars.peek() == Some(&'?') {
1054                // Last exit code $?
1055                if !current_text.is_empty() {
1056                    parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
1057                }
1058                chars.next(); // consume '?'
1059                parts.push(StringPart::LastExitCode);
1060            } else if chars.peek() == Some(&'$') {
1061                // Current PID $$
1062                if !current_text.is_empty() {
1063                    parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
1064                }
1065                chars.next(); // consume second '$'
1066                parts.push(StringPart::CurrentPid);
1067            } else if chars.peek().copied().map(is_name_start).unwrap_or(false) {
1068                // Simple variable reference $NAME
1069                if !current_text.is_empty() {
1070                    parts.push(StringPart::Literal(std::mem::take(&mut current_text)));
1071                }
1072
1073                // Collect identifier characters
1074                let mut var_name = String::new();
1075                while let Some(&c) = chars.peek() {
1076                    if is_name_char(c) {
1077                        if let Some(c) = chars.next() {
1078                            var_name.push(c);
1079                        }
1080                    } else {
1081                        break;
1082                    }
1083                }
1084
1085                parts.push(StringPart::Var(VarPath::simple(var_name)));
1086            } else {
1087                // Literal $ (not followed by { or identifier start)
1088                current_text.push(ch);
1089            }
1090        } else {
1091            current_text.push(ch);
1092        }
1093    }
1094
1095    if !current_text.is_empty() {
1096        parts.push(StringPart::Literal(current_text));
1097    }
1098
1099    Ok(parts)
1100}
1101
1102/// Parse error with location and context.
1103#[derive(Debug, Clone)]
1104pub struct ParseError {
1105    pub span: Span,
1106    pub message: String,
1107}
1108
1109impl ParseError {
1110    /// Format the error against the original source, emitting a 1-indexed
1111    /// `line:col [parse]: <message>` prefix and a snippet of the offending
1112    /// line. Mirrors `ValidationIssue::format` so error reporting feels
1113    /// consistent across pipeline phases.
1114    pub fn format(&self, source: &str) -> String {
1115        let start = self.span.start;
1116        let mut line = 1usize;
1117        let mut col = 1usize;
1118        for (i, ch) in source.char_indices() {
1119            if i >= start {
1120                break;
1121            }
1122            if ch == '\n' {
1123                line += 1;
1124                col = 1;
1125            } else {
1126                col += 1;
1127            }
1128        }
1129        let line_content = {
1130            let line_start = source[..start.min(source.len())]
1131                .rfind('\n')
1132                .map_or(0, |i| i + 1);
1133            let line_end = source[start.min(source.len())..]
1134                .find('\n')
1135                .map_or(source.len(), |i| start + i);
1136            source.get(line_start..line_end).unwrap_or("")
1137        };
1138        if line_content.is_empty() {
1139            format!("{}:{} [parse]: {}", line, col, self.message)
1140        } else {
1141            format!(
1142                "{}:{} [parse]: {}\n  | {}",
1143                line, col, self.message, line_content
1144            )
1145        }
1146    }
1147}
1148
1149impl std::fmt::Display for ParseError {
1150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1151        write!(f, "{} at {:?}", self.message, self.span)
1152    }
1153}
1154
1155impl std::error::Error for ParseError {}
1156
1157/// Parse kaish source code into a Program AST.
1158pub fn parse(source: &str) -> Result<Program, Vec<ParseError>> {
1159    // Tokenize with logos
1160    let tokens = lexer::tokenize(source).map_err(|errs| {
1161        errs.into_iter()
1162            .map(|e| ParseError {
1163                span: (e.span.start..e.span.end).into(),
1164                message: format!("lexer error: {}", e.token),
1165            })
1166            .collect::<Vec<_>>()
1167    })?;
1168
1169    // Convert tokens to (Token, SimpleSpan) pairs
1170    let tokens: Vec<(Token, Span)> = tokens
1171        .into_iter()
1172        .map(|spanned| (spanned.token, (spanned.span.start..spanned.span.end).into()))
1173        .collect();
1174
1175    // bash's `${VAR:offset:length}` is checked on the token stream, before the
1176    // grammar runs, for the reason documented on `command_parser`: a `try_map`
1177    // rejection inside `choice` loses its own message to a competing
1178    // alternative's. This shape needs to *teach* the bracket form, so it is
1179    // caught where nothing can outvote it.
1180    //
1181    // A name that does not read as what it is — one holding whitespace, a
1182    // bidi control, or a zero-width character — is caught in the same scan and
1183    // for the same reason: the message has to name a character the reader
1184    // cannot see, so it must not lose to a competing alternative's.
1185    for (i, (tok, span)) in tokens.iter().enumerate() {
1186        let prev = i.checked_sub(1).and_then(|j| tokens.get(j)).map(|(t, _)| t);
1187        if let Some((name, is_target)) =
1188            name_in_token_kind(tok, prev, tokens.get(i + 1).map(|(t, _)| t))
1189        {
1190            if let Err(bad) = crate::name::validate(name) {
1191                // `.` and `#` in an assignment target belong to the validator,
1192                // which refuses them as `E017`/`E018` and names the corrected
1193                // spelling (`user[email]=x`) where this scan can only describe
1194                // the shape. Those two codes are a published surface, so the
1195                // scan stands aside for exactly that case — and only that
1196                // case. A dot or hash anywhere the validator never looks
1197                // (`for`, `read`, `unset`, `push`, `scatter --as`) is refused
1198                // right here, which is the hole this rule exists to close.
1199                let defer = is_target && matches!(bad.ch, '.' | '#');
1200                if !defer {
1201                    return Err(vec![ParseError { span: *span, message: bad.to_string() }]);
1202                }
1203            }
1204        }
1205        // The quoted spelling of a read: `"$x"` arrives whole, so its names
1206        // have to be dug out rather than met as tokens. An all-ASCII string
1207        // cannot hold a name this rule refuses — every character the rule
1208        // rejects is non-ASCII — so the common string never pays for the
1209        // second parse.
1210        if let Token::String(s) = tok {
1211            if !s.is_ascii() && s.contains('$') {
1212                if let Ok(parts) = parse_interpolated_string(s) {
1213                    if let Some(bad) = bad_name_in_parts(&parts) {
1214                        return Err(vec![ParseError { span: *span, message: bad.to_string() }]);
1215                    }
1216                }
1217            }
1218        }
1219        let message = match tok {
1220            Token::VarRef(raw) => raw
1221                .strip_prefix("${")
1222                .and_then(|s| s.strip_suffix('}'))
1223                .filter(|_| find_default_separator(raw).is_none())
1224                .and_then(bash_substring_hint),
1225            // A quoted `"${d:0:4}/file"` arrives as one string token. The
1226            // guard keeps this off the hot path — an interpolation with a
1227            // top-level colon is the only thing worth re-scanning for.
1228            Token::String(s) if s.contains("${") && s.contains(':') => {
1229                parse_interpolated_string(s).err()
1230            }
1231            _ => None,
1232        };
1233        if let Some(message) = message {
1234            return Err(vec![ParseError {
1235                span: *span,
1236                message,
1237            }]);
1238        }
1239    }
1240
1241    // End-of-input span
1242    let end_span: Span = (source.len()..source.len()).into();
1243
1244    parse_tokens(tokens, end_span, (0..0).into())
1245}
1246
1247/// Parse an already-tokenized slice into a `Program`, running the same
1248/// structural well-formedness checks [`parse`] runs on the top-level source.
1249///
1250/// Shared by [`parse`] and `cmd_subst_parser`'s route-C recursive descent into
1251/// an unquoted `$(...)` body (GH #194): the lexer's token spans are absolute
1252/// byte offsets into the original source in both cases, so a caller handing
1253/// in a sub-slice needs no span-rebasing — errors from this function already
1254/// point at the right place.
1255///
1256/// `stdin_anchor` is where the ambiguous-multiple-stdin-redirect diagnostic
1257/// (which carries no AST span of its own) points: the source start for the
1258/// top level, or the `$(...)` span for a nested body.
1259fn parse_tokens(
1260    tokens: Vec<(Token, Span)>,
1261    end_span: Span,
1262    stdin_anchor: Span,
1263) -> Result<Program, Vec<ParseError>> {
1264    // Parse with the per-thread parser, built once (see `CACHED_PARSER`). A
1265    // nested `$(...)` body reaches this from inside a `try_map` closure that
1266    // is itself running as part of a `CACHED_PARSER.with(...)` call on the
1267    // same thread — reentrant, but sound: `with` just hands out a shared
1268    // `&Boxed<...>` after the one-time init completes, and nothing here is a
1269    // `RefCell`, so a second concurrent `&` borrow on the same thread is
1270    // ordinary aliasing, not a conflict.
1271    //
1272    // `tokens.clone()` costs one extra token-vec copy on every call (paid
1273    // even on success) so `tokens` survives for `validate_cmd_subst_bodies`
1274    // below — see that function's doc comment for why a failure needs a
1275    // second look with the original tokens in hand.
1276    let input = Stream::from_iter(tokens.clone()).map(end_span, keep_pair as PairFn);
1277    let result = CACHED_PARSER.with(|parser| parser.parse(input));
1278
1279    let program = result.into_result().map_err(|errs| {
1280        // A malformed unquoted `$(...)` body can lose its own precise error
1281        // to chumsky's choice/alt bookkeeping (see `validate_cmd_subst_bodies`'s
1282        // doc comment) in favor of a generic one from an unrelated sibling
1283        // `choice` alternative. Re-validate every `$(...)` body directly,
1284        // outside that machinery, so a body failure reports its own message.
1285        // Cheap on the common (successful) path — this only runs once the
1286        // grammar has already failed.
1287        if let Err(specific) = validate_cmd_subst_bodies(&tokens) {
1288            return specific;
1289        }
1290        // Same chumsky bookkeeping loss, for `$(...)` inside a double-quoted
1291        // string instead of the unquoted grammar (see
1292        // `validate_interpolated_strings`'s doc comment).
1293        if let Err(specific) = validate_interpolated_strings(&tokens) {
1294            return specific;
1295        }
1296        // And the same, for a heredoc body's own `$(...)`.
1297        if let Err(specific) = validate_heredoc_bodies(&tokens) {
1298            return specific;
1299        }
1300        // The count grammar matches `Token::Int`, which `break 007` no longer
1301        // produces, so chumsky blames the whole statement alternative set.
1302        let error_starts: Vec<usize> = errs.iter().map(|e| e.span().start).collect();
1303        if let Err(specific) = validate_leading_zero_counts(&tokens, &error_starts) {
1304            return specific;
1305        }
1306        // `reject_glued_args` raises this from inside a `try_map` wrapping the
1307        // whole argv, where chumsky's alt bookkeeping swaps in a shallower
1308        // sibling's span — `git show HEAD:x.py` reported at `show`. Re-derive
1309        // the paste from the tokens instead and report the span from here.
1310        //
1311        // Only when the standing error IS that rejection: this corrects a span
1312        // and must never author a verdict. The scan is an approximation of the
1313        // argv grammar and finds adjacency the grammar accepts, so the gate is
1314        // load-bearing. Scanning from the grammar's own position forward keeps
1315        // it from blaming a legal run in an earlier clause.
1316        if errs.iter().all(is_glued_args_error)
1317            && let Some(from_offset) = errs.iter().map(|e| e.span().start).min()
1318            && let Err(specific) = validate_glued_args(&tokens, from_offset)
1319        {
1320            return specific;
1321        }
1322        errs.into_iter()
1323            .map(|e| ParseError {
1324                span: *e.span(),
1325                message: e.to_string(),
1326            })
1327            .collect::<Vec<_>>()
1328    })?;
1329
1330    // Structural well-formedness checks that chumsky's grammar can't surface a
1331    // clean message for. A command with two stdin sources (`<`/`<<`/`<<<`)
1332    // would silently depend on redirect ordering at execution time, so reject
1333    // it here — at parse time, which (unlike validation) can never be skipped.
1334    if first_ambiguous_stdin(&program.statements) {
1335        return Err(vec![ParseError {
1336            // Redirects carry no AST span; the message is the actionable
1337            // part. Precise columns would require spanning `Redirect` —
1338            // deferred.
1339            span: stdin_anchor,
1340            message: "multiple stdin redirects on one command are ambiguous; \
1341                      use exactly one of `<`, `<<`, or `<<<`"
1342                .to_string(),
1343        }]);
1344    }
1345
1346    Ok(program)
1347}
1348
1349/// Parse a single statement (useful for REPL).
1350pub fn parse_statement(source: &str) -> Result<Stmt, Vec<ParseError>> {
1351    let program = parse(source)?;
1352    program
1353        .statements
1354        .into_iter()
1355        .find(|s| !matches!(s, Stmt::Empty))
1356        .ok_or_else(|| {
1357            vec![ParseError {
1358                span: (0..source.len()).into(),
1359                message: "empty input".to_string(),
1360            }]
1361        })
1362}
1363
1364// ═══════════════════════════════════════════════════════════════════════════
1365// Parser Combinators - generic over input type
1366// ═══════════════════════════════════════════════════════════════════════════
1367
1368/// Top-level program parser.
1369fn program_parser<'tokens, 'src: 'tokens, I>(
1370) -> impl Parser<'tokens, I, Program, extra::Err<Rich<'tokens, Token, Span>>>
1371where
1372    I: ValueInput<'tokens, Token = Token, Span = Span>,
1373{
1374    statement_parser()
1375        .repeated()
1376        .collect::<Vec<_>>()
1377        .map(|statements| Program { statements })
1378}
1379
1380/// Statement parser - dispatches based on leading token.
1381/// Supports statement-level chaining with && and ||.
1382fn statement_parser<'tokens, I>(
1383) -> impl Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone
1384where
1385    I: ValueInput<'tokens, Token = Token, Span = Span>,
1386{
1387    recursive(|stmt| {
1388        let terminator = choice((just(Token::Newline), just(Token::Semi))).repeated();
1389
1390        // A loop count is an integer. `NumericLiteral` is here because `-0` is
1391        // one — a valid count whose source text does not round-trip, so it
1392        // lexes as that variant rather than `Int` and would otherwise stop
1393        // parsing.
1394        let loop_count = select! {
1395            Token::Int(n) => n as usize,
1396            Token::NumericLiteral(data) if matches!(data.value, Value::Int(_)) => {
1397                match data.value {
1398                    Value::Int(n) => n as usize,
1399                    _ => unreachable!("guarded by the select! pattern above"),
1400                }
1401            },
1402        };
1403
1404        // break [N] - break out of N levels of loops (default 1)
1405        let break_stmt = just(Token::Break)
1406            .ignore_then(loop_count.or_not())
1407            .map(Stmt::Break);
1408
1409        // continue [N] - continue to next iteration, skipping N levels (default 1)
1410        let continue_stmt = just(Token::Continue)
1411            .ignore_then(loop_count.or_not())
1412            .map(Stmt::Continue);
1413
1414        // return [expr] - return from a tool
1415        let return_stmt = just(Token::Return)
1416            .ignore_then(primary_expr_parser().or_not())
1417            .map(|e| Stmt::Return(e.map(Box::new)));
1418
1419        // exit [code] - exit the script
1420        let exit_stmt = just(Token::Exit)
1421            .ignore_then(primary_expr_parser().or_not())
1422            .map(|e| Stmt::Exit(e.map(Box::new)));
1423
1424        // set command: `set -e`, `set +e`, `set` (no args), `set -o pipefail`
1425        // This must come BEFORE assignment_parser to handle `set -e` vs `X=value`
1426        //
1427        // Strategy: Use lookahead to check what follows `set`:
1428        // - If followed by a flag (-e, --long, +e): parse as set command
1429        // - If followed by identifier NOT followed by =: parse as set command (e.g., `set pipefail`)
1430        // - If followed by nothing (end/newline/semi): parse as set command
1431        // - If followed by identifier then =: let assignment_parser handle it
1432        let set_flag_arg = choice((
1433            select! { Token::ShortFlag(f) => Arg::ShortFlag(f) },
1434            select! { Token::LongFlag(f) => Arg::LongFlag(f) },
1435            // PlusFlag for +e, +x etc. - convert to positional arg with + prefix
1436            select! { Token::PlusFlag(f) => Arg::Positional(Expr::Literal(Value::String(format!("+{}", f)))) },
1437        ));
1438
1439        // Option value after `-o`/`+o`: a size literal (`8K`, `1M`) or raw
1440        // byte count. Stringified so `set.rs` can `parse_size` the
1441        // `output-limit=<value>` it reconstructs.
1442        let option_value_str = select! {
1443            Token::NumberIdent(s) => s,
1444            Token::Int(n) => n.to_string(),
1445            Token::Ident(s) => s,
1446        };
1447
1448        // `-o output-limit=8K`: `name`, `=`, `value` are three tokens; fold
1449        // them back into a single `name=value` positional (the form `set.rs`
1450        // and bash both expect). Without this the `=` is a parse error.
1451        let set_option_assign = ident_parser()
1452            .then_ignore(just(Token::Eq))
1453            .then(option_value_str)
1454            .map(|(name, value)| {
1455                Arg::Positional(Expr::Literal(Value::String(format!("{name}={value}"))))
1456            });
1457
1458        // Quoted option such as `set -o "output-limit=8K"`: the whole thing is
1459        // one string token. Accept it as a positional so the quoted form works
1460        // too (agents reach for it after the unquoted form trips a shell lint).
1461        let set_quoted_arg = select! {
1462            Token::String(s) => Arg::Positional(Expr::Literal(Value::String(s))),
1463            Token::SingleString(s) => Arg::Positional(Expr::Literal(Value::String(s))),
1464        };
1465
1466        // set with flags: `set -e`, `set -e -u -o pipefail`
1467        let set_with_flags = just(Token::Set)
1468            .then(set_flag_arg)
1469            .then(
1470                choice((
1471                    set_flag_arg,
1472                    // `-o name=value` (try before the bare-ident arm).
1473                    set_option_assign,
1474                    set_quoted_arg,
1475                    // Identifiers like 'pipefail' after -o
1476                    ident_parser().map(|name| Arg::Positional(Expr::Literal(Value::String(name)))),
1477                ))
1478                .repeated()
1479                .collect::<Vec<_>>(),
1480            )
1481            .map(|((_, first_arg), mut rest_args)| {
1482                let mut args = vec![first_arg];
1483                args.append(&mut rest_args);
1484                Stmt::Command(Command {
1485                    name: "set".to_string(),
1486                    args,
1487                    redirects: vec![],
1488                })
1489            });
1490
1491        // set with no args: `set` alone (shows settings)
1492        // Must be followed by newline, semicolon, end of input, or a chaining operator (&&, ||)
1493        let set_no_args = just(Token::Set)
1494            .then(
1495                choice((
1496                    just(Token::Newline).to(()),
1497                    just(Token::Semi).to(()),
1498                    just(Token::And).to(()),
1499                    just(Token::Or).to(()),
1500                    end(),
1501                ))
1502                .rewind(),
1503            )
1504            .map(|_| Stmt::Command(Command {
1505                name: "set".to_string(),
1506                args: vec![],
1507                redirects: vec![],
1508            }));
1509
1510        // Try set_with_flags first (requires at least one flag)
1511        // Then try set_no_args (no args, followed by terminator)
1512        // If neither matches, fall through to assignment_parser
1513        let set_command = set_with_flags.or(set_no_args);
1514
1515        // Inline env prefix: `NAME=value ... command`. One or more bash-style
1516        // assignments immediately followed by a command/pipeline scopes those
1517        // assignments to that command only (Stmt::EnvScoped). With no command
1518        // following, this alternative fails and we fall through to a plain,
1519        // persistent assignment. Must precede `assignment_parser` so the
1520        // prefixed-command form wins when a command follows.
1521        // Env-prefix assignment stays BARE-IDENT ONLY — a subscripted target
1522        // (`user[email]=x cmd`) is illegal here, not just unsupported: a
1523        // structured value cannot cross the process boundary into a child's
1524        // environment, so there is nothing correct to assign.
1525        //
1526        // Using `ident_parser()` directly, not `lvalue_path_parser()`, means a
1527        // bracket run before `=` never gets a chance to parse as a path here.
1528        // Either it is absent (plain ident), or the lexer's lvalue suppression
1529        // fires and the stray `LBracket` fails this parser. Both fall through
1530        // to a real parse error rather than being accepted silently.
1531        let env_prefix_assign = ident_parser()
1532            .then_ignore(just(Token::Eq))
1533            .then(value_expr_parser())
1534            .map(|(name, value)| Assignment { path: VarPath::simple(name), value, local: false });
1535        let env_scoped = env_prefix_assign
1536            .repeated()
1537            .at_least(1)
1538            .collect::<Vec<_>>()
1539            .then(pipeline_parser(command_stage_parser()).map(pipeline_into_stmt))
1540            .map(|(assignments, body)| Stmt::EnvScoped {
1541                assignments,
1542                body: Box::new(body),
1543            });
1544
1545        // The compound statements. They reach `base_statement` only through
1546        // `pipeline_parser`, which parses a lone compound and hands it back
1547        // unwrapped — a compound and a compound-headed pipeline are the same
1548        // alternative, so neither can shadow the other. Parsing them as
1549        // separate alternatives is what produced "found '|' expected '&&'":
1550        // `for_parser` sat ahead of the pipeline, consumed through `done`, and
1551        // the `&&`/`||` fold below then met the `|`.
1552        let compound = choice((
1553            if_parser(stmt.clone()).map(Stmt::If),
1554            for_parser(stmt.clone()).map(Stmt::For),
1555            while_parser(stmt.clone()).map(Stmt::While),
1556            case_parser(stmt.clone()).map(Stmt::Case),
1557        ))
1558        .boxed();
1559
1560        // Base statement (without chaining)
1561        let base_statement = choice((
1562            just(Token::Newline).to(Stmt::Empty),
1563            set_command,
1564            env_scoped,
1565            assignment_parser().map(Stmt::Assignment),
1566            // Shell-style functions (use $1, $2 positional params)
1567            posix_function_parser(stmt.clone()).map(Stmt::ToolDef),  // name() { }
1568            bash_function_parser(stmt.clone()).map(Stmt::ToolDef),   // function name { }
1569            break_stmt,
1570            continue_stmt,
1571            return_stmt,
1572            exit_stmt,
1573            test_expr_stmt_parser().map(Stmt::Test),
1574            arith_cond_parser().map(Stmt::Arith),
1575            // Note: 'true' and 'false' are handled by command_parser/pipeline_parser
1576            pipeline_parser(choice((
1577                compound.map(|s| PipelineStage::Compound(Box::new(s))),
1578                command_stage_parser(),
1579            )))
1580            .map(pipeline_into_stmt),
1581        ))
1582        .boxed();
1583
1584        // Statement chaining: `&&` and `||` have EQUAL precedence and associate
1585        // left-to-right (POSIX), so `true || echo A && echo B` parses as
1586        // `((true || echo A) && echo B)` and prints B — NOT `&&`-binds-tighter.
1587        // A single left fold over a stream of (operator, statement) pairs gives
1588        // that: each operator wraps the accumulated left side with the next stmt.
1589        base_statement
1590            .clone()
1591            .foldl(
1592                choice((
1593                    just(Token::And).to(true), // true = &&
1594                    just(Token::Or).to(false), // false = ||
1595                ))
1596                .then(base_statement)
1597                .repeated(),
1598                |left, (is_and, right): (bool, Stmt)| {
1599                    if is_and {
1600                        Stmt::AndChain {
1601                            left: Box::new(left),
1602                            right: Box::new(right),
1603                        }
1604                    } else {
1605                        Stmt::OrChain {
1606                            left: Box::new(left),
1607                            right: Box::new(right),
1608                        }
1609                    }
1610                },
1611            )
1612            .then_ignore(terminator)
1613    })
1614}
1615
1616/// One bracket subscript in an assignment LHS: `[0]`, `[email]`, `["a b"]`,
1617/// `[$k]`, `[0:2]`. Reached only via the lexer's lvalue suppression (see
1618/// `lexer::flush_glob_run`), which keeps a bracket run immediately followed by
1619/// `=` from fusing into a `GlobWord` — so this always sees primitive
1620/// `LBracket`/`RBracket` tokens around one of a handful of interior shapes.
1621/// Interior classification reuses [`parse_subscript`] (string-based) for the
1622/// `Ident` case (bareword key or colon-fused slice like `0:2`/`0:-1` — colon
1623/// merge already ran ahead of this parser) so read and write share one
1624/// subscript grammar; the other interior kinds map straight to their segment.
1625fn lvalue_subscript_parser<'tokens, I>(
1626) -> impl Parser<'tokens, I, VarSegment, extra::Err<Rich<'tokens, Token, Span>>> + Clone
1627where
1628    I: ValueInput<'tokens, Token = Token, Span = Span>,
1629{
1630    let interior = choice((
1631        select! { Token::SimpleVarRef(name) => VarSegment::Dynamic(name) },
1632        select! { Token::String(s) => VarSegment::Key(s) },
1633        select! { Token::SingleString(s) => VarSegment::Key(s) },
1634        select! { Token::Int(n) => VarSegment::Index(n) },
1635        select! { Token::Ident(s) => parse_subscript(&s) },
1636        // Without this arm `r[007]=v` was a parse error while `${r[007]}`
1637        // read fine. Both are the same text key now.
1638        select! { Token::NumberIdent(s) => parse_subscript(&s) },
1639        // Same split for `r[-0]=v` and `r[1.0]=v`. Classifying from the raw
1640        // text is what makes read and write agree: it is the same string the
1641        // read path hands `parse_subscript`.
1642        select! { Token::NumericLiteral(d) => parse_subscript(&d.raw) },
1643    ));
1644
1645    just(Token::LBracket)
1646        .ignore_then(interior)
1647        .then_ignore(just(Token::RBracket))
1648        .labelled("subscript")
1649}
1650
1651/// An lvalue path: `NAME`, `NAME[sub]`, `NAME[sub][sub]…`. The root is a
1652/// plain identifier; zero or more bracket subscripts follow with no
1653/// whitespace expected between them (the lexer only suppresses fusion for a
1654/// bracket run immediately followed by `=`, so this is the only shape that
1655/// reaches here already split into primitive tokens).
1656fn lvalue_path_parser<'tokens, I>(
1657) -> impl Parser<'tokens, I, VarPath, extra::Err<Rich<'tokens, Token, Span>>> + Clone
1658where
1659    I: ValueInput<'tokens, Token = Token, Span = Span>,
1660{
1661    ident_parser()
1662        .then(lvalue_subscript_parser().repeated().collect::<Vec<_>>())
1663        .map(|(name, subscripts)| {
1664            // Normalized like every other door to a name, so binding through
1665            // one spelling and reading through another reaches one variable.
1666            let mut segments = vec![VarSegment::Field(crate::ast::normalize_name(name))];
1667            segments.extend(subscripts);
1668            VarPath { segments }
1669        })
1670        .labelled("lvalue path")
1671}
1672
1673/// Assignment: `NAME=value` / `NAME[sub]=value` (bash-style), or
1674/// `local NAME = value` (scoped). Bracket paths are lvalues here — see
1675/// `docs/LANGUAGE.md`, "Assignment — bracket-path lvalues". They resolve at
1676/// runtime in `Scope::walk_write`, which shares the read resolver's per-hop
1677/// classification so a read and a write disagree about no path.
1678fn assignment_parser<'tokens, I>(
1679) -> impl Parser<'tokens, I, Assignment, extra::Err<Rich<'tokens, Token, Span>>> + Clone
1680where
1681    I: ValueInput<'tokens, Token = Token, Span = Span>,
1682{
1683    // local NAME = value (with spaces around =)
1684    let local_assignment = just(Token::Local)
1685        .ignore_then(lvalue_path_parser())
1686        .then_ignore(just(Token::Eq))
1687        .then(value_expr_parser())
1688        .map(|(path, value)| Assignment {
1689            path,
1690            value,
1691            local: true,
1692        });
1693
1694    // Bash-style: NAME=value / NAME[sub]=value (no spaces around =)
1695    // The lexer produces IDENT (LBRACKET ... RBRACKET)* EQ EXPR, so we parse it here
1696    let bash_assignment = lvalue_path_parser()
1697        .then_ignore(just(Token::Eq))
1698        .then(value_expr_parser())
1699        .map(|(path, value)| Assignment {
1700            path,
1701            value,
1702            local: false,
1703        });
1704
1705    choice((local_assignment, bash_assignment))
1706        .labelled("assignment")
1707        .boxed()
1708}
1709
1710/// POSIX-style function: `name() { body }`
1711///
1712/// Produces a ToolDef with empty params - uses positional params ($1, $2, etc.)
1713fn posix_function_parser<'tokens, I, S>(
1714    stmt: S,
1715) -> impl Parser<'tokens, I, ToolDef, extra::Err<Rich<'tokens, Token, Span>>> + Clone
1716where
1717    I: ValueInput<'tokens, Token = Token, Span = Span>,
1718    S: Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
1719{
1720    ident_parser()
1721        .then_ignore(just(Token::LParen))
1722        .then_ignore(just(Token::RParen))
1723        .then_ignore(just(Token::LBrace))
1724        .then_ignore(just(Token::Newline).repeated())
1725        .then(
1726            stmt.repeated()
1727                .collect::<Vec<_>>()
1728                .map(|stmts| stmts.into_iter().filter(|s| !matches!(s, Stmt::Empty)).collect()),
1729        )
1730        .then_ignore(just(Token::Newline).repeated())
1731        .then_ignore(just(Token::RBrace))
1732        .map(|(name, body)| ToolDef { name, params: vec![], body })
1733        .labelled("POSIX function")
1734        .boxed()
1735}
1736
1737/// Bash-style function: `function name { body }` (without parens)
1738///
1739/// Produces a ToolDef with empty params - uses positional params ($1, $2, etc.)
1740fn bash_function_parser<'tokens, I, S>(
1741    stmt: S,
1742) -> impl Parser<'tokens, I, ToolDef, extra::Err<Rich<'tokens, Token, Span>>> + Clone
1743where
1744    I: ValueInput<'tokens, Token = Token, Span = Span>,
1745    S: Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
1746{
1747    just(Token::Function)
1748        .ignore_then(ident_parser())
1749        .then_ignore(just(Token::LBrace))
1750        .then_ignore(just(Token::Newline).repeated())
1751        .then(
1752            stmt.repeated()
1753                .collect::<Vec<_>>()
1754                .map(|stmts| stmts.into_iter().filter(|s| !matches!(s, Stmt::Empty)).collect()),
1755        )
1756        .then_ignore(just(Token::Newline).repeated())
1757        .then_ignore(just(Token::RBrace))
1758        .map(|(name, body)| ToolDef { name, params: vec![], body })
1759        .labelled("bash function")
1760        .boxed()
1761}
1762
1763/// If statement: `if COND; then STMTS [elif COND; then STMTS]* [else STMTS] fi`
1764///
1765/// elif clauses are desugared to nested if/else:
1766///   `if A; then X elif B; then Y else Z fi`
1767/// becomes:
1768///   `if A; then X else { if B; then Y else Z fi } fi`
1769fn if_parser<'tokens, I, S>(
1770    stmt: S,
1771) -> impl Parser<'tokens, I, IfStmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone
1772where
1773    I: ValueInput<'tokens, Token = Token, Span = Span>,
1774    S: Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
1775{
1776    // Parse a single branch: condition + then statements
1777    let branch = condition_parser()
1778        .then_ignore(just(Token::Semi).or_not())
1779        .then_ignore(just(Token::Newline).repeated())
1780        .then_ignore(just(Token::Then))
1781        .then_ignore(just(Token::Newline).repeated())
1782        .then(
1783            stmt.clone()
1784                .repeated()
1785                .collect::<Vec<_>>()
1786                .map(|stmts: Vec<Stmt>| {
1787                    stmts
1788                        .into_iter()
1789                        .filter(|s| !matches!(s, Stmt::Empty))
1790                        .collect::<Vec<_>>()
1791                }),
1792        );
1793
1794    // Parse elif branches: `elif COND; then STMTS`
1795    let elif_branch = just(Token::Elif)
1796        .ignore_then(condition_parser())
1797        .then_ignore(just(Token::Semi).or_not())
1798        .then_ignore(just(Token::Newline).repeated())
1799        .then_ignore(just(Token::Then))
1800        .then_ignore(just(Token::Newline).repeated())
1801        .then(
1802            stmt.clone()
1803                .repeated()
1804                .collect::<Vec<_>>()
1805                .map(|stmts: Vec<Stmt>| {
1806                    stmts
1807                        .into_iter()
1808                        .filter(|s| !matches!(s, Stmt::Empty))
1809                        .collect::<Vec<_>>()
1810                }),
1811        );
1812
1813    // Parse else branch: `else STMTS`
1814    let else_branch = just(Token::Else)
1815        .ignore_then(just(Token::Newline).repeated())
1816        .ignore_then(stmt.repeated().collect::<Vec<_>>())
1817        .map(|stmts: Vec<Stmt>| {
1818            stmts
1819                .into_iter()
1820                .filter(|s| !matches!(s, Stmt::Empty))
1821                .collect::<Vec<_>>()
1822        });
1823
1824    just(Token::If)
1825        .ignore_then(branch)
1826        .then(elif_branch.repeated().collect::<Vec<_>>())
1827        .then(else_branch.or_not())
1828        .then_ignore(just(Token::Fi))
1829        .map(|(((condition, then_branch), elif_branches), else_branch)| {
1830            // Build nested if/else structure from elif branches
1831            build_if_chain(condition, then_branch, elif_branches, else_branch)
1832        })
1833        .labelled("if statement")
1834        .boxed()
1835}
1836
1837/// Build a nested IfStmt chain from elif branches.
1838///
1839/// Transforms:
1840///   if A then X elif B then Y elif C then Z else W fi
1841/// Into:
1842///   IfStmt { cond: A, then: X, else: Some([IfStmt { cond: B, then: Y, else: Some([IfStmt { cond: C, then: Z, else: Some(W) }]) }]) }
1843fn build_if_chain(
1844    condition: Expr,
1845    then_branch: Vec<Stmt>,
1846    mut elif_branches: Vec<(Expr, Vec<Stmt>)>,
1847    else_branch: Option<Vec<Stmt>>,
1848) -> IfStmt {
1849    if elif_branches.is_empty() {
1850        // No elif, just if/else
1851        IfStmt {
1852            condition: Box::new(condition),
1853            then_branch,
1854            else_branch,
1855        }
1856    } else {
1857        // Pop the first elif and recursively build the rest
1858        let (elif_cond, elif_then) = elif_branches.remove(0);
1859        let nested_if = build_if_chain(elif_cond, elif_then, elif_branches, else_branch);
1860        IfStmt {
1861            condition: Box::new(condition),
1862            then_branch,
1863            else_branch: Some(vec![Stmt::If(nested_if)]),
1864        }
1865    }
1866}
1867
1868/// For loop: `for VAR in ITEMS; do STMTS done`
1869fn for_parser<'tokens, I, S>(
1870    stmt: S,
1871) -> impl Parser<'tokens, I, ForLoop, extra::Err<Rich<'tokens, Token, Span>>> + Clone
1872where
1873    I: ValueInput<'tokens, Token = Token, Span = Span>,
1874    S: Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
1875{
1876    just(Token::For)
1877        .ignore_then(ident_parser())
1878        .then_ignore(just(Token::In))
1879        .then(expr_parser().repeated().at_least(1).collect::<Vec<_>>())
1880        .then_ignore(just(Token::Semi).or_not())
1881        .then_ignore(just(Token::Newline).repeated())
1882        .then_ignore(just(Token::Do))
1883        .then_ignore(just(Token::Newline).repeated())
1884        .then(
1885            stmt.repeated()
1886                .collect::<Vec<_>>()
1887                .map(|stmts| stmts.into_iter().filter(|s| !matches!(s, Stmt::Empty)).collect()),
1888        )
1889        .then_ignore(just(Token::Done))
1890        .map(|((variable, items), body)| ForLoop {
1891            variable,
1892            items,
1893            body,
1894        })
1895        .labelled("for loop")
1896        .boxed()
1897}
1898
1899/// While loop: `while condition; do ...; done`
1900fn while_parser<'tokens, I, S>(
1901    stmt: S,
1902) -> impl Parser<'tokens, I, WhileLoop, extra::Err<Rich<'tokens, Token, Span>>> + Clone
1903where
1904    I: ValueInput<'tokens, Token = Token, Span = Span>,
1905    S: Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
1906{
1907    just(Token::While)
1908        .ignore_then(condition_parser())
1909        .then_ignore(just(Token::Semi).or_not())
1910        .then_ignore(just(Token::Newline).repeated())
1911        .then_ignore(just(Token::Do))
1912        .then_ignore(just(Token::Newline).repeated())
1913        .then(
1914            stmt.repeated()
1915                .collect::<Vec<_>>()
1916                .map(|stmts| stmts.into_iter().filter(|s| !matches!(s, Stmt::Empty)).collect()),
1917        )
1918        .then_ignore(just(Token::Done))
1919        .map(|(condition, body)| WhileLoop {
1920            condition: Box::new(condition),
1921            body,
1922        })
1923        .labelled("while loop")
1924        .boxed()
1925}
1926
1927/// Case statement: `case expr in pattern) commands ;; esac`
1928///
1929/// Supports:
1930/// - Single patterns: `pattern) commands ;;`
1931/// - Multiple patterns: `pattern1|pattern2) commands ;;`
1932/// - Optional leading `(` before patterns: `(pattern) commands ;;`
1933fn case_parser<'tokens, I, S>(
1934    stmt: S,
1935) -> impl Parser<'tokens, I, CaseStmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone
1936where
1937    I: ValueInput<'tokens, Token = Token, Span = Span>,
1938    S: Parser<'tokens, I, Stmt, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
1939{
1940    // Pattern part: individual tokens that make up a glob pattern
1941    // e.g., "*.rs" is Star + Dot + Ident("rs")
1942    let pattern_part = choice((
1943        select! { Token::GlobWord(s) => s },
1944        select! { Token::Ident(s) => s },
1945        select! { Token::NumberIdent(s) => s },
1946        select! { Token::DashNumWord(s) => s },
1947        select! { Token::AtWord(s) => s },
1948        select! { Token::DottedIdent(s) => s },
1949        select! { Token::String(s) => s },
1950        select! { Token::SingleString(s) => s },
1951        select! { Token::Int(n) => n.to_string() },
1952        select! { Token::Star => "*".to_string() },
1953        select! { Token::Question => "?".to_string() },
1954        select! { Token::Dot => ".".to_string() },
1955        select! { Token::DotDot => "..".to_string() },
1956        select! { Token::Tilde => "~".to_string() },
1957        select! { Token::TildePath(s) => s },
1958        select! { Token::RelativePath(s) => s },
1959        select! { Token::DotSlashPath(s) => s },
1960        select! { Token::Path(p) => p },
1961        select! { Token::VarRef(v) => v },
1962        select! { Token::SimpleVarRef(v) => format!("${}", v) },
1963        // Dash/plus bare words and flag-shaped tokens (GH #144): a case
1964        // pattern that happens to look like a flag (`-h`, `--help`, `+x`) or
1965        // an unrecognized dash/plus prefix (`---`, `-%`, `+%s`) is still a
1966        // literal glob pattern in case position, not a flag — the lexer
1967        // strips the leading dash/plus off `ShortFlag`/`LongFlag`/`PlusFlag`,
1968        // so put it back. Grouped in a nested `choice()` to stay under
1969        // chumsky's 26-element tuple limit for the outer `choice()`.
1970        choice((
1971            select! { Token::DoubleDashBare(s) => s },
1972            select! { Token::PlusBare(s) => s },
1973            select! { Token::MinusBare(s) => s },
1974            select! { Token::MinusAlone => "-".to_string() },
1975            select! { Token::DoubleDash => "--".to_string() },
1976            select! { Token::ShortFlag(s) => format!("-{}", s) },
1977            select! { Token::LongFlag(s) => format!("--{}", s) },
1978            select! { Token::PlusFlag(s) => format!("+{}", s) },
1979        )),
1980        // Character class: [a-z], [!abc], [^abc], etc.
1981        just(Token::LBracket)
1982            .ignore_then(
1983                choice((
1984                    select! { Token::Ident(s) => s },
1985                    select! { Token::Int(n) => n.to_string() },
1986                    just(Token::Colon).to(":".to_string()),
1987                    // Negation: ! or ^ at start of char class
1988                    just(Token::Bang).to("!".to_string()),
1989                    // Range like a-z
1990                    select! { Token::ShortFlag(s) => format!("-{}", s) },
1991                ))
1992                .repeated()
1993                .at_least(1)
1994                .collect::<Vec<String>>()
1995            )
1996            .then_ignore(just(Token::RBracket))
1997            .map(|parts| format!("[{}]", parts.join(""))),
1998        // Brace expansion: {a,b,c} or {js,ts}
1999        just(Token::LBrace)
2000            .ignore_then(
2001                choice((
2002                    select! { Token::Ident(s) => s },
2003                    select! { Token::Int(n) => n.to_string() },
2004                ))
2005                .separated_by(just(Token::Comma))
2006                .at_least(1)
2007                .collect::<Vec<String>>()
2008            )
2009            .then_ignore(just(Token::RBrace))
2010            .map(|parts| format!("{{{}}}", parts.join(","))),
2011    ));
2012
2013    // A complete pattern is one or more pattern parts joined together
2014    // e.g., "*.rs" = Star + Dot + Ident
2015    let pattern = pattern_part
2016        .repeated()
2017        .at_least(1)
2018        .collect::<Vec<String>>()
2019        .map(|parts| parts.join(""))
2020        .labelled("case pattern");
2021
2022    // Multiple patterns separated by pipe: `pattern1 | pattern2`
2023    let patterns = pattern
2024        .separated_by(just(Token::Pipe))
2025        .at_least(1)
2026        .collect::<Vec<String>>()
2027        .labelled("case patterns");
2028
2029    // Branch: `[( ] patterns ) commands ;;`
2030    let branch = just(Token::LParen)
2031        .or_not()
2032        .ignore_then(just(Token::Newline).repeated())
2033        .ignore_then(patterns)
2034        .then_ignore(just(Token::RParen))
2035        .then_ignore(just(Token::Newline).repeated())
2036        .then(
2037            stmt.clone()
2038                .repeated()
2039                .collect::<Vec<_>>()
2040                .map(|stmts| stmts.into_iter().filter(|s| !matches!(s, Stmt::Empty)).collect()),
2041        )
2042        .then_ignore(just(Token::DoubleSemi))
2043        .then_ignore(just(Token::Newline).repeated())
2044        .map(|(patterns, body)| CaseBranch { patterns, body })
2045        .labelled("case branch");
2046
2047    just(Token::Case)
2048        .ignore_then(expr_parser())
2049        .then_ignore(just(Token::In))
2050        .then_ignore(just(Token::Newline).repeated())
2051        .then(branch.repeated().collect::<Vec<_>>())
2052        .then_ignore(just(Token::Esac))
2053        .map(|(expr, branches)| CaseStmt { expr, branches })
2054        .labelled("case statement")
2055        .boxed()
2056}
2057
2058/// Pipeline: `stage | stage | stage [&]`.
2059///
2060/// `stage` is the caller's stage parser — `command_stage_parser()` alone where
2061/// only a command is legal, or a compound statement ahead of it in the
2062/// positions that host one. Taking it as a parameter keeps every compound
2063/// inside the single `recursive(|stmt| …)` in `statement_parser`, and lets one
2064/// alternative serve both a bare compound and a compound-headed pipeline:
2065/// `pipeline_into_stmt` unwraps a lone compound stage back to its statement.
2066fn pipeline_parser<'tokens, I, S>(
2067    stage: S,
2068) -> impl Parser<'tokens, I, Pipeline, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2069where
2070    I: ValueInput<'tokens, Token = Token, Span = Span>,
2071    S: Parser<'tokens, I, PipelineStage, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
2072{
2073    stage
2074        .separated_by(just(Token::Pipe))
2075        .at_least(1)
2076        .collect::<Vec<_>>()
2077        .then(just(Token::Amp).or_not())
2078        .map(|(stages, bg)| Pipeline {
2079            stages,
2080            background: bg.is_some(),
2081        })
2082        .labelled("pipeline")
2083        .boxed()
2084}
2085
2086/// A single command as a pipeline stage.
2087fn command_stage_parser<'tokens, I>(
2088) -> impl Parser<'tokens, I, PipelineStage, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2089where
2090    I: ValueInput<'tokens, Token = Token, Span = Span>,
2091{
2092    command_parser().map(PipelineStage::Command)
2093}
2094
2095/// Command: `name args... [redirects...]`
2096/// Command names can be identifiers, 'true', 'false', ':' (null command), or
2097/// '.' (source alias).
2098fn command_parser<'tokens, I>(
2099) -> impl Parser<'tokens, I, Command, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2100where
2101    I: ValueInput<'tokens, Token = Token, Span = Span>,
2102{
2103    // Command name can be an identifier, path, 'true', 'false', ':' (null
2104    // command), '.' (source alias), or ./path. A bare `:` reaches here only
2105    // when nothing adjacent fused it into a word — inside brackets and braces
2106    // the colon is structural (record entries, slices, character classes) and
2107    // never reaches a command-name position.
2108    let command_name = choice((
2109        ident_parser(),
2110        path_parser(),
2111        select! { Token::DotSlashPath(s) => s },
2112        just(Token::True).to("true".to_string()),
2113        just(Token::False).to("false".to_string()),
2114        just(Token::Colon).to(":".to_string()),
2115        just(Token::Dot).to(".".to_string()),
2116    ));
2117
2118    // NB: the "at most one stdin source per command" rule is enforced by a
2119    // post-parse scan in `parse()` (see `first_ambiguous_stdin`), NOT here.
2120    // A `try_map` rejection at this level cannot surface its own message: a
2121    // command like `cat <<< a <<< b` also fails the competing statement-level
2122    // assignment/function alternative ("expected '=', or '('"), and chumsky's
2123    // `choice` merge keeps that alternative's error regardless of which span
2124    // our custom error carries. So we accept the command here and reject it
2125    // structurally after parsing, where the message is fully under our control
2126    // (verified empirically 2026-06-07).
2127    command_name
2128        .then(args_list_parser())
2129        .then(redirect_parser(primary_expr_parser()).repeated().collect::<Vec<_>>())
2130        .map(|((name, args), redirects)| Command {
2131            name,
2132            args,
2133            redirects,
2134        })
2135        .labelled("command")
2136        .boxed()
2137}
2138
2139/// Map a parsed `Pipeline` to a statement, unwrapping a single redirect-free
2140/// foreground command to `Stmt::Command` (the canonical shape used throughout
2141/// the parser). Shared by the top-level statement parser, `$()` bodies, and
2142/// inline env-prefix bodies so the unwrap rule lives in one place.
2143fn pipeline_into_stmt(p: Pipeline) -> Stmt {
2144    if p.stages.len() == 1 && !p.background && p.stages[0].redirects().is_empty() {
2145        match p.stages.into_iter().next() {
2146            // A lone compound stage is just that statement — `for … done` on
2147            // its own parses to `Stmt::For`, exactly as it did before the
2148            // pipeline position learned to host one.
2149            Some(PipelineStage::Compound(stmt)) => *stmt,
2150            Some(PipelineStage::Command(cmd)) => Stmt::Command(cmd),
2151            None => Stmt::Empty, // unreachable (len checked) but safe
2152        }
2153    } else {
2154        Stmt::Pipeline(p)
2155    }
2156}
2157
2158/// True if `cmd` has more than one stdin source (`<`, `<<`, `<<<`). Such a
2159/// command would silently depend on redirect ordering at execution time
2160/// (`setup_stdin_redirects` is last-wins), so `parse()` rejects it loudly.
2161fn command_has_ambiguous_stdin(cmd: &Command) -> bool {
2162    cmd.redirects
2163        .iter()
2164        .filter(|r| {
2165            matches!(
2166                r.kind,
2167                RedirectKind::Stdin | RedirectKind::HereDoc(_) | RedirectKind::HereString
2168            )
2169        })
2170        .count()
2171        > 1
2172}
2173
2174/// Find the first command anywhere in `stmts` (recursing into pipelines,
2175/// control-flow bodies, chains, and tool definitions) that has more than one
2176/// stdin source. Used by `parse()` to reject the ambiguity after parsing.
2177fn first_ambiguous_stdin(stmts: &[Stmt]) -> bool {
2178    stmts.iter().any(stmt_has_ambiguous_stdin)
2179}
2180
2181fn stmt_has_ambiguous_stdin(stmt: &Stmt) -> bool {
2182    match stmt {
2183        Stmt::Command(c) => command_has_ambiguous_stdin(c),
2184        Stmt::Pipeline(p) => p.stages.iter().any(|stage| match stage {
2185            PipelineStage::Command(cmd) => command_has_ambiguous_stdin(cmd),
2186            PipelineStage::Compound(inner) => stmt_has_ambiguous_stdin(inner),
2187        }),
2188        Stmt::If(i) => {
2189            first_ambiguous_stdin(&i.then_branch)
2190                || i.else_branch
2191                    .as_deref()
2192                    .is_some_and(first_ambiguous_stdin)
2193        }
2194        Stmt::For(f) => first_ambiguous_stdin(&f.body),
2195        Stmt::While(w) => first_ambiguous_stdin(&w.body),
2196        Stmt::Case(c) => c.branches.iter().any(|b| first_ambiguous_stdin(&b.body)),
2197        Stmt::ToolDef(t) => first_ambiguous_stdin(&t.body),
2198        Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
2199            stmt_has_ambiguous_stdin(left) || stmt_has_ambiguous_stdin(right)
2200        }
2201        Stmt::EnvScoped { body, .. } => stmt_has_ambiguous_stdin(body),
2202        Stmt::Assignment(_)
2203        | Stmt::Break(_)
2204        | Stmt::Continue(_)
2205        | Stmt::Return(_)
2206        | Stmt::Exit(_)
2207        | Stmt::Test(_)
2208        | Stmt::Arith(_)
2209        | Stmt::Empty => false,
2210    }
2211}
2212
2213/// True for the argv-fragment `Arg` shapes eligible for the glue check:
2214/// bareword/expr positionals and long flags.
2215///
2216/// `ShortFlag` is excluded: `cut -d,` and `grep -A$n` are the getopt glued-value
2217/// idiom the kernel binder already supports, and a punctuation or substitution
2218/// value genuinely arrives as two adjacent `Arg`s. `--flag` has no such idiom,
2219/// so a `LongFlag` glued to a fragment is always an accident.
2220///
2221/// `Named`/`WordAssign` ARE candidates: their own fusion covers the boundaries
2222/// inside the word, not a fragment glued to the END of the value, and
2223/// `--a=1--b=2` is pasting by any other name.
2224fn is_glue_candidate(arg: &Arg) -> bool {
2225    matches!(
2226        arg,
2227        Arg::Positional(_) | Arg::LongFlag(_) | Arg::Named { .. } | Arg::WordAssign { .. }
2228    )
2229}
2230
2231/// Reject a run of argv fragments produced by glued (zero source-gap)
2232/// tokens — kaish does no token pasting, so an unquoted `/tmp/$(echo
2233/// x).txt` lexes into three fragments (`/tmp/`, the substitution, `.txt`)
2234/// that would otherwise silently bind as THREE separate args, and
2235/// `--flag$(echo x)` glues a flag straight to the next fragment with no
2236/// error at all. Shared by the pre-`--` and post-`--` argument grammars
2237/// (GH #189: the post-`--` half of this used to be unchecked entirely — a
2238/// script relying on `--` to end flag parsing got a silent argv-splat
2239/// instead of this same helpful error).
2240///
2241/// A comma-bearing word (`cut -f1,3`, `sort -k2,2n`, `echo a,b`) used to
2242/// trip this guard and get a comma-specific "kaish reserves `,`" hint —
2243/// that was never true outside a `[...]`/`{...}` literal or pattern, and
2244/// the lexer now folds a bare comma into the surrounding bareword before
2245/// the parser ever sees separate fragments (see `lexer::flush_glob_run`),
2246/// so a comma-bearing word no longer reaches this function as two glued
2247/// `Arg`s at all. Every remaining case is genuine token pasting.
2248fn reject_glued_args<'src>(
2249    args: Vec<(Arg, Span)>,
2250) -> Result<Vec<Arg>, Rich<'src, Token, Span>> {
2251    for pair in args.windows(2) {
2252        let (prev, prev_span) = &pair[0];
2253        let (next, next_span) = &pair[1];
2254        if is_glue_candidate(prev) && is_glue_candidate(next) && prev_span.end == next_span.start {
2255            return Err(Rich::custom(*next_span, GLUED_ARGS_MESSAGE));
2256        }
2257    }
2258    Ok(args.into_iter().map(|(arg, _)| arg).collect())
2259}
2260
2261/// The message `reject_glued_args` and [`validate_glued_args`] both raise —
2262/// kept as one constant so the raw-token fallback can never drift from the
2263/// grammar-level check it exists to correct the span for.
2264const GLUED_ARGS_MESSAGE: &str = "adjacent words with no space between them are not joined into \
2265     one argument (kaish does no token pasting); quote the whole word, e.g. \
2266     \"/tmp/$(echo x).txt\" or \"$dir/out.txt\"";
2267
2268/// True when `e` is `reject_glued_args`'s own rejection and nothing else —
2269/// the only case where [`validate_glued_args`] may restate the span.
2270///
2271/// Any other `Custom` is a purpose-built diagnosis, and `flat_merge` prefers
2272/// `Custom`, so it is the message standing here and must reach the caller
2273/// untouched. An `ExpectedFound` means the grammar never judged this a paste,
2274/// and inventing that verdict would change what kaish rejects. No skip list is
2275/// needed: a new guard is protected the day it is written.
2276fn is_glued_args_error(e: &Rich<'_, Token, Span>) -> bool {
2277    matches!(e.reason(), RichReason::Custom(msg) if msg.as_str() == GLUED_ARGS_MESSAGE)
2278}
2279
2280/// Arguments list parser that handles `--` flag terminator.
2281///
2282/// After `--`, all subsequent flags are converted to positional string arguments.
2283fn args_list_parser<'tokens, I>(
2284) -> impl Parser<'tokens, I, Vec<Arg>, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2285where
2286    I: ValueInput<'tokens, Token = Token, Span = Span>,
2287{
2288    // Arguments before `--` (normal parsing). Each arg is captured with its
2289    // source span so we can reject the silent argv-splat: two argv fragments
2290    // with no whitespace between them (`/tmp/$(echo x).txt` → 3 args,
2291    // `--flag$(echo x)` → a flag glued to one). kaish does no token pasting,
2292    // so an unquoted interpolated word fragments into separate args; the fix
2293    // is to quote the whole word. Single-token words (`file.txt`, `v1.2.3`)
2294    // are one arg and never trigger this. See `reject_glued_args`.
2295    let pre_dash = arg_before_double_dash_parser()
2296        .map_with(|arg, e| -> (Arg, Span) { (arg, e.span()) })
2297        .repeated()
2298        .collect::<Vec<(Arg, Span)>>()
2299        .try_map(|args, _span| reject_glued_args(args));
2300
2301    // The `--` marker itself
2302    let double_dash = select! {
2303        Token::DoubleDash => Arg::DoubleDash,
2304    };
2305
2306    // Arguments after `--` (flags become positional strings)
2307    let post_dash_arg = choice((
2308        // `--flag=value` — one operand, like `name=value` below. Long flags
2309        // only; see the production's own doc for why `-x=value` is not here.
2310        // This must precede the bare-flag rule, which would otherwise take
2311        // `--flag` and leave `=value` to be rejected as a glued word. Past
2312        // `--` there is no flag for a value to belong to, so the pair is
2313        // simply text; the binders stringify it the way they already
2314        // stringify a post-`--` `WordAssign`.
2315        post_dash_flag_value_parser(),
2316        // Flags become positional strings
2317        select! {
2318            Token::ShortFlag(name) => Arg::Positional(Expr::Literal(Value::String(format!("-{}", name)))),
2319            Token::LongFlag(name) => Arg::Positional(Expr::Literal(Value::String(format!("--{}", name)))),
2320        },
2321        // `name=value` — same WordAssign production used before `--`. Nothing
2322        // is special after `--` (standard shell behavior), but the
2323        // WordAssign→positional collapse already yields the literal
2324        // `"name=value"` string for commands that don't consume shell
2325        // assignments (like `echo`), so no separate literal-folding rule is
2326        // needed here.
2327        word_assign_arg_parser(),
2328        // `test`/`[` operators stay literal after `--` too (`test -- a = b`).
2329        test_operator_arg_parser(),
2330        // Everything else stays the same
2331        primary_expr_parser().map(Arg::Positional),
2332    ));
2333
2334    // Same glue guard as `pre_dash` (GH #189): before this, a post-`--`
2335    // glued word silently split into separate positionals instead of
2336    // erroring — the pre-`--` guard never ran over these tokens at all.
2337    let post_dash = post_dash_arg
2338        .map_with(|arg, e| -> (Arg, Span) { (arg, e.span()) })
2339        .repeated()
2340        .collect::<Vec<(Arg, Span)>>()
2341        .try_map(|args, _span| reject_glued_args(args));
2342
2343    // Combine: args_before ++ [--] ++ args_after
2344    pre_dash
2345        .then(double_dash.then(post_dash).or_not())
2346        .map(|(mut args, maybe_dd)| {
2347            if let Some((dd, post)) = maybe_dd {
2348                args.push(dd);
2349                args.extend(post);
2350            }
2351            args
2352        })
2353}
2354
2355/// A statement keyword used as a plain word — its source spelling.
2356///
2357/// Lets keywords serve as the *key* of a `key=value` argv assignment, so
2358/// `dd if=/dev/urandom` works (`if` is `Token::If`, not an `Ident`). Safe
2359/// because: a statement-level `if`/`for`/… is decided before arg parsing (the
2360/// compound parsers are the pipeline's first stage alternative, tried ahead of
2361/// `command_parser`), `command_name` never accepts these tokens, and the
2362/// `key=value` rule requires the key span-adjacent to `=` — a real `if <cond>`
2363/// has a space and never matches. See docs/binary-data.md.
2364fn keyword_word<'tokens, I>(
2365) -> impl Parser<'tokens, I, String, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2366where
2367    I: ValueInput<'tokens, Token = Token, Span = Span>,
2368{
2369    select! {
2370        Token::Set => "set",
2371        Token::Local => "local",
2372        Token::If => "if",
2373        Token::Then => "then",
2374        Token::Else => "else",
2375        Token::Elif => "elif",
2376        Token::Fi => "fi",
2377        Token::For => "for",
2378        Token::While => "while",
2379        Token::In => "in",
2380        Token::Do => "do",
2381        Token::Done => "done",
2382        Token::Case => "case",
2383        Token::Esac => "esac",
2384        Token::Function => "function",
2385        Token::Break => "break",
2386        Token::Continue => "continue",
2387        Token::Return => "return",
2388        Token::Exit => "exit",
2389    }
2390    .map(|s| s.to_string())
2391}
2392
2393/// Shell assignment in argv position: `name=value` (must not have spaces
2394/// around `=`). Produces `Arg::WordAssign`; the kernel routes it through
2395/// `tool_args.named` only for shell-assignment-accepting builtins (export,
2396/// alias). For every other command it materialises as a `"name=value"`
2397/// positional, matching bash semantics (`cat foo=bar` opens a file named
2398/// `foo=bar`). Shared by the pre-`--` and post-`--` argument grammars — the
2399/// `WordAssign`/positional collapse already gives `--`-following `a=b` the
2400/// literal-string behavior shell users expect, so it needs no special casing
2401/// after `--`.
2402/// `--flag=value` after `--`, as a single `Arg::Named`.
2403///
2404/// The binders route a `Named` past `--` into a stringified
2405/// `"--flag=value"` positional, exactly as they do for `WordAssign` — so this
2406/// production only has to say "these three tokens are one word". Adjacency is
2407/// checked the same way `word_assign_arg_parser` checks it, which keeps
2408/// `-- --flag = value` (spaced) a separate-words error rather than silently
2409/// pasting.
2410///
2411/// Long flags only, because `Arg::Named` means a long flag everywhere else
2412/// and the binders reconstruct it with `--`. `-x=value` is refused on BOTH
2413/// sides of `--` today (`echo -n=1` is the same parse error), so accepting it
2414/// only after `--` would trade one asymmetry for another; that spelling is
2415/// its own question about the flag grammar.
2416fn post_dash_flag_value_parser<'tokens, I>(
2417) -> impl Parser<'tokens, I, Arg, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2418where
2419    I: ValueInput<'tokens, Token = Token, Span = Span>,
2420{
2421    select! { Token::LongFlag(name) => name }
2422        .map_with(|s, e| -> (String, Span) { (s, e.span()) })
2423    .then(just(Token::Eq).map_with(|_, e| -> Span { e.span() }))
2424    .then(primary_expr_parser().map_with(|expr, e| -> (Expr, Span) { (expr, e.span()) }))
2425    .try_map(
2426        |(((key, key_span), eq_span), (value, value_span)): (((String, Span), Span), (Expr, Span)),
2427         span| {
2428            if key_span.end != eq_span.start || eq_span.end != value_span.start {
2429                Err(Rich::custom(
2430                    span,
2431                    "a flag and its value must not have spaces around '=' \
2432                     (use '--flag=value' not '--flag = value')",
2433                ))
2434            } else {
2435                Ok(Arg::Named { key, value })
2436            }
2437        },
2438    )
2439}
2440
2441fn word_assign_arg_parser<'tokens, I>(
2442) -> impl Parser<'tokens, I, Arg, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2443where
2444    I: ValueInput<'tokens, Token = Token, Span = Span>,
2445{
2446    choice((
2447        select! { Token::Ident(s) => s },
2448        keyword_word(),
2449    ))
2450    .map_with(|s, e| -> (String, Span) { (s, e.span()) })
2451    .then(just(Token::Eq).map_with(|_, e| -> Span { e.span() }))
2452    .then(primary_expr_parser().map_with(|expr, e| -> (Expr, Span) { (expr, e.span()) }))
2453    .try_map(|(((key, key_span), eq_span), (value, value_span)): (((String, Span), Span), (Expr, Span)), span| {
2454        // Check that key ends where = starts and = ends where value starts
2455        if key_span.end != eq_span.start || eq_span.end != value_span.start {
2456            Err(Rich::custom(
2457                span,
2458                "shell assignment must not have spaces around '=' (use 'key=value' not 'key = value')",
2459            ))
2460        } else {
2461            Ok(Arg::WordAssign { key, value })
2462        }
2463    })
2464}
2465
2466/// The `test`/`[` comparison and negation operators (`=`, `==`, `!=`, `!`) as
2467/// ordinary positional argv words.
2468///
2469/// POSIX `test` is a *command*, so its operators must reach it flat as argv —
2470/// but kaish lexes `=`/`==`/`!=`/`!` as shell-significant tokens, so at
2471/// command-argument position they used to parse-error before ever reaching a
2472/// command (`test a = b`). This production makes each a literal-string
2473/// positional. It is name-agnostic: like bash, `echo a = b` prints `a = b` —
2474/// no command name is special-cased (that would be fragile under aliases).
2475///
2476/// Deliberately EXCLUDES the angle brackets `<` `>` `<=` `>=`: those stay
2477/// redirection (making them argv would shadow redirects) and remain
2478/// `[[ ]]`-only. Ordered after the flag/`word_assign` productions so a glued
2479/// `name=value` still binds as a `WordAssign` — this bare-operator rule only
2480/// fires once the current token IS the standalone operator (a spaced `a = b`,
2481/// where `word_assign`'s span-adjacency check has already declined).
2482fn test_operator_arg_parser<'tokens, I>(
2483) -> impl Parser<'tokens, I, Arg, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2484where
2485    I: ValueInput<'tokens, Token = Token, Span = Span>,
2486{
2487    select! {
2488        Token::Eq => "=",
2489        Token::EqEq => "==",
2490        Token::NotEq => "!=",
2491        Token::Bang => "!",
2492    }
2493    .map(|s| Arg::Positional(Expr::Literal(Value::String(s.to_string()))))
2494}
2495
2496/// Argument parser for arguments before `--` (normal flag handling).
2497fn arg_before_double_dash_parser<'tokens, I>(
2498) -> impl Parser<'tokens, I, Arg, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2499where
2500    I: ValueInput<'tokens, Token = Token, Span = Span>,
2501{
2502    // Long flag with value: --name=value
2503    let long_flag_with_value = select! {
2504        Token::LongFlag(name) => name,
2505    }
2506    .then_ignore(just(Token::Eq))
2507    .then(primary_expr_parser())
2508    .map(|(key, value)| Arg::Named { key, value });
2509
2510    // Boolean long flag: --name
2511    let long_flag = select! {
2512        Token::LongFlag(name) => Arg::LongFlag(name),
2513    };
2514
2515    // Boolean short flag: -x
2516    let short_flag = select! {
2517        Token::ShortFlag(name) => Arg::ShortFlag(name),
2518    };
2519
2520    // Shell assignment in argv position: name=value (must not have spaces around =).
2521    let named = word_assign_arg_parser();
2522
2523    // Positional argument
2524    let positional = primary_expr_parser().map(Arg::Positional);
2525
2526    // The `test`/`[` operators (`=` `==` `!=` `!`) as literal positionals.
2527    // After the flag/`named` productions (so glued `name=value` stays a
2528    // WordAssign), before `positional` (which can't parse these tokens).
2529    let test_operator = test_operator_arg_parser();
2530
2531    // Order matters: try more specific patterns first
2532    // Note: DoubleDash is NOT included here - it's handled by args_list_parser
2533    choice((
2534        long_flag_with_value,
2535        long_flag,
2536        short_flag,
2537        named,
2538        test_operator,
2539        positional,
2540    ))
2541    .boxed()
2542}
2543
2544/// Redirect: `> file`, `>> file`, `< file`, `<< heredoc`, `2> file`, `&> file`, `2>&1`
2545///
2546/// `target` parses the file word (and here-string body); the sole caller
2547/// (`command_parser`) passes a fresh `primary_expr_parser()`. `target` stays
2548/// a generic parameter rather than calling `primary_expr_parser()` directly
2549/// here for history, not necessity: `cmd_subst_parser` used to pass its own
2550/// recursive `expr` handle so a redirect inside `$(...)` could parse without
2551/// an unbounded `cmd_subst → redirect → primary_expr → cmd_subst` construction
2552/// cycle. Route C (GH #194) replaced that hand-rolled grammar with a
2553/// recursive descent through the full program grammar at parse time, so
2554/// `cmd_subst_parser` no longer calls this function at all — the cycle this
2555/// threading avoided no longer exists here, but the shape was left as-is
2556/// since a second caller could reintroduce the same need.
2557fn redirect_parser<'tokens, I, T>(
2558    target: T,
2559) -> impl Parser<'tokens, I, Redirect, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2560where
2561    I: ValueInput<'tokens, Token = Token, Span = Span>,
2562    T: Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
2563{
2564    // `target` only ever parses ONE expression. An unquoted target that
2565    // spans multiple lexical fragments with no gap between them
2566    // (`/tmp/$(echo x).txt` lexes as three tokens: "/tmp/", the command
2567    // substitution, ".txt") only binds its first fragment as the target —
2568    // the rest dangle, and the surrounding statement grammar rejects them
2569    // with a generic chumsky "expected ..." message that never mentions
2570    // quoting (GH #189). Peek (`rewind`, consumes nothing) for an
2571    // immediately-adjacent further expr fragment and turn that into the same
2572    // "quote it" hint `reject_glued_args` gives positional args, worded for a
2573    // redirect target. The peek reuses the caller's own `target` clone
2574    // (never a fresh `primary_expr_parser()` built here) — see the
2575    // construction-cycle note in this function's doc comment above.
2576    let target = target
2577        .clone()
2578        .map_with(|expr, e| -> (Expr, Span) { (expr, e.span()) })
2579        .then(target.clone().map_with(|_, e| e.span()).rewind().or_not())
2580        .try_map(|((expr, span), glued), _| match glued {
2581            Some(next_span) if next_span.start == span.end => Err(Rich::custom(
2582                next_span,
2583                "adjacent words with no space between them are not joined into the redirect \
2584                 target (kaish does no token pasting); quote the whole target, e.g. \
2585                 \"/tmp/$(echo x).txt\"",
2586            )),
2587            _ => Ok(expr),
2588        })
2589        .boxed();
2590
2591    // Regular redirects: >, >>, <, 2>, &>
2592    let regular_redirect = select! {
2593        Token::GtGt => RedirectKind::StdoutAppend,
2594        Token::Gt => RedirectKind::StdoutOverwrite,
2595        Token::Lt => RedirectKind::Stdin,
2596        Token::Stderr => RedirectKind::Stderr,
2597        Token::Both => RedirectKind::Both,
2598    }
2599    .then(target.clone())
2600    .map(|(kind, target)| Redirect { kind, target });
2601
2602    // Here-doc redirect: << content
2603    // Quoted delimiters (<<'EOF' or <<"EOF") produce literal heredocs (no expansion).
2604    // Unquoted delimiters produce interpolated heredocs (variables are expanded).
2605    // For literal heredocs the `<<-EOF` tab stripping is applied here at parse
2606    // time (the body is fully known); for interpolated heredocs the stripping
2607    // is deferred to the interpreter so source byte offsets in `parts` stay
2608    // aligned with the original source for span reporting.
2609    let heredoc_redirect = just(Token::HereDocStart)
2610        .ignore_then(select! { Token::HereDoc(data) => data })
2611        .try_map(|data: HereDocData, span| {
2612            // How it was written, kept for the plan. The target below is what
2613            // executes: tab-stripped, or split into interpolation parts.
2614            let meta = HereDocMeta {
2615                delimiter: data.delimiter.clone(),
2616                literal: data.literal,
2617                strip_tabs: data.strip_tabs,
2618                body: data.source_body.clone(),
2619                body_offset: data.body_start_offset,
2620            };
2621            let target = if data.literal {
2622                let body = if data.strip_tabs {
2623                    crate::interpreter::strip_leading_tabs(&data.content)
2624                } else {
2625                    data.content
2626                };
2627                Expr::Literal(Value::String(body))
2628            } else {
2629                let parts =
2630                    parse_interpolated_string_spanned(&data.content, data.body_start_offset)
2631                        .map_err(|msg| Rich::custom(span, msg))?;
2632                // If there's only one literal part and no tab stripping is
2633                // needed, simplify to Expr::Literal — keeps the AST shape
2634                // identical to the pre-spans path for trivial bodies.
2635                if parts.len() == 1 && !data.strip_tabs {
2636                    if let StringPart::Literal(text) = &parts[0].part {
2637                        return Ok(Redirect {
2638                            kind: RedirectKind::HereDoc(meta),
2639                            target: Expr::Literal(Value::String(text.clone())),
2640                        });
2641                    }
2642                }
2643                Expr::HereDocBody {
2644                    parts,
2645                    strip_tabs: data.strip_tabs,
2646                }
2647            };
2648            Ok(Redirect {
2649                kind: RedirectKind::HereDoc(meta),
2650                target,
2651            })
2652        });
2653
2654    // Here-string redirect: <<< word
2655    // The target is any single expression; kaish's existing Expr machinery
2656    // handles interpolation, single-quoted literals, and command substitution.
2657    let herestring_redirect = just(Token::HereString)
2658        .ignore_then(target.clone())
2659        .map(|target| Redirect {
2660            kind: RedirectKind::HereString,
2661            target,
2662        });
2663
2664    // Merge stderr to stdout: 2>&1 (no target needed - implicit)
2665    let merge_stderr_redirect = just(Token::StderrToStdout)
2666        .map(|_| Redirect {
2667            kind: RedirectKind::MergeStderr,
2668            // Target is unused for MergeStderr, but we need something
2669            target: Expr::Literal(Value::Null),
2670        });
2671
2672    // Merge stdout to stderr: 1>&2 or >&2 (no target needed - implicit)
2673    let merge_stdout_redirect = choice((
2674        just(Token::StdoutToStderr),
2675        just(Token::StdoutToStderr2),
2676    ))
2677    .map(|_| Redirect {
2678        kind: RedirectKind::MergeStdout,
2679        // Target is unused for MergeStdout, but we need something
2680        target: Expr::Literal(Value::Null),
2681    });
2682
2683    choice((
2684        heredoc_redirect,
2685        herestring_redirect,
2686        merge_stderr_redirect,
2687        merge_stdout_redirect,
2688        regular_redirect,
2689    ))
2690    .labelled("redirect")
2691    .boxed()
2692}
2693
2694/// Test expression parser for `[[ ... ]]` syntax.
2695///
2696/// Supports:
2697/// - File tests: `[[ -f path ]]`, `[[ -d path ]]`, etc.
2698/// - String tests: `[[ -z str ]]`, `[[ -n str ]]`
2699/// - Shape-guard tests: `[[ -list x ]]`, `[[ -record x ]]` (see
2700///   `docs/LANGUAGE.md`, "Shape guards")
2701/// - Comparisons: `[[ $X == "value" ]]`, `[[ $NUM -gt 5 ]]`
2702/// - Compound: `[[ -f a && -d b ]]`, `[[ -z x || -n y ]]`, `[[ ! -f file ]]`
2703///
2704/// `(( expr ))` — the sibling of `test_expr_stmt_parser` for a bare
2705/// arithmetic condition. The lexer already extracted the text between the
2706/// two `(` and the two `)` into one `ArithCond` token (mirroring `$((`'s
2707/// `Arithmetic` token); this parser only unwraps it.
2708fn arith_cond_parser<'tokens, I>(
2709) -> impl Parser<'tokens, I, String, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2710where
2711    I: ValueInput<'tokens, Token = Token, Span = Span>,
2712{
2713    select! { Token::ArithCond(expr) => expr }.labelled("arithmetic condition")
2714}
2715
2716/// Precedence (highest to lowest): `!` > `&&` > `||`
2717fn test_expr_stmt_parser<'tokens, I>(
2718) -> impl Parser<'tokens, I, TestExpr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2719where
2720    I: ValueInput<'tokens, Token = Token, Span = Span>,
2721{
2722    // File test operators: -e, -f, -d, -r, -w, -x, -L (alias -h)
2723    let file_test_op = select! {
2724        Token::ShortFlag(s) if s == "e" => FileTestOp::Exists,
2725        Token::ShortFlag(s) if s == "f" => FileTestOp::IsFile,
2726        Token::ShortFlag(s) if s == "d" => FileTestOp::IsDir,
2727        Token::ShortFlag(s) if s == "r" => FileTestOp::Readable,
2728        Token::ShortFlag(s) if s == "w" => FileTestOp::Writable,
2729        Token::ShortFlag(s) if s == "x" => FileTestOp::Executable,
2730        Token::ShortFlag(s) if s == "L" => FileTestOp::IsSymlink,
2731        Token::ShortFlag(s) if s == "h" => FileTestOp::IsSymlink,
2732    };
2733
2734    // String test operators: -z, -n, plus the shape-guard operators -list /
2735    // -record (value-typed tests, not path stats — same operand-evaluation
2736    // path as -z/-n, unlike the file_test_op family above).
2737    let string_test_op = select! {
2738        Token::ShortFlag(s) if s == "z" => StringTestOp::IsEmpty,
2739        Token::ShortFlag(s) if s == "n" => StringTestOp::IsNonEmpty,
2740        Token::ShortFlag(s) if s == "list" => StringTestOp::IsList,
2741        Token::ShortFlag(s) if s == "record" => StringTestOp::IsRecord,
2742    };
2743
2744    // Comparison operators: =, ==, !=, =~, !~, >, <, >=, <=, -gt, -lt, -ge, -le, -eq, -ne
2745    // Note: = and == are equivalent inside [[ ]] (matching bash behavior)
2746    let cmp_op = choice((
2747        just(Token::EqEq).to(TestCmpOp::Eq),
2748        just(Token::Eq).to(TestCmpOp::Eq),
2749        just(Token::NotEq).to(TestCmpOp::NotEq),
2750        just(Token::Match).to(TestCmpOp::Match),
2751        just(Token::NotMatch).to(TestCmpOp::NotMatch),
2752        just(Token::Gt).to(TestCmpOp::Gt),
2753        just(Token::Lt).to(TestCmpOp::Lt),
2754        just(Token::GtEq).to(TestCmpOp::GtEq),
2755        just(Token::LtEq).to(TestCmpOp::LtEq),
2756        select! { Token::ShortFlag(s) if s == "eq" => TestCmpOp::NumEq },
2757        select! { Token::ShortFlag(s) if s == "ne" => TestCmpOp::NumNotEq },
2758        select! { Token::ShortFlag(s) if s == "gt" => TestCmpOp::NumGt },
2759        select! { Token::ShortFlag(s) if s == "lt" => TestCmpOp::NumLt },
2760        select! { Token::ShortFlag(s) if s == "ge" => TestCmpOp::NumGtEq },
2761        select! { Token::ShortFlag(s) if s == "le" => TestCmpOp::NumLtEq },
2762    ));
2763
2764    // File test: -f path
2765    let file_test = file_test_op
2766        .then(primary_expr_parser())
2767        .map(|(op, path)| TestExpr::FileTest {
2768            op,
2769            path: Box::new(path),
2770        });
2771
2772    // String test: -z str
2773    let string_test = string_test_op
2774        .then(primary_expr_parser())
2775        .map(|(op, value)| TestExpr::StringTest {
2776            op,
2777            value: Box::new(value),
2778        });
2779
2780    // Comparison: $X == "value" or $NUM -gt 5
2781    let comparison = primary_expr_parser()
2782        .then(cmp_op)
2783        .then(primary_expr_parser())
2784        .map(|((left, op), right)| TestExpr::Comparison {
2785            left: Box::new(left),
2786            op,
2787            right: Box::new(right),
2788        });
2789
2790    // Collection membership: `e in $coll` / `e not in $coll` (element-in-list,
2791    // key-in-record; see docs/LANGUAGE.md, "Membership"). There is no dedicated
2792    // `not` token — it lexes as a plain identifier, so `not_in` matches the
2793    // two-word sequence `Ident("not") In`. Try `not_in` before `in` below, or
2794    // `e not in c` parses as `e in` and then fails on the stray `not` bareword.
2795    let not_in = primary_expr_parser()
2796        .then_ignore(select! { Token::Ident(s) if s == "not" => () })
2797        .then_ignore(just(Token::In))
2798        .then(value_primary_parser())
2799        .map(|(left, right)| TestExpr::NotIn {
2800            left: Box::new(left),
2801            right: Box::new(right),
2802        });
2803
2804    let in_ = primary_expr_parser()
2805        .then_ignore(just(Token::In))
2806        .then(value_primary_parser())
2807        .map(|(left, right)| TestExpr::In {
2808            left: Box::new(left),
2809            right: Box::new(right),
2810        });
2811
2812    // Primary test expression (atomic - no compound operators)
2813    let primary_test = choice((file_test, string_test, not_in, in_, comparison));
2814
2815    // Build compound expressions with proper precedence:
2816    // Grammar:
2817    //   test_expr = or_expr
2818    //   or_expr   = and_expr { "||" and_expr }
2819    //   and_expr  = unary_expr { "&&" unary_expr }
2820    //   unary_expr = "!" unary_expr | primary_test
2821    //
2822    // Precedence: ! (highest) > && > ||
2823
2824    // Unary NOT binds tighter than `&&`/`||`, so it must recurse at the
2825    // unary level — `! A || B` is `(!A) || B`, NOT `!(A || B)`. The inner
2826    // `recursive` lets `!` chain (`! ! expr`) while bottoming out at a
2827    // primary test, so the bang never swallows a following `&&`/`||` operand.
2828    let unary = recursive(|unary| {
2829        let not_expr = just(Token::Bang)
2830            .ignore_then(unary)
2831            .map(|expr| TestExpr::Not { expr: Box::new(expr) });
2832        choice((not_expr, primary_test.clone()))
2833    });
2834
2835    // AND level: unary && unary && ...
2836    let and_expr = unary.clone().foldl(
2837        just(Token::And).ignore_then(unary).repeated(),
2838        |left, right| TestExpr::And {
2839            left: Box::new(left),
2840            right: Box::new(right),
2841        },
2842    );
2843
2844    // OR level: and_expr || and_expr || ...
2845    let compound_test = and_expr.clone().foldl(
2846        just(Token::Or).ignore_then(and_expr).repeated(),
2847        |left, right| TestExpr::Or {
2848            left: Box::new(left),
2849            right: Box::new(right),
2850        },
2851    );
2852
2853    // [[ ]] is two consecutive bracket tokens (not a single TestStart token)
2854    // to avoid conflicts with nested array syntax like [[1, 2], [3, 4]]
2855    just(Token::LBracket)
2856        .then(just(Token::LBracket))
2857        .ignore_then(compound_test)
2858        .then_ignore(just(Token::RBracket).then(just(Token::RBracket)))
2859        .labelled("test expression")
2860        .boxed()
2861}
2862
2863/// Condition parser: supports [[ ]] test expressions and commands with && / || chaining.
2864///
2865/// Shell semantics: conditions are commands whose exit codes determine truthiness.
2866/// - `if true; then` → runs `true` builtin, exit code 0 = truthy
2867/// - `if grep -q pattern file; then` → runs command, checks exit code
2868/// - `if a && b; then` → runs `a`, if exit 0, runs `b`
2869///
2870/// Use `[[ ]]` for comparisons: `if [[ $X -gt 5 ]]; then`
2871///
2872/// Grammar (with precedence - && binds tighter than ||):
2873///   condition = or_expr
2874///   or_expr   = and_expr { "||" and_expr }
2875///   and_expr  = base { "&&" base }
2876///   base      = test_expr | command
2877fn condition_parser<'tokens, I>(
2878) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2879where
2880    I: ValueInput<'tokens, Token = Token, Span = Span>,
2881{
2882    // [[ ]] test expression - wrap as Expr::Test
2883    let test_expr_condition = test_expr_stmt_parser().map(|test| Expr::Test(Box::new(test)));
2884
2885    // (( expr )) arithmetic condition - the sibling of [[ ]] above.
2886    let arith_condition = arith_cond_parser().map(Expr::Arith);
2887
2888    // Command as condition (includes true/false/: as command names)
2889    // The command's exit code determines truthiness (0 = true, non-zero = false)
2890    let command_condition = command_parser().map(Expr::Command);
2891
2892    // Base: test expr OR arithmetic condition OR command
2893    let base = choice((test_expr_condition, arith_condition, command_condition));
2894
2895    // `!` negates the command that follows it, BEFORE `&&`/`||` fold below —
2896    // bash reads `! true && true` as `(! true) && true`. Repeated so `! ! x`
2897    // parses, which bash also accepts.
2898    let base = just(Token::Bang)
2899        .repeated()
2900        .foldr(base, |_, inner| Expr::Not(Box::new(inner)));
2901
2902    // && has higher precedence than ||
2903    // First chain with && (higher precedence)
2904    let and_expr = base.clone().foldl(
2905        just(Token::And).ignore_then(base).repeated(),
2906        |left, right| Expr::BinaryOp {
2907            left: Box::new(left),
2908            op: BinaryOp::And,
2909            right: Box::new(right),
2910        },
2911    );
2912
2913    // Then chain with || (lower precedence)
2914    and_expr
2915        .clone()
2916        .foldl(
2917            just(Token::Or).ignore_then(and_expr).repeated(),
2918            |left, right| Expr::BinaryOp {
2919                left: Box::new(left),
2920                op: BinaryOp::Or,
2921                right: Box::new(right),
2922            },
2923        )
2924        .labelled("condition")
2925        .boxed()
2926}
2927
2928/// Expression parser - supports && and || binary operators.
2929///
2930/// Used by `for`-head items (among others), which must stay `$()`-only
2931/// (bare `$VAR` splice is rejected upstream by validator E012 — see
2932/// docs/LANGUAGE.md) and must NOT gain collection literals later. Do not
2933/// reroute this to the value seam.
2934fn expr_parser<'tokens, I>(
2935) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2936where
2937    I: ValueInput<'tokens, Token = Token, Span = Span>,
2938{
2939    // For now, just primary expressions. Can extend for && / || later if needed.
2940    primary_expr_parser()
2941}
2942
2943/// Value-position expression parser (assignment RHS: bash-style, `local`,
2944/// and env-prefix). Adds collection literals on top of everything
2945/// `primary_expr_parser` covers, so they appear on assignment RHS but never
2946/// in argv or `for`-head items (`expr_parser`, above, stays untouched).
2947fn value_expr_parser<'tokens, I>(
2948) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2949where
2950    I: ValueInput<'tokens, Token = Token, Span = Span>,
2951{
2952    value_literal_parser()
2953}
2954
2955/// Value-position primary parser (`in`/`not in` RHS operand only — the
2956/// collection being tested for membership; the left needle stays on
2957/// `primary_expr_parser`). Same grammar as `value_expr_parser`; kept as a
2958/// separate name because the two seams are conceptually distinct call sites
2959/// (see PR-A) even though they currently share an implementation.
2960fn value_primary_parser<'tokens, I>(
2961) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2962where
2963    I: ValueInput<'tokens, Token = Token, Span = Span>,
2964{
2965    value_literal_parser()
2966}
2967
2968/// The value-position grammar: list/record literals (tried first, so a
2969/// `[`/`{` at value position is always a literal — never a bareword/glob),
2970/// falling back to everything `primary_expr_parser` covers ($(), `$VAR`,
2971/// scalars, …). `recursive` lets literal interiors reference this same
2972/// grammar, so nesting (`{tags: [a b], meta: {active: true}}`) and spread
2973/// (`[...$xs date]`) both parse.
2974///
2975/// The lexer guarantees a `[`/`{` reaching here at value position was never
2976/// fused into a `GlobWord`/colon-joined `Ident` (see
2977/// `lexer::compute_value_context`), so this choice never needs to "unfuse"
2978/// anything — it just sees primitive bracket/brace tokens.
2979fn value_literal_parser<'tokens, I>(
2980) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
2981where
2982    I: ValueInput<'tokens, Token = Token, Span = Span>,
2983{
2984    recursive(|value| {
2985        choice((
2986            list_literal_parser(value.clone()),
2987            record_literal_parser(value.clone()),
2988            primary_expr_parser(),
2989        ))
2990    })
2991    .boxed()
2992}
2993
2994/// List literal: `[a b c]`, `[]`, `[...$xs date]`. Elements may be separated
2995/// by whitespace alone, commas, newlines, or any mix — all optional and
2996/// interchangeable (see docs/LANGUAGE.md, "Construction — list/record
2997/// literals"). Newlines are consumed rather than treated as statement
2998/// terminators, so a multi-line literal does not end the assignment early.
2999/// A bare element nests as ONE item; `...` flattens a list operand's elements
3000/// into this one (spread).
3001fn list_literal_parser<'tokens, I, V>(
3002    value: V,
3003) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
3004where
3005    I: ValueInput<'tokens, Token = Token, Span = Span>,
3006    V: Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
3007{
3008    let spread_elem = just(Token::DotDotDot)
3009        .ignore_then(value.clone())
3010        .map(ListElem::Spread);
3011    let item_elem = value.map(ListElem::Item);
3012    let elem = choice((spread_elem, item_elem));
3013
3014    let sep = choice((just(Token::Comma).to(()), just(Token::Newline).to(()))).repeated();
3015
3016    just(Token::LBracket)
3017        .ignore_then(just(Token::Newline).repeated())
3018        .ignore_then(elem.then_ignore(sep).repeated().collect::<Vec<_>>())
3019        .then_ignore(just(Token::RBracket))
3020        .map(Expr::ListLiteral)
3021        .labelled("list literal")
3022}
3023
3024/// Record literal: `{name: amy, role: maintainer}`, `{port:8080}` (the
3025/// colon-fusion exemption in the lexer means both spellings reach here as
3026/// the same three tokens). Keys are a bareword (`Ident`) or a quoted string
3027/// (for anything that isn't a bareword, e.g. `{"content-type": x}`); values
3028/// are the full recursive value grammar, so nested literals work. Entries
3029/// separate the same way list elements do (comma/newline/whitespace, all
3030/// optional) — including multi-line literals with a trailing comma.
3031fn record_literal_parser<'tokens, I, V>(
3032    value: V,
3033) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
3034where
3035    I: ValueInput<'tokens, Token = Token, Span = Span>,
3036    V: Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone + 'tokens,
3037{
3038    let bare_key = select! { Token::Ident(s) => RecordKey::Bare(s) };
3039    // A double-quoted key interpolates like any double-quoted string ({"$k": v}
3040    // resolves $k at eval time — it used to silently create a literal "$k"
3041    // key); a pure-literal result folds back to Quoted so the common case
3042    // carries no eval overhead. Single quotes stay verbatim — the escape hatch
3043    // for a literal `$` in a key.
3044    let double_key = select! { Token::String(s) => s }.try_map(|s, span| {
3045        let parts = parse_interpolated_string(&s)
3046            .map_err(|e| Rich::custom(span, format!("record key: {e}")))?;
3047        Ok(match parts.as_slice() {
3048            [] => RecordKey::Quoted(String::new()),
3049            [StringPart::Literal(lit)] => RecordKey::Quoted(lit.clone()),
3050            _ => RecordKey::Interpolated(parts),
3051        })
3052    });
3053    let single_key = select! { Token::SingleString(s) => RecordKey::Quoted(s) };
3054    let key = choice((double_key, single_key, bare_key)).labelled("record key");
3055
3056    // Guard against the classic unquoted multi-word value mistake
3057    // (`{msg: hello world}`): without this, "world" is consumed by the
3058    // NEXT `entry` attempt as a candidate key (kaish allows a bare
3059    // space — no comma — between entries, so `{a: 1 b: 2}` is legal), which
3060    // then fails at `}` expecting `:` — chumsky's generic message ("found
3061    // '}' expected ':'") without ever naming the actual mistake. Peeked via
3062    // `.rewind()` (consumes nothing — a legitimate following entry, comma
3063    // or not, is still parsed normally by the outer `repeated()`): an
3064    // `Ident` right after this value that ISN'T itself followed by `:` can
3065    // only be a stray unquoted word, since a real next entry always looks
3066    // like `Ident :` (or a quoted key) at this position.
3067    let stray_bareword_after_value = select! { Token::Ident(s) => s }
3068        .then(just(Token::Colon).or_not())
3069        .rewind()
3070        .or_not()
3071        .try_map(|maybe, span| match maybe {
3072            Some((word, None)) => Err(Rich::custom(
3073                span,
3074                format!(
3075                    "record value: unexpected word \"{word}\" after the value — a multi-word \
3076                     value must be quoted, e.g. {{key: \"hello world\"}}"
3077                ),
3078            )),
3079            _ => Ok(()),
3080        });
3081
3082    let entry = key
3083        .then_ignore(just(Token::Colon))
3084        .then(value)
3085        .then_ignore(stray_bareword_after_value)
3086        .map(|(key, value)| RecordEntry { key, value });
3087
3088    let sep = choice((just(Token::Comma).to(()), just(Token::Newline).to(()))).repeated();
3089
3090    just(Token::LBrace)
3091        .ignore_then(just(Token::Newline).repeated())
3092        .ignore_then(entry.then_ignore(sep).repeated().collect::<Vec<_>>())
3093        .then_ignore(just(Token::RBrace))
3094        .map(Expr::RecordLiteral)
3095        .labelled("record literal")
3096}
3097
3098/// Primary expression: literal, variable reference, command substitution, or bare identifier.
3099///
3100/// Uses `recursive` to support nested command substitution like `$(echo $(date))`.
3101fn primary_expr_parser<'tokens, I>(
3102) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
3103where
3104    I: ValueInput<'tokens, Token = Token, Span = Span>,
3105{
3106    // Positional parameters: $0-$9, $@, $#, ${#VAR}, $?, $$
3107    let positional = select! {
3108        Token::Positional(n) => Expr::Positional(n),
3109        Token::AllArgs => Expr::AllArgs,
3110        Token::ArgCount => Expr::ArgCount,
3111        Token::VarLength(name) => Expr::VarLength(parse_varpath(&format!("${{{name}}}"))),
3112        Token::LastExitCode => Expr::LastExitCode,
3113        Token::CurrentPid => Expr::CurrentPid,
3114    };
3115
3116    // Arithmetic expression: $((expr)) - preprocessed into Arithmetic token
3117    let arithmetic = select! {
3118        Token::Arithmetic(expr_str) => Expr::Arithmetic(expr_str),
3119    };
3120
3121    // Keywords that can also be used as barewords in argument position
3122    // (e.g., `echo done` should work even though `done` is a keyword)
3123    let keyword_as_bareword = select! {
3124        Token::Done => "done",
3125        Token::Fi => "fi",
3126        Token::Then => "then",
3127        Token::Else => "else",
3128        Token::Elif => "elif",
3129        Token::In => "in",
3130        Token::Do => "do",
3131        Token::Esac => "esac",
3132        // `set` in argument position is the literal word (`echo set`,
3133        // `kaish-output-limit set 1K`); the `set` *builtin* is only matched
3134        // when `Token::Set` leads a statement (see `set_command`), so this
3135        // arm never shadows it.
3136        Token::Set => "set",
3137    }
3138    .map(|s| Expr::Literal(Value::String(s.to_string())));
3139
3140    // Bare words starting with + or - (e.g., date +%s, cat -), and a
3141    // `--`-prefixed word that isn't a valid long flag (`echo ---`,
3142    // `echo --=x`, GH #137).
3143    let plus_minus_bare = select! {
3144        Token::PlusBare(s) => Expr::Literal(Value::String(s)),
3145        Token::MinusBare(s) => Expr::Literal(Value::String(s)),
3146        Token::MinusAlone => Expr::Literal(Value::String("-".to_string())),
3147        Token::DoubleDashBare(s) => Expr::Literal(Value::String(s)),
3148    };
3149
3150    // Glob patterns: merged GlobWord tokens and bare Star/Question
3151    let glob_pattern = select! {
3152        Token::GlobWord(s) => Expr::GlobPattern(s),
3153        Token::Star => Expr::GlobPattern("*".to_string()),
3154        Token::Question => Expr::GlobPattern("?".to_string()),
3155    };
3156
3157    // No longer `recursive()`: `cmd_subst_parser` used to need this closure's
3158    // own `expr` handle to parse `$(...)`'s body and redirect targets, which
3159    // is what created the `cmd_subst → primary_expr → cmd_subst` construction
3160    // cycle (see `cmd_subst_parser`'s doc comment). Route C (GH #194) parses
3161    // the `$(...)` body from raw captured tokens instead, so nothing in this
3162    // choice references itself anymore.
3163    choice((
3164        positional,
3165        arithmetic,
3166        cmd_subst_parser(),
3167        var_expr_parser(),
3168        interpolated_string_parser(),
3169        literal_parser().map(Expr::Literal),
3170        numeric_literal_parser(),
3171        // Glob patterns before ident (GlobWord is more specific)
3172        glob_pattern,
3173        // Bare identifiers become string literals (shell barewords)
3174        ident_parser().map(|s| Expr::Literal(Value::String(s))),
3175        // Absolute paths become string literals
3176        path_parser().map(|s| Expr::Literal(Value::String(s))),
3177        // Bare words starting with + or - (date +%s, cat -)
3178        // Shell navigation tokens
3179        select! {
3180            // Bare `.` in argument/expression position is the literal
3181            // current-directory path (`find .`, `ls .`, `echo .`). The
3182            // `source` alias is unaffected: `command_parser` consumes a
3183            // *leading* `.` as the command name before args are parsed,
3184            // so only a `.` that follows a command reaches here.
3185            Token::Dot => Expr::Literal(Value::String(".".into())),
3186            Token::DotDot => Expr::Literal(Value::String("..".into())),
3187            // Bare comma in argument position is the literal "," — the
3188            // `cut -d, -f2` / `tr -d ,` delimiter idiom. This is reached
3189            // only by a comma with no adjacent bareword to fold into
3190            // (whitespace on both sides, e.g. `cut -d , -f2`, or a
3191            // neighbor the lexer doesn't fuse across, e.g. `,$VAR`) — a
3192            // comma glued to a bareword (`echo a,b`, `sort -k 2,2n`) is
3193            // already folded into ONE token before the parser runs (see
3194            // `lexer::flush_glob_run`), and a comma inside a
3195            // `[...]`/`{...}` literal or pattern is consumed there
3196            // instead (list/record literals, brace expansion — see
3197            // `docs/LANGUAGE.md`, "Construction").
3198            Token::Comma => Expr::Literal(Value::String(",".into())),
3199            // Bare colon in argument position is the literal ":" — the
3200            // `awk -F: '{print $1}'` / `--field-separator=:` idiom. In
3201            // command-name position the colon is the null command (see
3202            // `command_name` in `command_parser`); here it is only reached
3203            // after a command name has been parsed, so there is no
3204            // ambiguity with that form.
3205            Token::Colon => Expr::Literal(Value::String(":".into())),
3206            Token::Tilde => Expr::Literal(Value::String("~".into())),
3207            Token::TildePath(s) => Expr::Literal(Value::String(s)),
3208            Token::RelativePath(s) => Expr::Literal(Value::String(s)),
3209            Token::DotSlashPath(s) => Expr::Literal(Value::String(s)),
3210            // Digit-leading bareword (SHA prefix `019dda1c`, UUIDs).
3211            Token::NumberIdent(s) => Expr::Literal(Value::String(s)),
3212            // Hyphenated/minus-led numeric word (`2024-01-02`, `10-20`,
3213            // `1.5-2`, `cut -f 1-3`, `find -size -1k`) — one contiguous word.
3214            Token::DashNumWord(s) => Expr::Literal(Value::String(s)),
3215            // Leading-`@` bareword (`@scope/pkg`, `@0`, bare `@`).
3216            Token::AtWord(s) => Expr::Literal(Value::String(s)),
3217            // Dot-prefixed bareword (`.gitignore`, `.parent`, `.parent.parent`).
3218            // Distinct from `Token::Dot` (the source alias), which only
3219            // matches a bare `.` and requires whitespace before its file
3220            // argument.
3221            Token::DottedIdent(s) => Expr::Literal(Value::String(s)),
3222            // Job specifier `%1` for wait/kill — flows as the literal
3223            // string "%1"; the builtins interpret the leading `%`.
3224            Token::JobSpec(s) => Expr::Literal(Value::String(s)),
3225        },
3226        plus_minus_bare,
3227        // Keywords can be used as barewords in argument position
3228        keyword_as_bareword,
3229    ))
3230    .labelled("expression")
3231    .boxed()
3232}
3233
3234/// Variable reference: `${VAR}`, `${VAR.field}`, `${VAR:-default}`, or `$VAR` (simple form).
3235/// Returns Expr directly to support both VarRef and VarWithDefault.
3236fn var_expr_parser<'tokens, I>(
3237) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
3238where
3239    I: ValueInput<'tokens, Token = Token, Span = Span>,
3240{
3241    choice((
3242        select! { Token::VarRef(raw) => raw }.try_map(|raw, span| {
3243            // The unquoted twin of the check in `parse_interpolated_string`:
3244            // bash's `${v:0:5}` is a different slice convention here, and used
3245            // to expand to nothing at all.
3246            let inner = raw
3247                .strip_prefix("${")
3248                .and_then(|s| s.strip_suffix('}'))
3249                .unwrap_or(&raw);
3250            if !raw.starts_with("${?}")
3251                && !raw.starts_with("${$}")
3252                && find_default_separator(&raw).is_none()
3253                && let Some(msg) = bash_substring_hint(inner)
3254            {
3255                return Err(Rich::custom(span, msg));
3256            }
3257            // `${x:-WORD}`'s default word expands like a double-quoted string,
3258            // and `parse_var_expr` returns an `Expr` with nowhere to put a
3259            // failure — so a malformed `$(` inside the word was kept as
3260            // literal text and the whole statement ran. Checked here, at the
3261            // grammar, rather than on the token stream: a nested default word
3262            // (`$(echo ${x:-$(echo hi})`) is a `VarRef` at whatever depth it
3263            // occurs, so this one rule reaches every nesting.
3264            if let Some(colon) = find_default_separator(&raw)
3265                && raw.len() > colon + 3
3266                && let Err(msg) =
3267                    parse_interpolated_string(&unquote_default_word(&raw[colon + 2..raw.len() - 1]))
3268            {
3269                return Err(Rich::custom(span, msg));
3270            }
3271            Ok(parse_var_expr(&raw))
3272        }),
3273        select! { Token::SimpleVarRef(name) => Expr::VarRef(VarPath::simple(name)) },
3274    ))
3275    .labelled("variable reference")
3276}
3277
3278/// Capture the token stream inside `$(...)`, consuming through the matching
3279/// closing `)`.
3280///
3281/// A `)` can close a nested `$(`, a plain `(`, or be a case-branch pattern
3282/// terminator with no matching open at all (`case $x in a) … ;; esac` is
3283/// legal with no leading `(`). Which one a given `)` means depends on what
3284/// is innermost at that point, so this is a stack of [`CmdSubstFrame`]s, not
3285/// a flat counter — see that type's doc comment for the rule.
3286///
3287/// Token spans are untouched — they stay the lexer's absolute byte offsets
3288/// into the original source. That is what lets `cmd_subst_parser` hand the
3289/// captured slice straight to [`parse_tokens`] and get diagnostics anchored
3290/// at their true position with no span-rebasing.
3291///
3292/// Returns the body tokens (the closing `)` is not included) and that `)`'s
3293/// own span, which the caller uses as the sub-parse's end-of-input point.
3294/// A `$(...)` body's captured tokens (the closing `)` not included) and that
3295/// `)`'s own span. Named so [`cmd_subst_body_tokens`]'s return type reads —
3296/// clippy's `type_complexity` flags the bare tuple spelled out inline.
3297type CmdSubstBody = (Vec<(Token, Span)>, Span);
3298
3299fn cmd_subst_body_tokens<'tokens, I>(
3300) -> impl Parser<'tokens, I, CmdSubstBody, extra::Err<Rich<'tokens, Token, Span>>> + Clone
3301where
3302    I: ValueInput<'tokens, Token = Token, Span = Span>,
3303{
3304    custom(|inp| {
3305        let mut tracker = CmdSubstFrames::default();
3306        let mut body: Vec<(Token, Span)> = Vec::new();
3307        loop {
3308            let before = inp.cursor();
3309            match inp.next() {
3310                None => {
3311                    let span = inp.span_since(&before);
3312                    return Err(Rich::custom(
3313                        span,
3314                        "unterminated command substitution: missing `)`",
3315                    ));
3316                }
3317                Some(tok) => {
3318                    let span = inp.span_since(&before);
3319                    // `inp.peek()` now shows the token AFTER `tok` — `next()`
3320                    // already advanced the cursor past it — giving `step`
3321                    // its one-token lookahead without consuming anything.
3322                    let next = inp.peek();
3323                    if tracker.step(&tok, next.as_ref()) {
3324                        return Ok((body, span));
3325                    }
3326                    body.push((tok, span));
3327                }
3328            }
3329        }
3330    })
3331}
3332
3333/// One open construct on a [`CmdSubstFrames`] stack.
3334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3335enum CmdSubstFrame {
3336    /// Opened by a nested `$(` (`Token::CmdSubstStart`); closed by a `)`.
3337    Subst,
3338    /// Opened by a literal `(` (`Token::LParen`) — e.g. a parenthesized
3339    /// case-branch pattern `(a)`; closed by a `)`. A `case`-pattern's
3340    /// leading `(` is popped by [`CmdSubstFrames::step`]'s `RParen` arm,
3341    /// which also clears the `Case` frame directly beneath it out of
3342    /// `awaiting_pattern` — see that arm's comment.
3343    Paren,
3344    /// Opened by `Token::Case`, unless the very next token is `Token::Eq`
3345    /// (`case=x` is a `key=value` argv key spelled with the keyword, not a
3346    /// case-statement opener — see [`CmdSubstFrames::step`]'s `Case` arm).
3347    /// `awaiting_pattern` is `true` right after `case … in` and right after
3348    /// a `;;` — a branch pattern (or `esac`) is expected next — and `false`
3349    /// once a pattern's `)` has been consumed, for the rest of that
3350    /// branch's body. `Esac` closes this frame only while `awaiting_pattern`
3351    /// is `true`; see [`CmdSubstFrames`].
3352    Case { awaiting_pattern: bool },
3353}
3354
3355/// Tracks, one token at a time, the stack of open `$(`/`(`/`case` frames
3356/// while scanning a `$(...)` body for the `)` that closes it.
3357///
3358/// A flat depth counter can't tell a case-branch pattern's `)` apart from
3359/// one that really closes a nested `$(...)` or `(...)` once both are open
3360/// at once — `case $x in a) …` has no leading `(` at all, so its `)` has no
3361/// matching open on any counter, but a bare "depth > 0 decrements" rule
3362/// doesn't know that and consumes whatever counter happens to be nonzero.
3363/// Asking a stack instead — what's actually innermost right now — resolves
3364/// each `)` against the frame it belongs to:
3365///
3366/// - innermost is `Case` — the `)` is a branch pattern terminator; it's
3367///   part of the body and nothing pops (but `awaiting_pattern` flips to
3368///   `false` — see below).
3369/// - innermost is `Paren` — that frame closes; pop it. If a `Case` frame
3370///   awaiting its pattern is directly beneath, its `)` was also the
3371///   pattern's own closer (`(a)` is `a)` with an optional leading paren —
3372///   the same word either way), so clear that `Case`'s `awaiting_pattern`
3373///   too; otherwise this `Paren` closed something else (a POSIX function's
3374///   empty `()`, or a case-branch body's own paren once `awaiting_pattern`
3375///   is already `false`) and the frame beneath is untouched either way.
3376/// - innermost is `Subst` — that frame closes; pop it.
3377/// - stack empty — this is the substitution's own closing `)`; stop, and
3378///   the token is not part of the body.
3379///
3380/// `Esac` is ALSO the literal bareword `"esac"` in argument position
3381/// (`keyword_as_bareword`, same as `done`/`fi`), and that bareword can
3382/// appear anywhere inside a branch's own body (`case a in a) y=esac;;
3383/// b) …`) — a real, still-open case whose closer has not been reached yet.
3384/// Popping the `Case` frame on every `Esac` while it's innermost (rather
3385/// than only when one was never open) is not enough: it would treat that
3386/// bareword as the closer too, and then the branch's real `;;`/pattern/
3387/// `esac` tokens run with no `Case` frame protecting them, corrupting the
3388/// scan the same way the original flat counter did. `awaiting_pattern`
3389/// tracks the one thing that actually distinguishes them — position, not
3390/// spelling: `esac` closes only where a new pattern could otherwise start
3391/// (right after `case … in` or a `;;`); anywhere else in the body it's
3392/// just a word.
3393///
3394/// Shared by [`cmd_subst_body_tokens`] (the live chumsky capture that
3395/// actually bounds the body during a real parse) and
3396/// [`find_cmd_subst_close`] (a plain slice scan used only by
3397/// `validate_cmd_subst_bodies`'s error-path fallback) so the rule lives in
3398/// exactly one place.
3399#[derive(Default)]
3400struct CmdSubstFrames(Vec<CmdSubstFrame>);
3401
3402impl CmdSubstFrames {
3403    /// Feed one token, plus the token right after it (`None` at end of
3404    /// input) so a bareword `case` can be told apart from a `case=value`
3405    /// argv key one token early — see the `Case` arm. Returns `true` when
3406    /// `tok` is the substitution's own closing `)` — the scan must stop, and
3407    /// `tok` itself is not part of the body. Returns `false` when `tok`
3408    /// belongs to the body and scanning continues.
3409    fn step(&mut self, tok: &Token, next: Option<&Token>) -> bool {
3410        match tok {
3411            Token::RParen => match self.0.last_mut() {
3412                None => return true,
3413                Some(CmdSubstFrame::Case { awaiting_pattern }) => {
3414                    *awaiting_pattern = false;
3415                }
3416                Some(CmdSubstFrame::Paren) => {
3417                    self.0.pop();
3418                    // The paren just closed may have been a case-branch
3419                    // pattern's optional leading `(` (`(a)` == `a)`) — if
3420                    // so, this same `)` also consumed the pattern.
3421                    if let Some(CmdSubstFrame::Case { awaiting_pattern }) = self.0.last_mut() {
3422                        *awaiting_pattern = false;
3423                    }
3424                }
3425                Some(CmdSubstFrame::Subst) => {
3426                    self.0.pop();
3427                }
3428            },
3429            Token::LParen => self.0.push(CmdSubstFrame::Paren),
3430            Token::CmdSubstStart => self.0.push(CmdSubstFrame::Subst),
3431            // `case` opens a case-statement frame UNLESS it's immediately
3432            // followed by `=` — kaish permits shell keywords as `key=value`
3433            // argv keys (`in=a`, `do=b`; see `keyword_word`), and `case` is
3434            // no exception. Pushing a frame for `case=x` would leave it
3435            // stuck open (nothing but a bareword `esac` or a stray `)`
3436            // would ever touch it again), corrupting the rest of the scan.
3437            Token::Case if !matches!(next, Some(Token::Eq)) => {
3438                self.0.push(CmdSubstFrame::Case { awaiting_pattern: true });
3439            }
3440            Token::Case => {}
3441            Token::DoubleSemi => {
3442                if let Some(CmdSubstFrame::Case { awaiting_pattern }) = self.0.last_mut() {
3443                    *awaiting_pattern = true;
3444                }
3445            }
3446            Token::Esac
3447                if matches!(
3448                    self.0.last(),
3449                    Some(CmdSubstFrame::Case { awaiting_pattern: true })
3450                ) =>
3451            {
3452                self.0.pop();
3453            }
3454            _ => {}
3455        }
3456        false
3457    }
3458}
3459
3460/// Find the index in `tokens` of the `)` that closes a `$(...)` whose body
3461/// starts at `tokens[0]` (i.e. `tokens` must NOT include the leading
3462/// `CmdSubstStart`). `None` if `tokens` runs out first (unterminated).
3463///
3464/// Plain-slice twin of [`cmd_subst_body_tokens`]'s live chumsky capture, used
3465/// only by `validate_cmd_subst_bodies`'s error-path fallback — see that
3466/// function's doc comment for why a second, non-chumsky scan exists at all.
3467fn find_cmd_subst_close(tokens: &[(Token, Span)]) -> Option<usize> {
3468    let mut tracker = CmdSubstFrames::default();
3469    (0..tokens.len()).find(|&i| {
3470        let next = tokens.get(i + 1).map(|(t, _)| t);
3471        tracker.step(&tokens[i].0, next)
3472    })
3473}
3474
3475/// Re-validate every unquoted `$(...)` body in `tokens` on its own, outside
3476/// chumsky's `choice`/`try_map` alternative machinery — called only after
3477/// [`parse_tokens`]'s main grammar pass has already failed.
3478///
3479/// Why this exists: chumsky's `TryMap::go` (see `combinator.rs` in the
3480/// `chumsky` crate) records a failed alternative's error at the cursor
3481/// position from *before* the wrapped parser ran, not at the error's own
3482/// span. `cmd_subst_parser`'s `try_map` wraps a body that can be many tokens
3483/// long, so a deep, specific error from well inside a malformed `$(...)`
3484/// body gets attributed to the shallow position right after `$(` for
3485/// purposes of chumsky's furthest-error bookkeeping — and can lose to a
3486/// shorter, more generic error from a sibling `choice` alternative that
3487/// never had a chance of matching. The user then sees "expected expression"
3488/// pointing at the `$(` itself instead of the real problem inside.
3489///
3490/// The fix already used elsewhere in this parser (`bash_substring_hint`,
3491/// `first_ambiguous_stdin`) is to step outside chumsky's alternative
3492/// machinery entirely for diagnostics we want full control over. This
3493/// function does that for `$(...)` bodies: it is not part of the grammar, so
3494/// nothing merges or discards the error it returns.
3495fn validate_cmd_subst_bodies(tokens: &[(Token, Span)]) -> Result<(), Vec<ParseError>> {
3496    let mut i = 0;
3497    while i < tokens.len() {
3498        if !matches!(tokens[i].0, Token::CmdSubstStart) {
3499            i += 1;
3500            continue;
3501        }
3502        let start_span = tokens[i].1;
3503        let rest = &tokens[i + 1..];
3504        let Some(close_rel) = find_cmd_subst_close(rest) else {
3505            return Err(vec![ParseError {
3506                span: start_span,
3507                message: "unterminated command substitution: missing `)`".to_string(),
3508            }]);
3509        };
3510        let body = &rest[..close_rel];
3511        let rparen_span = rest[close_rel].1;
3512        let end_span: Span = (rparen_span.start..rparen_span.start).into();
3513        // Recurse before moving on to the remainder of `tokens`, so a nested
3514        // `$(...)` reports its own (deeper, more specific) error rather than
3515        // this level's.
3516        parse_tokens(body.to_vec(), end_span, start_span)?;
3517        i += 1 + close_rel + 1;
3518    }
3519    Ok(())
3520}
3521
3522/// Re-validate every double-quoted string's interpolation directly, outside
3523/// chumsky's `choice`/`try_map` alternative machinery — called only after
3524/// [`parse_tokens`]'s main grammar pass has already failed, the same way and
3525/// for the same reason as [`validate_cmd_subst_bodies`] (its doc comment has
3526/// the mechanism): a `$(...)` inside a `Token::String` fails deep inside
3527/// `interpolated_string_parser`'s `try_map`, so a `choice` alternative
3528/// elsewhere in the grammar that never had a chance of matching can still win
3529/// chumsky's furthest-error bookkeeping and bury the real message (an
3530/// unterminated or malformed quoted `$(...)` reporting a generic "expected
3531/// expression" pointing at the string's start, instead of naming the actual
3532/// problem).
3533fn validate_interpolated_strings(tokens: &[(Token, Span)]) -> Result<(), Vec<ParseError>> {
3534    for (tok, span) in tokens {
3535        let owned;
3536        let body = match tok {
3537            Token::String(s) => Some(s.as_str()),
3538            // `${x:-WORD}`'s default word fails in `var_expr_parser`'s
3539            // `try_map`, which is inside the same `choice` bookkeeping — so its
3540            // message needs surfacing here for the same reason a string's does.
3541            Token::VarRef(raw) => match find_default_separator(raw) {
3542                Some(colon) if raw.len() > colon + 3 => {
3543                    owned = unquote_default_word(&raw[colon + 2..raw.len() - 1]);
3544                    Some(owned.as_str())
3545                }
3546                _ => None,
3547            },
3548            _ => None,
3549        };
3550        if let Some(body) = body
3551            && let Err(message) = parse_interpolated_string(body)
3552        {
3553            return Err(vec![ParseError { span: *span, message }]);
3554        }
3555    }
3556    Ok(())
3557}
3558
3559/// A heredoc body's own `$(...)` errors are raised inside
3560/// `parse_interpolated_string_spanned`, deep in a `try_map`, so chumsky's
3561/// alternative bookkeeping can bury them behind a generic "found `<<`" — the
3562/// same loss `validate_cmd_subst_bodies` exists to undo for the unquoted form.
3563///
3564/// Uses the heredoc's own parser, never the double-quoted string's: a body may
3565/// hold a raw `"`, and the string scanner reads `stamp = "$(date +%s)"` as
3566/// unterminated. A quoted delimiter (`<<'EOF'`) is literal and never expanded,
3567/// so its body is never inspected.
3568fn validate_heredoc_bodies(tokens: &[(Token, Span)]) -> Result<(), Vec<ParseError>> {
3569    for (tok, span) in tokens {
3570        if let Token::HereDoc(d) = tok
3571            && !d.literal
3572            && let Err(message) = parse_interpolated_string_spanned(&d.content, 0)
3573        {
3574            return Err(vec![ParseError { span: *span, message }]);
3575        }
3576    }
3577    Ok(())
3578}
3579
3580/// True for a lexer token that becomes exactly one glue-candidate `Arg`
3581/// (`Positional` or a bare `LongFlag`) on its own.
3582///
3583/// Exhaustive over `Token` with no wildcard, so a new variant is a compile
3584/// error here rather than a silently un-improved span. That is completeness
3585/// against the ENUM, not the GRAMMAR: a new production built from a token
3586/// already listed `false` compiles fine and this scanner keeps missing it.
3587fn is_word_token(tok: &Token) -> bool {
3588    match tok {
3589        Token::True | Token::False | Token::EqEq | Token::NotEq | Token::Eq
3590        | Token::Colon | Token::Comma | Token::DotDot | Token::Dot | Token::TildePath(_)
3591        | Token::Tilde | Token::RelativePath(_) | Token::DotSlashPath(_)
3592        | Token::DottedIdent(_) | Token::Star | Token::Bang | Token::Question
3593        | Token::GlobWord(_) | Token::Arithmetic(_) | Token::LongFlag(_)
3594        | Token::DoubleDashBare(_) | Token::PlusBare(_) | Token::MinusBare(_)
3595        | Token::JobSpec(_) | Token::MinusAlone | Token::String(_)
3596        | Token::SingleString(_) | Token::VarRef(_) | Token::SimpleVarRef(_)
3597        | Token::Positional(_) | Token::AllArgs | Token::ArgCount | Token::LastExitCode
3598        | Token::CurrentPid | Token::VarLength(_) | Token::Int(_) | Token::Float(_)
3599        | Token::NumericLiteral(_)
3600        | Token::NumberIdent(_) | Token::DashNumWord(_) | Token::AtWord(_)
3601        | Token::Path(_) | Token::Ident(_) => true,
3602
3603        // Known gap. These nine are keywords `keyword_as_bareword` accepts in
3604        // argument position, so `git checkout do:x` is a real paste this
3605        // scanner misses; it falls through to the grammar's own span. Flipping
3606        // them to `true` closes it, but changes error text outside this
3607        // change's scope. (`keyword_word`'s wider set is assignment KEYS — see
3608        // `is_assign_key_token`.)
3609        Token::Done | Token::Fi | Token::Then | Token::Else | Token::Elif | Token::In
3610        | Token::Do | Token::Esac | Token::Set => false,
3611
3612        // Keywords that are only ever keywords in argument position.
3613        Token::Local | Token::If | Token::For | Token::While | Token::Case
3614        | Token::Function | Token::Break | Token::Continue | Token::Return | Token::Exit => false,
3615
3616        // Type names, only meaningful in a declaration.
3617        Token::TypeString | Token::TypeInt | Token::TypeFloat | Token::TypeBool => false,
3618
3619        // Operators: never a word, and each already breaks a run.
3620        Token::And | Token::Or | Token::Match | Token::NotMatch | Token::GtEq
3621        | Token::LtEq => false,
3622
3623        // Redirect operators. A redirect's target is diagnosed by
3624        // `redirect_parser` with its own wording, and `is_glued_args_error`
3625        // keeps this scanner from ever running for those inputs.
3626        Token::Gt | Token::GtGt | Token::Lt | Token::Stderr | Token::Both
3627        | Token::HereString | Token::HereDocStart | Token::StderrToStdout
3628        | Token::StdoutToStderr | Token::StdoutToStderr2 => false,
3629
3630        // Statement and collection structure.
3631        Token::Pipe | Token::Amp | Token::Semi | Token::DoubleSemi | Token::DotDotDot
3632        | Token::LBrace | Token::RBrace | Token::LBracket | Token::RBracket
3633        | Token::LParen | Token::RParen => false,
3634
3635        // `(( ))` is a whole command/condition, like `[[ ]]` — never a word
3636        // an argument would glue onto.
3637        Token::ArithCond(_) => false,
3638
3639        // `$(` opens a balanced group, not a single-token word — `word_unit`
3640        // handles it by scanning to the matching `)`.
3641        Token::CmdSubstStart => false,
3642
3643        // Excluded to match `is_glue_candidate`'s own exclusion of
3644        // `Arg::ShortFlag`/`Arg::DoubleDash` — see that function.
3645        Token::ShortFlag(_) | Token::PlusFlag(_) | Token::DoubleDash => false,
3646
3647        // Heredoc bodies, lexer errors, trivia: never argv words.
3648        Token::HereDoc(_) | Token::InvalidFloatNoLeading | Token::InvalidFloatNoTrailing
3649        | Token::Comment | Token::Newline | Token::LineContinuation
3650        | Token::BacktickRejected => false,
3651    }
3652}
3653
3654/// A key token `word_assign_arg_parser`/`long_flag_with_value` accept —
3655/// `Ident` for `key=value`, `LongFlag` for `--key=value`.
3656///
3657/// The `LongFlag` half fuses `--key=value` into ONE unit; without it a spaced
3658/// `--a=1 --b=2` would scan as adjacent fragments and be flagged as a paste it
3659/// is not. `keyword_word`'s wider key set is deliberately not reproduced: this
3660/// scanner re-derives common shapes, not the whole grammar.
3661fn is_assign_key_token(tok: &Token) -> bool {
3662    matches!(tok, Token::Ident(_) | Token::LongFlag(_))
3663}
3664
3665/// If `tokens[i]` starts a single glue-candidate word — one `is_word_token`
3666/// token, or a balanced `$(...)` group — returns its span and the index
3667/// just past it. `None` when `tokens[i]` cannot start a word (an operator,
3668/// a `ShortFlag`, a keyword used as a keyword, an unterminated `$(...)`).
3669fn word_unit(tokens: &[(Token, Span)], i: usize) -> Option<(Span, usize)> {
3670    let (tok, span) = tokens.get(i)?;
3671    if matches!(tok, Token::CmdSubstStart) {
3672        let close_rel = find_cmd_subst_close(&tokens[i + 1..])?;
3673        let close_idx = i + 1 + close_rel;
3674        return Some(((span.start..tokens[close_idx].1.end).into(), close_idx + 1));
3675    }
3676    if is_word_token(tok) {
3677        return Some((*span, i + 1));
3678    }
3679    None
3680}
3681
3682/// Walk `tokens` producing the glue-candidate units
3683/// `arg_before_double_dash_parser` would bind as `Arg`s: a word token, a
3684/// `key=value`/`--key=value` assignment, or a balanced `$(...)`. Anything
3685/// else breaks the chain and never merges across it.
3686///
3687/// Redirect targets need no handling here — `redirect_parser` diagnoses a
3688/// glued target in its own words, so `is_glued_args_error` keeps this
3689/// scanner from running for them at all.
3690fn glue_candidate_units(tokens: &[(Token, Span)]) -> Vec<Span> {
3691    let mut units = Vec::new();
3692    let mut i = 0;
3693    while i < tokens.len() {
3694        let (tok, span) = &tokens[i];
3695
3696        if is_assign_key_token(tok)
3697            && let Some((Token::Eq, eq_span)) = tokens.get(i + 1)
3698            && eq_span.start == span.end
3699            && let Some((value_span, next_i)) = word_unit(tokens, i + 2)
3700            && eq_span.end == value_span.start
3701        {
3702            units.push((span.start..value_span.end).into());
3703            i = next_i;
3704            continue;
3705        }
3706
3707        if let Some((unit_span, next_i)) = word_unit(tokens, i) {
3708            units.push(unit_span);
3709            i = next_i;
3710            continue;
3711        }
3712
3713        i += 1;
3714    }
3715    units
3716}
3717
3718/// Re-detect a glued-argv paste directly from `tokens`, after the main
3719/// grammar pass has already failed — the same shape as
3720/// [`validate_cmd_subst_bodies`], for the same reason.
3721///
3722/// `reject_glued_args` raises this rejection inside a `try_map` wrapping the
3723/// whole argv, and its span never survives: `flat_merge` keeps our message
3724/// while `Rich::merge` keeps a shallower sibling's span, so `git show
3725/// HEAD:x.py` reports at `show`.
3726///
3727/// Reached only once `is_glued_args_error` confirms the standing error is
3728/// this exact rejection, so it need not know what to avoid. Under-recognizing
3729/// a shape just leaves the grammar's span in place. The scope is "report the
3730/// right span for a rejection that already happened", never "change what is
3731/// rejected".
3732/// `break 007` no longer matches the count grammar, so chumsky reports the
3733/// miss against every statement alternative — a long message that never
3734/// mentions the zero.
3735///
3736/// Runs only after the grammar has already failed, and only when the grammar's
3737/// own error sits exactly on that numeral. Like [`validate_glued_args`], this
3738/// may reword a diagnosis and must never author one: without the position gate
3739/// it scanned the whole stream and would blame `echo break 007` (where `break`
3740/// is an argv word the grammar rejected for its own reasons) or answer a real
3741/// error elsewhere on the line with this message instead.
3742fn validate_leading_zero_counts(
3743    tokens: &[(Token, Span)],
3744    error_starts: &[usize],
3745) -> Result<(), Vec<ParseError>> {
3746    for (i, pair) in tokens.windows(2).enumerate() {
3747        let keyword = match &pair[0].0 {
3748            Token::Break => "break",
3749            Token::Continue => "continue",
3750            _ => continue,
3751        };
3752        // `break` is also a bareword an argument list accepts, and `echo break
3753        // 007` fails ON the numeral just like the statement does. Only a
3754        // `break` in statement position is the one this message describes.
3755        let at_statement_start = match i.checked_sub(1).map(|prev| &tokens[prev].0) {
3756            None => true,
3757            Some(
3758                Token::Newline
3759                | Token::Semi
3760                | Token::DoubleSemi
3761                | Token::Do
3762                | Token::Then
3763                | Token::Else
3764                | Token::LBrace
3765                | Token::And
3766                | Token::Or,
3767            ) => true,
3768            Some(_) => false,
3769        };
3770        if !at_statement_start {
3771            continue;
3772        }
3773        let Token::NumberIdent(word) = &pair[1].0 else {
3774            continue;
3775        };
3776        if !lexer::is_leading_zero_numeral(word) {
3777            continue;
3778        }
3779        // The grammar must have failed ON this numeral. Anywhere else and the
3780        // verdict belongs to whatever the grammar was actually judging.
3781        if !error_starts.contains(&pair[1].1.start) {
3782            continue;
3783        }
3784        // Keep the sign: `break -022` is not fixed by writing `break 22`.
3785        let sign = if word.starts_with('-') { "-" } else { "" };
3786        let digits = word.trim_start_matches('-').trim_start_matches('0');
3787        let count = format!("{sign}{}", if digits.is_empty() { "0" } else { digits });
3788        // The count grammar takes only a whole number — `007.5` trims to
3789        // `7.5`, which is itself a parse error, so the fix cannot be the
3790        // trimmed value. Name the integer part instead.
3791        let message = if let Some((int_part, _)) = count.split_once('.') {
3792            let int_part = if int_part.is_empty() || int_part == "-" { "0" } else { int_part };
3793            format!(
3794                "`{keyword}` takes a whole-number loop count and `{word}` is text (leading \
3795                 zero) — write a whole number such as `{keyword} {int_part}`"
3796            )
3797        } else {
3798            format!(
3799                "`{keyword}` takes a loop count and `{word}` is text (leading zero) — write \
3800                 `{keyword} {count}`"
3801            )
3802        };
3803        return Err(vec![ParseError { span: pair[1].1, message }]);
3804    }
3805    Ok(())
3806}
3807
3808fn validate_glued_args(
3809    tokens: &[(Token, Span)],
3810    from_offset: usize,
3811) -> Result<(), Vec<ParseError>> {
3812    let units = glue_candidate_units(tokens);
3813    for i in 0..units.len().saturating_sub(1) {
3814        if units[i].end != units[i + 1].start {
3815            continue;
3816        }
3817        let mut start_idx = i;
3818        while start_idx > 0 && units[start_idx - 1].end == units[start_idx].start {
3819            start_idx -= 1;
3820        }
3821        let mut end_idx = i + 1;
3822        while end_idx + 1 < units.len() && units[end_idx].end == units[end_idx + 1].start {
3823            end_idx += 1;
3824        }
3825        // The scan walks the whole token stream, so it also finds adjacency
3826        // in regions the grammar parsed happily — `for x in $a/b; do echo
3827        // /tmp/$(echo x).txt; done` has a legal `$a/b` in the loop head and
3828        // the real paste in the body. Take the first run at or after the
3829        // grammar's own position so the earlier legal run cannot win.
3830        if units[start_idx].start < from_offset {
3831            continue;
3832        }
3833        let span: Span = (units[start_idx].start..units[end_idx].end).into();
3834        return Err(vec![ParseError {
3835            span,
3836            message: GLUED_ARGS_MESSAGE.to_string(),
3837        }]);
3838    }
3839    // No run at or after the grammar's position: say nothing and let its own
3840    // span stand. Never worse than the message shipped in 0.16.0.
3841    Ok(())
3842}
3843
3844/// Command substitution: `$(...)` - runs a statement sequence and returns its
3845/// result.
3846///
3847/// Route C (GH #194): the body is a token slice, balance-captured by
3848/// [`cmd_subst_body_tokens`], then parsed with the FULL program grammar
3849/// (`parse_tokens`, the same entry point [`parse`] uses) from inside this
3850/// `.try_map()` closure — at *parse* time, not build time. That is what lets
3851/// `if`/`for`/`while`/`case` appear inside an unquoted `$(...)`: closing the
3852/// cycle with a second `recursive()` call (`cmd_subst → primary_expr →
3853/// cmd_subst`) overflows the stack while the parser graph is being
3854/// CONSTRUCTED (see `CACHED_PARSER`'s doc comment), but a call made once that
3855/// graph already exists — from inside a closure that only runs when this
3856/// combinator actually matches a token — has nothing left to recurse through
3857/// at build time.
3858///
3859/// Before this, the body had its own hand-rolled pipeline/`&&`/`||` grammar
3860/// with control structures intentionally out of scope — a second, smaller
3861/// copy of `pipeline_parser`/`command_parser` kept in sync by hand. Route C
3862/// deleted that copy: a pipeline inside `$(...)` now goes through the same
3863/// `pipeline_parser` as everywhere else, and this function no longer needs
3864/// the caller's recursive `expr` handle at all (the redirect-target cycle
3865/// `redirect_parser` used to document is gone with it).
3866fn cmd_subst_parser<'tokens, I>(
3867) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
3868where
3869    I: ValueInput<'tokens, Token = Token, Span = Span>,
3870{
3871    just(Token::CmdSubstStart)
3872        .ignore_then(cmd_subst_body_tokens())
3873        .try_map(|(body_tokens, rparen_span), outer_span| {
3874            let end_span: Span = (rparen_span.start..rparen_span.start).into();
3875            parse_tokens(body_tokens, end_span, outer_span)
3876                .map(|program| Expr::CommandSubst(program.statements))
3877                .map_err(|errs| {
3878                    let first = errs.into_iter().next().unwrap_or_else(|| ParseError {
3879                        span: outer_span,
3880                        message: "command substitution failed to parse".to_string(),
3881                    });
3882                    Rich::custom(first.span, first.message)
3883                })
3884        })
3885        .labelled("command substitution")
3886}
3887
3888/// String parser - handles double-quoted strings (with interpolation) and single-quoted (literal).
3889fn interpolated_string_parser<'tokens, I>(
3890) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
3891where
3892    I: ValueInput<'tokens, Token = Token, Span = Span>,
3893{
3894    // Double-quoted string: may contain $VAR or ${VAR} interpolation
3895    let double_quoted = select! {
3896        Token::String(s) => s,
3897    }
3898    .try_map(|s, span| {
3899        // Check if string contains interpolation markers (${} or $NAME) or escaped dollars
3900        if s.contains('$') || s.contains("__KAISH_ESCAPED_DOLLAR__") {
3901            // Parse interpolated parts. A syntax error inside a `$(…)` is loud
3902            // (Rich error at this string's span), not silently demoted to text.
3903            let parts = parse_interpolated_string(&s)
3904                .map_err(|msg| Rich::custom(span, msg))?;
3905            if parts.len() == 1
3906                && let StringPart::Literal(text) = &parts[0] {
3907                    return Ok(Expr::Literal(Value::String(text.clone())));
3908                }
3909            Ok(Expr::Interpolated(parts))
3910        } else {
3911            Ok(Expr::Literal(Value::String(s)))
3912        }
3913    });
3914
3915    // Single-quoted string: literal, no interpolation
3916    let single_quoted = select! {
3917        Token::SingleString(s) => Expr::Literal(Value::String(s)),
3918    };
3919
3920    choice((single_quoted, double_quoted)).labelled("string")
3921}
3922
3923/// Literal value parser (excluding strings, which are handled by interpolated_string_parser).
3924fn literal_parser<'tokens, I>(
3925) -> impl Parser<'tokens, I, Value, extra::Err<Rich<'tokens, Token, Span>>> + Clone
3926where
3927    I: ValueInput<'tokens, Token = Token, Span = Span>,
3928{
3929    choice((
3930        select! {
3931            Token::True => Value::Bool(true),
3932            Token::False => Value::Bool(false),
3933        },
3934        select! {
3935            Token::Int(n) => Value::Int(n),
3936            Token::Float(f) => Value::Float(f),
3937        },
3938    ))
3939    .labelled("literal")
3940    .boxed()
3941}
3942
3943/// A numeral whose source text does not round-trip through its own typed
3944/// `Display` — a negative zero, a leading zero, or a non-canonical trailing
3945/// fraction digit. See `lexer::Token::NumericLiteral`. Kept separate from
3946/// `literal_parser` because it produces `Expr::NumericLiteral` directly
3947/// (carrying `raw` alongside `value`), not a bare `Value` for `Expr::Literal`
3948/// to wrap.
3949fn numeric_literal_parser<'tokens, I>(
3950) -> impl Parser<'tokens, I, Expr, extra::Err<Rich<'tokens, Token, Span>>> + Clone
3951where
3952    I: ValueInput<'tokens, Token = Token, Span = Span>,
3953{
3954    select! {
3955        Token::NumericLiteral(d) => Expr::NumericLiteral { value: d.value, raw: d.raw },
3956    }
3957    .labelled("literal")
3958}
3959
3960/// Identifier parser.
3961fn ident_parser<'tokens, I>(
3962) -> impl Parser<'tokens, I, String, extra::Err<Rich<'tokens, Token, Span>>> + Clone
3963where
3964    I: ValueInput<'tokens, Token = Token, Span = Span>,
3965{
3966    select! {
3967        Token::Ident(s) => s,
3968    }
3969    .labelled("identifier")
3970}
3971
3972/// Path parser: matches absolute paths like `/tmp/out`, `/etc/hosts`.
3973fn path_parser<'tokens, I>(
3974) -> impl Parser<'tokens, I, String, extra::Err<Rich<'tokens, Token, Span>>> + Clone
3975where
3976    I: ValueInput<'tokens, Token = Token, Span = Span>,
3977{
3978    select! {
3979        Token::Path(s) => s,
3980    }
3981    .labelled("path")
3982}
3983
3984#[cfg(test)]
3985#[allow(clippy::approx_constant)]
3986mod tests {
3987    use super::*;
3988    use proptest::strategy::Strategy;
3989
3990    /// The commands of a command-only pipeline. Panics on a compound stage —
3991    /// every assertion below is about a pipeline of plain commands, and a
3992    /// compound appearing in one would be the bug, not a case to skip.
3993    fn pipeline_commands(p: &Pipeline) -> Vec<&Command> {
3994        p.stages
3995            .iter()
3996            .map(|stage| stage.as_command().expect("expected a command stage"))
3997            .collect()
3998    }
3999
4000    /// Extract the single `Command` from a one-statement `$(cmd)` body.
4001    fn subst_cmd(expr: &Expr) -> &Command {
4002        match expr {
4003            Expr::CommandSubst(stmts) => match stmts.as_slice() {
4004                [Stmt::Command(cmd)] => cmd,
4005                other => panic!("expected a single command in $(), got {other:?}"),
4006            },
4007            other => panic!("expected command subst, got {other:?}"),
4008        }
4009    }
4010
4011    /// Extract the single `Pipeline` from a one-statement `$(a | b)` body.
4012    fn subst_pipeline(expr: &Expr) -> &Pipeline {
4013        match expr {
4014            Expr::CommandSubst(stmts) => match stmts.as_slice() {
4015                [Stmt::Pipeline(p)] => p,
4016                other => panic!("expected a single pipeline in $(), got {other:?}"),
4017            },
4018            other => panic!("expected command subst, got {other:?}"),
4019        }
4020    }
4021
4022    #[test]
4023    fn parse_empty() {
4024        let result = parse("");
4025        assert!(result.is_ok());
4026        assert_eq!(result.expect("ok").statements.len(), 0);
4027    }
4028
4029    #[test]
4030    fn parse_newlines_only() {
4031        let result = parse("\n\n\n");
4032        assert!(result.is_ok());
4033    }
4034
4035    #[test]
4036    fn parse_simple_command() {
4037        let result = parse("echo");
4038        assert!(result.is_ok());
4039        let program = result.expect("ok");
4040        assert_eq!(program.statements.len(), 1);
4041        assert!(matches!(&program.statements[0], Stmt::Command(_)));
4042    }
4043
4044    #[test]
4045    fn parse_command_with_string_arg() {
4046        let result = parse(r#"echo "hello""#);
4047        assert!(result.is_ok());
4048        let program = result.expect("ok");
4049        match &program.statements[0] {
4050            Stmt::Command(cmd) => assert_eq!(cmd.args.len(), 1),
4051            _ => panic!("expected Command"),
4052        }
4053    }
4054
4055    #[test]
4056    fn parse_assignment() {
4057        let result = parse("X=5");
4058        assert!(result.is_ok());
4059        let program = result.expect("ok");
4060        assert!(matches!(&program.statements[0], Stmt::Assignment(_)));
4061    }
4062
4063    #[test]
4064    fn parse_pipeline() {
4065        let result = parse("a | b | c");
4066        assert!(result.is_ok());
4067        let program = result.expect("ok");
4068        match &program.statements[0] {
4069            Stmt::Pipeline(p) => assert_eq!(pipeline_commands(p).len(), 3),
4070            _ => panic!("expected Pipeline"),
4071        }
4072    }
4073
4074    #[test]
4075    fn parse_background_job() {
4076        let result = parse("cmd &");
4077        assert!(result.is_ok());
4078        let program = result.expect("ok");
4079        match &program.statements[0] {
4080            Stmt::Pipeline(p) => assert!(p.background),
4081            _ => panic!("expected Pipeline with background"),
4082        }
4083    }
4084
4085    #[test]
4086    fn parse_if_simple() {
4087        let result = parse("if true; then echo; fi");
4088        assert!(result.is_ok());
4089        let program = result.expect("ok");
4090        assert!(matches!(&program.statements[0], Stmt::If(_)));
4091    }
4092
4093    #[test]
4094    fn parse_if_else() {
4095        let result = parse("if true; then echo; else echo; fi");
4096        assert!(result.is_ok());
4097        let program = result.expect("ok");
4098        match &program.statements[0] {
4099            Stmt::If(if_stmt) => assert!(if_stmt.else_branch.is_some()),
4100            _ => panic!("expected If"),
4101        }
4102    }
4103
4104    #[test]
4105    fn parse_elif_simple() {
4106        let result = parse("if true; then echo a; elif false; then echo b; fi");
4107        assert!(result.is_ok(), "parse failed: {:?}", result);
4108        let program = result.expect("ok");
4109        match &program.statements[0] {
4110            Stmt::If(if_stmt) => {
4111                // elif is desugared to nested if in else
4112                assert!(if_stmt.else_branch.is_some());
4113                let else_branch = if_stmt.else_branch.as_ref().unwrap();
4114                assert_eq!(else_branch.len(), 1);
4115                assert!(matches!(&else_branch[0], Stmt::If(_)));
4116            }
4117            _ => panic!("expected If"),
4118        }
4119    }
4120
4121    #[test]
4122    fn parse_elif_with_else() {
4123        let result = parse("if true; then echo a; elif false; then echo b; else echo c; fi");
4124        assert!(result.is_ok(), "parse failed: {:?}", result);
4125        let program = result.expect("ok");
4126        match &program.statements[0] {
4127            Stmt::If(outer_if) => {
4128                // Check nested structure: if -> elif -> else
4129                let else_branch = outer_if.else_branch.as_ref().expect("outer else");
4130                assert_eq!(else_branch.len(), 1);
4131                match &else_branch[0] {
4132                    Stmt::If(inner_if) => {
4133                        // The inner if (from elif) should have the final else
4134                        assert!(inner_if.else_branch.is_some());
4135                    }
4136                    _ => panic!("expected nested If from elif"),
4137                }
4138            }
4139            _ => panic!("expected If"),
4140        }
4141    }
4142
4143    #[test]
4144    fn parse_multiple_elif() {
4145        // Shell-compatible: use [[ ]] for comparisons
4146        let result = parse(
4147            "if [[ ${X} == 1 ]]; then echo one; elif [[ ${X} == 2 ]]; then echo two; elif [[ ${X} == 3 ]]; then echo three; else echo other; fi",
4148        );
4149        assert!(result.is_ok(), "parse failed: {:?}", result);
4150    }
4151
4152    #[test]
4153    fn parse_for_loop() {
4154        let result = parse("for X in items; do echo; done");
4155        assert!(result.is_ok());
4156        let program = result.expect("ok");
4157        assert!(matches!(&program.statements[0], Stmt::For(_)));
4158    }
4159
4160    #[test]
4161    fn parse_brackets_not_array_literal() {
4162        // Array literals are no longer supported, [ is just a regular char
4163        let result = parse("cmd [1");
4164        // This should fail or parse unexpectedly - arrays are removed
4165        // Just verify we don't crash
4166        let _ = result;
4167    }
4168
4169    #[test]
4170    fn parse_named_arg() {
4171        // Bareword key=value parses as WordAssign — the kernel decides per
4172        // command whether to route it to tool_args.named (export/alias) or
4173        // stringify to a positional (every other builtin).
4174        let result = parse("cmd foo=5");
4175        assert!(result.is_ok());
4176        let program = result.expect("ok");
4177        match &program.statements[0] {
4178            Stmt::Command(cmd) => {
4179                assert_eq!(cmd.args.len(), 1);
4180                assert!(matches!(&cmd.args[0], Arg::WordAssign { .. }));
4181            }
4182            _ => panic!("expected Command"),
4183        }
4184    }
4185
4186    #[test]
4187    fn parse_short_flag() {
4188        let result = parse("ls -l");
4189        assert!(result.is_ok());
4190        let program = result.expect("ok");
4191        match &program.statements[0] {
4192            Stmt::Command(cmd) => {
4193                assert_eq!(cmd.name, "ls");
4194                assert_eq!(cmd.args.len(), 1);
4195                match &cmd.args[0] {
4196                    Arg::ShortFlag(name) => assert_eq!(name, "l"),
4197                    _ => panic!("expected ShortFlag"),
4198                }
4199            }
4200            _ => panic!("expected Command"),
4201        }
4202    }
4203
4204    #[test]
4205    fn parse_long_flag() {
4206        let result = parse("git push --force");
4207        assert!(result.is_ok());
4208        let program = result.expect("ok");
4209        match &program.statements[0] {
4210            Stmt::Command(cmd) => {
4211                assert_eq!(cmd.name, "git");
4212                assert_eq!(cmd.args.len(), 2);
4213                match &cmd.args[0] {
4214                    Arg::Positional(Expr::Literal(Value::String(s))) => assert_eq!(s, "push"),
4215                    _ => panic!("expected Positional push"),
4216                }
4217                match &cmd.args[1] {
4218                    Arg::LongFlag(name) => assert_eq!(name, "force"),
4219                    _ => panic!("expected LongFlag"),
4220                }
4221            }
4222            _ => panic!("expected Command"),
4223        }
4224    }
4225
4226    #[test]
4227    fn parse_long_flag_with_value() {
4228        let result = parse(r#"git commit --message="hello""#);
4229        assert!(result.is_ok());
4230        let program = result.expect("ok");
4231        match &program.statements[0] {
4232            Stmt::Command(cmd) => {
4233                assert_eq!(cmd.name, "git");
4234                assert_eq!(cmd.args.len(), 2);
4235                match &cmd.args[1] {
4236                    Arg::Named { key, value } => {
4237                        assert_eq!(key, "message");
4238                        match value {
4239                            Expr::Literal(Value::String(s)) => assert_eq!(s, "hello"),
4240                            _ => panic!("expected String value"),
4241                        }
4242                    }
4243                    _ => panic!("expected Named from --flag=value"),
4244                }
4245            }
4246            _ => panic!("expected Command"),
4247        }
4248    }
4249
4250    #[test]
4251    fn parse_mixed_flags_and_args() {
4252        let result = parse(r#"git commit -m "message" --amend"#);
4253        assert!(result.is_ok());
4254        let program = result.expect("ok");
4255        match &program.statements[0] {
4256            Stmt::Command(cmd) => {
4257                assert_eq!(cmd.name, "git");
4258                assert_eq!(cmd.args.len(), 4);
4259                // commit (positional)
4260                assert!(matches!(&cmd.args[0], Arg::Positional(_)));
4261                // -m (short flag)
4262                match &cmd.args[1] {
4263                    Arg::ShortFlag(name) => assert_eq!(name, "m"),
4264                    _ => panic!("expected ShortFlag -m"),
4265                }
4266                // "message" (positional)
4267                assert!(matches!(&cmd.args[2], Arg::Positional(_)));
4268                // --amend (long flag)
4269                match &cmd.args[3] {
4270                    Arg::LongFlag(name) => assert_eq!(name, "amend"),
4271                    _ => panic!("expected LongFlag --amend"),
4272                }
4273            }
4274            _ => panic!("expected Command"),
4275        }
4276    }
4277
4278    #[test]
4279    fn parse_redirect_stdout() {
4280        let result = parse("cmd > file");
4281        assert!(result.is_ok());
4282        let program = result.expect("ok");
4283        // Commands with redirects stay as Pipeline, not Command
4284        match &program.statements[0] {
4285            Stmt::Pipeline(p) => {
4286                assert_eq!(pipeline_commands(p).len(), 1);
4287                let cmd = pipeline_commands(p)[0];
4288                assert_eq!(cmd.redirects.len(), 1);
4289                assert!(matches!(cmd.redirects[0].kind, RedirectKind::StdoutOverwrite));
4290            }
4291            _ => panic!("expected Pipeline"),
4292        }
4293    }
4294
4295    #[test]
4296    fn parse_var_ref() {
4297        let result = parse("echo ${VAR}");
4298        assert!(result.is_ok());
4299        let program = result.expect("ok");
4300        match &program.statements[0] {
4301            Stmt::Command(cmd) => {
4302                assert_eq!(cmd.args.len(), 1);
4303                assert!(matches!(&cmd.args[0], Arg::Positional(Expr::VarRef(_))));
4304            }
4305            _ => panic!("expected Command"),
4306        }
4307    }
4308
4309    #[test]
4310    fn parse_multiple_statements() {
4311        let result = parse("a\nb\nc");
4312        assert!(result.is_ok());
4313        let program = result.expect("ok");
4314        let non_empty: Vec<_> = program.statements.iter().filter(|s| !matches!(s, Stmt::Empty)).collect();
4315        assert_eq!(non_empty.len(), 3);
4316    }
4317
4318    #[test]
4319    fn parse_semicolon_separated() {
4320        let result = parse("a; b; c");
4321        assert!(result.is_ok());
4322        let program = result.expect("ok");
4323        let non_empty: Vec<_> = program.statements.iter().filter(|s| !matches!(s, Stmt::Empty)).collect();
4324        assert_eq!(non_empty.len(), 3);
4325    }
4326
4327    #[test]
4328    fn parse_complex_pipeline() {
4329        let result = parse(r#"cat file | grep pattern="foo" | head count=10"#);
4330        assert!(result.is_ok());
4331        let program = result.expect("ok");
4332        match &program.statements[0] {
4333            Stmt::Pipeline(p) => assert_eq!(pipeline_commands(p).len(), 3),
4334            _ => panic!("expected Pipeline"),
4335        }
4336    }
4337
4338    #[test]
4339    fn parse_json_as_string_arg() {
4340        // JSON arrays/objects should be passed as string arguments
4341        let result = parse(r#"cmd '[[1, 2], [3, 4]]'"#);
4342        assert!(result.is_ok());
4343    }
4344
4345    #[test]
4346    fn parse_mixed_args() {
4347        let result = parse(r#"cmd pos1 key="val" pos2 num=42"#);
4348        assert!(result.is_ok());
4349        let program = result.expect("ok");
4350        match &program.statements[0] {
4351            Stmt::Command(cmd) => assert_eq!(cmd.args.len(), 4),
4352            _ => panic!("expected Command"),
4353        }
4354    }
4355
4356    #[test]
4357    fn error_unterminated_string() {
4358        let result = parse(r#"echo "hello"#);
4359        assert!(result.is_err());
4360    }
4361
4362    #[test]
4363    fn error_unterminated_var_ref() {
4364        let result = parse("echo ${VAR");
4365        assert!(result.is_err());
4366    }
4367
4368    #[test]
4369    fn error_missing_fi() {
4370        let result = parse("if true; then echo");
4371        assert!(result.is_err());
4372    }
4373
4374    #[test]
4375    fn error_missing_done() {
4376        let result = parse("for X in items; do echo");
4377        assert!(result.is_err());
4378    }
4379
4380    #[test]
4381    fn parse_lvalue_single_index() {
4382        let result = parse("xs[0]=9").unwrap();
4383        match &result.statements[0] {
4384            Stmt::Assignment(a) => {
4385                assert_eq!(a.name(), "xs");
4386                assert_eq!(
4387                    a.path.segments,
4388                    vec![VarSegment::Field("xs".into()), VarSegment::Index(0)]
4389                );
4390                assert!(!a.local);
4391            }
4392            other => panic!("expected assignment, got {:?}", other),
4393        }
4394    }
4395
4396    #[test]
4397    fn parse_lvalue_negative_index() {
4398        let result = parse("xs[-1]=7").unwrap();
4399        match &result.statements[0] {
4400            Stmt::Assignment(a) => assert_eq!(
4401                a.path.segments,
4402                vec![VarSegment::Field("xs".into()), VarSegment::Index(-1)]
4403            ),
4404            other => panic!("expected assignment, got {:?}", other),
4405        }
4406    }
4407
4408    #[test]
4409    fn parse_lvalue_bareword_key() {
4410        let result = parse("user[email]=x").unwrap();
4411        match &result.statements[0] {
4412            Stmt::Assignment(a) => assert_eq!(
4413                a.path.segments,
4414                vec![
4415                    VarSegment::Field("user".into()),
4416                    VarSegment::Key("email".into())
4417                ]
4418            ),
4419            other => panic!("expected assignment, got {:?}", other),
4420        }
4421    }
4422
4423    #[test]
4424    fn parse_lvalue_chained_keys() {
4425        let result = parse("s[web][port]=9000").unwrap();
4426        match &result.statements[0] {
4427            Stmt::Assignment(a) => assert_eq!(
4428                a.path.segments,
4429                vec![
4430                    VarSegment::Field("s".into()),
4431                    VarSegment::Key("web".into()),
4432                    VarSegment::Key("port".into())
4433                ]
4434            ),
4435            other => panic!("expected assignment, got {:?}", other),
4436        }
4437    }
4438
4439    #[test]
4440    fn parse_lvalue_dynamic_key() {
4441        let result = parse("r[$k]=v").unwrap();
4442        match &result.statements[0] {
4443            Stmt::Assignment(a) => assert_eq!(
4444                a.path.segments,
4445                vec![
4446                    VarSegment::Field("r".into()),
4447                    VarSegment::Dynamic("k".into())
4448                ]
4449            ),
4450            other => panic!("expected assignment, got {:?}", other),
4451        }
4452    }
4453
4454    #[test]
4455    fn parse_local_lvalue_spaced() {
4456        let result = parse("local xs[0] = 9").unwrap();
4457        match &result.statements[0] {
4458            Stmt::Assignment(a) => {
4459                assert!(a.local);
4460                assert_eq!(
4461                    a.path.segments,
4462                    vec![VarSegment::Field("xs".into()), VarSegment::Index(0)]
4463                );
4464            }
4465            other => panic!("expected assignment, got {:?}", other),
4466        }
4467    }
4468
4469    #[test]
4470    fn env_prefix_subscripted_target_is_not_captured_as_env_scoped() {
4471        // A subscripted target before a following command (`user[email]=x
4472        // echo hi`) must NOT become `Stmt::EnvScoped` — structured values
4473        // can't cross the process boundary, so env-prefix stays bare-ident
4474        // only. The lexer suppression + `env_prefix_assign` using
4475        // `ident_parser()` (not `lvalue_path_parser()`) means this falls
4476        // through to an ordinary subscripted assignment followed by an
4477        // independent statement — the SAME back-to-back-without-a-terminator
4478        // shape `X=1 Y=2` already has (kaish's `terminator` is
4479        // `.repeated()`, not `.at_least(1)`), not a new hazard.
4480        let result = parse("user={}\nuser[email]=x echo hi").unwrap();
4481        for stmt in &result.statements {
4482            assert!(
4483                !matches!(stmt, Stmt::EnvScoped { .. }),
4484                "a subscripted assignment must never be captured into EnvScoped: {stmt:?}"
4485            );
4486        }
4487        // Sanity: it really did parse as two independent statements.
4488        assert!(matches!(&result.statements[1], Stmt::Assignment(a) if a.name() == "user"));
4489        assert!(matches!(&result.statements[2], Stmt::Command(c) if c.name == "echo"));
4490    }
4491
4492    #[test]
4493    fn parse_nested_cmd_subst() {
4494        // Nested command substitution is supported
4495        let result = parse("X=$(echo $(date))").unwrap();
4496        match &result.statements[0] {
4497            Stmt::Assignment(a) => {
4498                assert_eq!(a.name(), "X");
4499                let outer = subst_cmd(&a.value);
4500                assert_eq!(outer.name, "echo");
4501                // The argument should be another command substitution
4502                match &outer.args[0] {
4503                    Arg::Positional(inner_expr) => {
4504                        assert_eq!(subst_cmd(inner_expr).name, "date");
4505                    }
4506                    other => panic!("expected nested cmd subst arg, got {:?}", other),
4507                }
4508            }
4509            other => panic!("expected assignment, got {:?}", other),
4510        }
4511    }
4512
4513    #[test]
4514    fn parse_deeply_nested_cmd_subst() {
4515        // Three levels deep
4516        let result = parse("X=$(a $(b $(c)))").unwrap();
4517        match &result.statements[0] {
4518            Stmt::Assignment(a) => {
4519                let level1 = subst_cmd(&a.value);
4520                assert_eq!(level1.name, "a");
4521                match &level1.args[0] {
4522                    Arg::Positional(level2_expr) => {
4523                        let level2 = subst_cmd(level2_expr);
4524                        assert_eq!(level2.name, "b");
4525                        match &level2.args[0] {
4526                            Arg::Positional(level3_expr) => {
4527                                assert_eq!(subst_cmd(level3_expr).name, "c");
4528                            }
4529                            other => panic!("expected level3 cmd subst, got {:?}", other),
4530                        }
4531                    }
4532                    other => panic!("expected level2 cmd subst, got {:?}", other),
4533                }
4534            }
4535            other => panic!("expected assignment, got {:?}", other),
4536        }
4537    }
4538
4539    // ═══════════════════════════════════════════════════════════════════════════
4540    // Value Preservation Tests - These test that actual values are captured
4541    // ═══════════════════════════════════════════════════════════════════════════
4542
4543    #[test]
4544    fn value_int_preserved() {
4545        let result = parse("X=42").unwrap();
4546        match &result.statements[0] {
4547            Stmt::Assignment(a) => {
4548                assert_eq!(a.name(), "X");
4549                match &a.value {
4550                    Expr::Literal(Value::Int(n)) => assert_eq!(*n, 42),
4551                    other => panic!("expected int literal, got {:?}", other),
4552                }
4553            }
4554            other => panic!("expected assignment, got {:?}", other),
4555        }
4556    }
4557
4558    #[test]
4559    fn value_negative_int_preserved() {
4560        let result = parse("X=-99").unwrap();
4561        match &result.statements[0] {
4562            Stmt::Assignment(a) => match &a.value {
4563                Expr::Literal(Value::Int(n)) => assert_eq!(*n, -99),
4564                other => panic!("expected int, got {:?}", other),
4565            },
4566            other => panic!("expected assignment, got {:?}", other),
4567        }
4568    }
4569
4570    #[test]
4571    fn value_float_preserved() {
4572        let result = parse("PI=3.14").unwrap();
4573        match &result.statements[0] {
4574            Stmt::Assignment(a) => match &a.value {
4575                Expr::Literal(Value::Float(f)) => assert!((*f - 3.14).abs() < 0.001),
4576                other => panic!("expected float, got {:?}", other),
4577            },
4578            other => panic!("expected assignment, got {:?}", other),
4579        }
4580    }
4581
4582    #[test]
4583    fn value_string_preserved() {
4584        let result = parse(r#"echo "hello world""#).unwrap();
4585        match &result.statements[0] {
4586            Stmt::Command(cmd) => {
4587                assert_eq!(cmd.name, "echo");
4588                match &cmd.args[0] {
4589                    Arg::Positional(Expr::Literal(Value::String(s))) => {
4590                        assert_eq!(s, "hello world");
4591                    }
4592                    other => panic!("expected string arg, got {:?}", other),
4593                }
4594            }
4595            other => panic!("expected command, got {:?}", other),
4596        }
4597    }
4598
4599    #[test]
4600    fn value_string_with_escapes_preserved() {
4601        let result = parse(r#"echo "line1\nline2""#).unwrap();
4602        match &result.statements[0] {
4603            Stmt::Command(cmd) => match &cmd.args[0] {
4604                Arg::Positional(Expr::Literal(Value::String(s))) => {
4605                    assert_eq!(s, "line1\nline2");
4606                }
4607                other => panic!("expected string, got {:?}", other),
4608            },
4609            other => panic!("expected command, got {:?}", other),
4610        }
4611    }
4612
4613    #[test]
4614    fn value_command_name_preserved() {
4615        let result = parse("my-command").unwrap();
4616        match &result.statements[0] {
4617            Stmt::Command(cmd) => assert_eq!(cmd.name, "my-command"),
4618            other => panic!("expected command, got {:?}", other),
4619        }
4620    }
4621
4622    #[test]
4623    fn value_assignment_name_preserved() {
4624        let result = parse("MY_VAR=1").unwrap();
4625        match &result.statements[0] {
4626            Stmt::Assignment(a) => assert_eq!(a.name(), "MY_VAR"),
4627            other => panic!("expected assignment, got {:?}", other),
4628        }
4629    }
4630
4631    #[test]
4632    fn value_for_variable_preserved() {
4633        let result = parse("for ITEM in items; do echo; done").unwrap();
4634        match &result.statements[0] {
4635            Stmt::For(f) => assert_eq!(f.variable, "ITEM"),
4636            other => panic!("expected for, got {:?}", other),
4637        }
4638    }
4639
4640    #[test]
4641    fn value_varref_name_preserved() {
4642        let result = parse("echo ${MESSAGE}").unwrap();
4643        match &result.statements[0] {
4644            Stmt::Command(cmd) => match &cmd.args[0] {
4645                Arg::Positional(Expr::VarRef(path)) => {
4646                    assert_eq!(path.segments.len(), 1);
4647                    let VarSegment::Field(name) = &path.segments[0] else {
4648                        panic!("expected root field, got {:?}", path.segments[0]);
4649                    };
4650                    assert_eq!(name, "MESSAGE");
4651                }
4652                other => panic!("expected varref, got {:?}", other),
4653            },
4654            other => panic!("expected command, got {:?}", other),
4655        }
4656    }
4657
4658    #[test]
4659    fn value_varref_field_access_preserved() {
4660        let result = parse("echo ${RESULT.data}").unwrap();
4661        match &result.statements[0] {
4662            Stmt::Command(cmd) => match &cmd.args[0] {
4663                Arg::Positional(Expr::VarRef(path)) => {
4664                    // A dotted `${RESULT.data}` keeps both as Field — the root
4665                    // and a dotted segment (resolution turns the latter into the
4666                    // brackets-only error).
4667                    assert_eq!(path.segments.len(), 2);
4668                    let VarSegment::Field(a) = &path.segments[0] else {
4669                        panic!("expected field, got {:?}", path.segments[0]);
4670                    };
4671                    let VarSegment::Field(b) = &path.segments[1] else {
4672                        panic!("expected field, got {:?}", path.segments[1]);
4673                    };
4674                    assert_eq!(a, "RESULT");
4675                    assert_eq!(b, "data");
4676                }
4677                other => panic!("expected varref, got {:?}", other),
4678            },
4679            other => panic!("expected command, got {:?}", other),
4680        }
4681    }
4682
4683    #[test]
4684    fn value_varref_index_parsed() {
4685        // Bracket subscripts are now parsed into typed segments (native
4686        // collection access), not filtered out.
4687        let result = parse("echo ${ITEMS[0]}").unwrap();
4688        match &result.statements[0] {
4689            Stmt::Command(cmd) => match &cmd.args[0] {
4690                Arg::Positional(Expr::VarRef(path)) => {
4691                    assert_eq!(path.segments.len(), 2);
4692                    let VarSegment::Field(name) = &path.segments[0] else {
4693                        panic!("expected root field, got {:?}", path.segments[0]);
4694                    };
4695                    assert_eq!(name, "ITEMS");
4696                    assert_eq!(path.segments[1], VarSegment::Index(0));
4697                }
4698                other => panic!("expected varref, got {:?}", other),
4699            },
4700            other => panic!("expected command, got {:?}", other),
4701        }
4702    }
4703
4704    #[test]
4705    fn value_named_arg_preserved() {
4706        // Bareword key=value parses as WordAssign — the kernel decides per
4707        // command whether to route into args.named (export/alias) or
4708        // stringify as a positional.
4709        let result = parse("cmd count=42").unwrap();
4710        match &result.statements[0] {
4711            Stmt::Command(cmd) => {
4712                assert_eq!(cmd.name, "cmd");
4713                match &cmd.args[0] {
4714                    Arg::WordAssign { key, value } => {
4715                        assert_eq!(key, "count");
4716                        match value {
4717                            Expr::Literal(Value::Int(n)) => assert_eq!(*n, 42),
4718                            other => panic!("expected int, got {:?}", other),
4719                        }
4720                    }
4721                    other => panic!("expected WordAssign arg, got {:?}", other),
4722                }
4723            }
4724            other => panic!("expected command, got {:?}", other),
4725        }
4726    }
4727
4728    #[test]
4729    fn value_function_def_name_preserved() {
4730        let result = parse("greet() { echo }").unwrap();
4731        match &result.statements[0] {
4732            Stmt::ToolDef(t) => {
4733                assert_eq!(t.name, "greet");
4734                assert!(t.params.is_empty());
4735            }
4736            other => panic!("expected function def, got {:?}", other),
4737        }
4738    }
4739
4740    // ═══════════════════════════════════════════════════════════════════════════
4741    // New Feature Tests - Comparisons, Interpolation, Nested Structures
4742    // ═══════════════════════════════════════════════════════════════════════════
4743
4744    #[test]
4745    fn parse_comparison_equals() {
4746        // Shell-compatible: use [[ ]] for comparisons
4747        let result = parse("if [[ ${X} == 5 ]]; then echo; fi").unwrap();
4748        match &result.statements[0] {
4749            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
4750                Expr::Test(test) => match test.as_ref() {
4751                    TestExpr::Comparison { left, op, right } => {
4752                        assert!(matches!(left.as_ref(), Expr::VarRef(_)));
4753                        assert_eq!(*op, TestCmpOp::Eq);
4754                        match right.as_ref() {
4755                            Expr::Literal(Value::Int(n)) => assert_eq!(*n, 5),
4756                            other => panic!("expected int, got {:?}", other),
4757                        }
4758                    }
4759                    other => panic!("expected comparison, got {:?}", other),
4760                },
4761                other => panic!("expected test expr, got {:?}", other),
4762            },
4763            other => panic!("expected if, got {:?}", other),
4764        }
4765    }
4766
4767    #[test]
4768    fn parse_comparison_not_equals() {
4769        let result = parse("if [[ ${X} != 0 ]]; then echo; fi").unwrap();
4770        match &result.statements[0] {
4771            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
4772                Expr::Test(test) => match test.as_ref() {
4773                    TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::NotEq),
4774                    other => panic!("expected comparison, got {:?}", other),
4775                },
4776                other => panic!("expected test expr, got {:?}", other),
4777            },
4778            other => panic!("expected if, got {:?}", other),
4779        }
4780    }
4781
4782    #[test]
4783    fn parse_comparison_less_than() {
4784        let result = parse("if [[ ${COUNT} -lt 10 ]]; then echo; fi").unwrap();
4785        match &result.statements[0] {
4786            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
4787                Expr::Test(test) => match test.as_ref() {
4788                    TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::NumLt),
4789                    other => panic!("expected comparison, got {:?}", other),
4790                },
4791                other => panic!("expected test expr, got {:?}", other),
4792            },
4793            other => panic!("expected if, got {:?}", other),
4794        }
4795    }
4796
4797    #[test]
4798    fn parse_comparison_greater_than() {
4799        let result = parse("if [[ ${COUNT} -gt 0 ]]; then echo; fi").unwrap();
4800        match &result.statements[0] {
4801            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
4802                Expr::Test(test) => match test.as_ref() {
4803                    TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::NumGt),
4804                    other => panic!("expected comparison, got {:?}", other),
4805                },
4806                other => panic!("expected test expr, got {:?}", other),
4807            },
4808            other => panic!("expected if, got {:?}", other),
4809        }
4810    }
4811
4812    #[test]
4813    fn parse_comparison_less_equal() {
4814        let result = parse("if [[ ${X} -le 100 ]]; then echo; fi").unwrap();
4815        match &result.statements[0] {
4816            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
4817                Expr::Test(test) => match test.as_ref() {
4818                    TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::NumLtEq),
4819                    other => panic!("expected comparison, got {:?}", other),
4820                },
4821                other => panic!("expected test expr, got {:?}", other),
4822            },
4823            other => panic!("expected if, got {:?}", other),
4824        }
4825    }
4826
4827    #[test]
4828    fn parse_comparison_greater_equal() {
4829        let result = parse("if [[ ${X} -ge 1 ]]; then echo; fi").unwrap();
4830        match &result.statements[0] {
4831            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
4832                Expr::Test(test) => match test.as_ref() {
4833                    TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::NumGtEq),
4834                    other => panic!("expected comparison, got {:?}", other),
4835                },
4836                other => panic!("expected test expr, got {:?}", other),
4837            },
4838            other => panic!("expected if, got {:?}", other),
4839        }
4840    }
4841
4842    #[test]
4843    fn parse_regex_match() {
4844        let result = parse(r#"if [[ ${NAME} =~ "^test" ]]; then echo; fi"#).unwrap();
4845        match &result.statements[0] {
4846            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
4847                Expr::Test(test) => match test.as_ref() {
4848                    TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::Match),
4849                    other => panic!("expected comparison, got {:?}", other),
4850                },
4851                other => panic!("expected test expr, got {:?}", other),
4852            },
4853            other => panic!("expected if, got {:?}", other),
4854        }
4855    }
4856
4857    #[test]
4858    fn parse_regex_not_match() {
4859        let result = parse(r#"if [[ ${NAME} !~ "^test" ]]; then echo; fi"#).unwrap();
4860        match &result.statements[0] {
4861            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
4862                Expr::Test(test) => match test.as_ref() {
4863                    TestExpr::Comparison { op, .. } => assert_eq!(*op, TestCmpOp::NotMatch),
4864                    other => panic!("expected comparison, got {:?}", other),
4865                },
4866                other => panic!("expected test expr, got {:?}", other),
4867            },
4868            other => panic!("expected if, got {:?}", other),
4869        }
4870    }
4871
4872    #[test]
4873    fn parse_string_interpolation() {
4874        let result = parse(r#"echo "Hello ${NAME}!""#).unwrap();
4875        match &result.statements[0] {
4876            Stmt::Command(cmd) => match &cmd.args[0] {
4877                Arg::Positional(Expr::Interpolated(parts)) => {
4878                    assert_eq!(parts.len(), 3);
4879                    match &parts[0] {
4880                        StringPart::Literal(s) => assert_eq!(s, "Hello "),
4881                        other => panic!("expected literal, got {:?}", other),
4882                    }
4883                    match &parts[1] {
4884                        StringPart::Var(path) => {
4885                            assert_eq!(path.segments.len(), 1);
4886                            let VarSegment::Field(name) = &path.segments[0] else {
4887                                panic!("expected root field, got {:?}", path.segments[0]);
4888                            };
4889                            assert_eq!(name, "NAME");
4890                        }
4891                        other => panic!("expected var, got {:?}", other),
4892                    }
4893                    match &parts[2] {
4894                        StringPart::Literal(s) => assert_eq!(s, "!"),
4895                        other => panic!("expected literal, got {:?}", other),
4896                    }
4897                }
4898                other => panic!("expected interpolated, got {:?}", other),
4899            },
4900            other => panic!("expected command, got {:?}", other),
4901        }
4902    }
4903
4904    #[test]
4905    fn parse_string_interpolation_multiple_vars() {
4906        let result = parse(r#"echo "${FIRST} and ${SECOND}""#).unwrap();
4907        match &result.statements[0] {
4908            Stmt::Command(cmd) => match &cmd.args[0] {
4909                Arg::Positional(Expr::Interpolated(parts)) => {
4910                    // ${FIRST} + " and " + ${SECOND} = 3 parts
4911                    assert_eq!(parts.len(), 3);
4912                    assert!(matches!(&parts[0], StringPart::Var(_)));
4913                    assert!(matches!(&parts[1], StringPart::Literal(_)));
4914                    assert!(matches!(&parts[2], StringPart::Var(_)));
4915                }
4916                other => panic!("expected interpolated, got {:?}", other),
4917            },
4918            other => panic!("expected command, got {:?}", other),
4919        }
4920    }
4921
4922    #[test]
4923    fn parse_empty_function_body() {
4924        let result = parse("empty() { }").unwrap();
4925        match &result.statements[0] {
4926            Stmt::ToolDef(t) => {
4927                assert_eq!(t.name, "empty");
4928                assert!(t.params.is_empty());
4929                assert!(t.body.is_empty());
4930            }
4931            other => panic!("expected function def, got {:?}", other),
4932        }
4933    }
4934
4935    #[test]
4936    fn parse_bash_style_function() {
4937        let result = parse("function greet { echo hello }").unwrap();
4938        match &result.statements[0] {
4939            Stmt::ToolDef(t) => {
4940                assert_eq!(t.name, "greet");
4941                assert!(t.params.is_empty());
4942                assert_eq!(t.body.len(), 1);
4943            }
4944            other => panic!("expected function def, got {:?}", other),
4945        }
4946    }
4947
4948    #[test]
4949    fn parse_comparison_string_values() {
4950        let result = parse(r#"if [[ ${STATUS} == "ok" ]]; then echo; fi"#).unwrap();
4951        match &result.statements[0] {
4952            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
4953                Expr::Test(test) => match test.as_ref() {
4954                    TestExpr::Comparison { left, op, right } => {
4955                        assert!(matches!(left.as_ref(), Expr::VarRef(_)));
4956                        assert_eq!(*op, TestCmpOp::Eq);
4957                        match right.as_ref() {
4958                            Expr::Literal(Value::String(s)) => assert_eq!(s, "ok"),
4959                            other => panic!("expected string, got {:?}", other),
4960                        }
4961                    }
4962                    other => panic!("expected comparison, got {:?}", other),
4963                },
4964                other => panic!("expected test expr, got {:?}", other),
4965            },
4966            other => panic!("expected if, got {:?}", other),
4967        }
4968    }
4969
4970    // ═══════════════════════════════════════════════════════════════════════════
4971    // Command Substitution Tests
4972    // ═══════════════════════════════════════════════════════════════════════════
4973
4974    #[test]
4975    fn parse_cmd_subst_simple() {
4976        let result = parse("X=$(echo)").unwrap();
4977        match &result.statements[0] {
4978            Stmt::Assignment(a) => {
4979                assert_eq!(a.name(), "X");
4980                assert_eq!(subst_cmd(&a.value).name, "echo");
4981            }
4982            other => panic!("expected assignment, got {:?}", other),
4983        }
4984    }
4985
4986    #[test]
4987    fn parse_cmd_subst_with_args() {
4988        let result = parse(r#"X=$(fetch url="http://example.com")"#).unwrap();
4989        match &result.statements[0] {
4990            Stmt::Assignment(a) => {
4991                let cmd = subst_cmd(&a.value);
4992                assert_eq!(cmd.name, "fetch");
4993                assert_eq!(cmd.args.len(), 1);
4994                match &cmd.args[0] {
4995                    Arg::WordAssign { key, .. } => assert_eq!(key, "url"),
4996                    other => panic!("expected WordAssign arg, got {:?}", other),
4997                }
4998            }
4999            other => panic!("expected assignment, got {:?}", other),
5000        }
5001    }
5002
5003    #[test]
5004    fn parse_cmd_subst_pipeline() {
5005        let result = parse("X=$(cat file | grep pattern)").unwrap();
5006        match &result.statements[0] {
5007            Stmt::Assignment(a) => {
5008                let pipeline = subst_pipeline(&a.value);
5009                assert_eq!(pipeline_commands(pipeline).len(), 2);
5010                assert_eq!(pipeline_commands(pipeline)[0].name, "cat");
5011                assert_eq!(pipeline_commands(pipeline)[1].name, "grep");
5012            }
5013            other => panic!("expected assignment, got {:?}", other),
5014        }
5015    }
5016
5017    #[test]
5018    fn parse_cmd_subst_with_redirect() {
5019        // Regression: `cmd_subst_parser` used to hardcode `redirects: vec![]`,
5020        // so a redirect inside `$()` was a parse error. A command carrying a
5021        // redirect stays a `Stmt::Pipeline` (`pipeline_into_stmt` only unwraps
5022        // redirect-free commands), so read it back through `subst_pipeline`.
5023        let result = parse("X=$(echo hi > out.txt)").unwrap();
5024        match &result.statements[0] {
5025            Stmt::Assignment(a) => {
5026                let pipeline = subst_pipeline(&a.value);
5027                assert_eq!(pipeline_commands(pipeline).len(), 1);
5028                let cmd = pipeline_commands(pipeline)[0];
5029                assert_eq!(cmd.name, "echo");
5030                assert_eq!(cmd.redirects.len(), 1);
5031                assert!(matches!(
5032                    cmd.redirects[0].kind,
5033                    RedirectKind::StdoutOverwrite
5034                ));
5035            }
5036            other => panic!("expected assignment, got {:?}", other),
5037        }
5038    }
5039
5040    #[test]
5041    fn parse_cmd_subst_redirect_target_with_nested_subst() {
5042        // The cycle-break's sharpest case: a `$(...)` in the redirect *target*,
5043        // inside a `$(...)`. This exercises cmd_subst → redirect → (recursive
5044        // expr) → cmd_subst, the path that used to recurse unboundedly during
5045        // parser construction (stack overflow). It must parse; the target is a
5046        // nested `CommandSubst`.
5047        let result = parse("X=$(echo hi > $(echo f))").unwrap();
5048        match &result.statements[0] {
5049            Stmt::Assignment(a) => {
5050                let pipeline = subst_pipeline(&a.value);
5051                assert_eq!(pipeline_commands(pipeline).len(), 1);
5052                let cmd = pipeline_commands(pipeline)[0];
5053                assert_eq!(cmd.name, "echo");
5054                assert_eq!(cmd.redirects.len(), 1);
5055                assert!(
5056                    matches!(cmd.redirects[0].target, Expr::CommandSubst(_)),
5057                    "redirect target should be a nested command substitution, got {:?}",
5058                    cmd.redirects[0].target
5059                );
5060            }
5061            other => panic!("expected assignment, got {:?}", other),
5062        }
5063    }
5064
5065    #[test]
5066    fn parse_cmd_subst_chain_with_redirect() {
5067        // A redirect in a chained `$()` body binds to its own command, not to
5068        // the chain: `$(a && b > f)` → AndChain{ left: a, right: (b > f) }, with
5069        // the redirect on `b` only.
5070        let result = parse("X=$(echo a && echo b > out.txt)").unwrap();
5071        let stmts = match &result.statements[0] {
5072            Stmt::Assignment(a) => match &a.value {
5073                Expr::CommandSubst(s) => s,
5074                other => panic!("expected command subst, got {:?}", other),
5075            },
5076            other => panic!("expected assignment, got {:?}", other),
5077        };
5078        match stmts.as_slice() {
5079            [Stmt::AndChain { left, right }] => {
5080                // `echo a` is redirect-free → unwrapped to Stmt::Command.
5081                assert!(
5082                    matches!(**left, Stmt::Command(_)),
5083                    "left of && should be a bare command, got {:?}",
5084                    left
5085                );
5086                // `echo b > out.txt` carries a redirect → stays Stmt::Pipeline.
5087                match &**right {
5088                    Stmt::Pipeline(p) => {
5089                        assert_eq!(pipeline_commands(p).len(), 1);
5090                        assert_eq!(pipeline_commands(p)[0].name, "echo");
5091                        assert_eq!(pipeline_commands(p)[0].redirects.len(), 1);
5092                    }
5093                    other => panic!("right should be a redirect-bearing pipeline, got {:?}", other),
5094                }
5095            }
5096            other => panic!("expected a single AndChain, got {:?}", other),
5097        }
5098    }
5099
5100    #[test]
5101    fn parse_cmd_subst_in_condition() {
5102        // Shell-compatible: conditions are commands, not command substitutions
5103        let result = parse("if kaish-validate; then echo; fi").unwrap();
5104        match &result.statements[0] {
5105            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
5106                Expr::Command(cmd) => {
5107                    assert_eq!(cmd.name, "kaish-validate");
5108                }
5109                other => panic!("expected command, got {:?}", other),
5110            },
5111            other => panic!("expected if, got {:?}", other),
5112        }
5113    }
5114
5115    // ═══════════════════════════════════════════════════════════════════════════
5116    // GH #194: control structures inside an UNQUOTED `$(...)` (route C)
5117    //
5118    // Before this, `x="$(for f in a b; do echo $f; done)"` (quoted) worked
5119    // because `parse_interpolated_string` recursively calls the top-level
5120    // `parse()`, but `echo $(for f in a b; do echo $f; done)` (unquoted) was
5121    // a parse error: `cmd_subst_parser` had its own hand-rolled
5122    // pipeline/`&&`/`||` grammar with control structures intentionally out
5123    // of scope. Route C replaced that with a balance-captured token slice
5124    // parsed through the full program grammar from inside a `.try_map()`
5125    // closure at parse time — see `cmd_subst_parser`'s doc comment for why
5126    // it has to be parse time, not build time.
5127    // ═══════════════════════════════════════════════════════════════════════════
5128
5129    #[test]
5130    fn parse_cmd_subst_unquoted_for_loop() {
5131        let result = parse("X=$(for f in a b; do echo $f; done)").unwrap();
5132        let stmts = match &result.statements[0] {
5133            Stmt::Assignment(a) => match &a.value {
5134                Expr::CommandSubst(s) => s,
5135                other => panic!("expected command subst, got {:?}", other),
5136            },
5137            other => panic!("expected assignment, got {:?}", other),
5138        };
5139        match stmts.as_slice() {
5140            [Stmt::For(f)] => {
5141                assert_eq!(f.variable, "f");
5142                assert_eq!(f.items.len(), 2);
5143                assert!(matches!(f.body.as_slice(), [Stmt::Command(c)] if c.name == "echo"));
5144            }
5145            other => panic!("expected a single For statement, got {:?}", other),
5146        }
5147    }
5148
5149    #[test]
5150    fn parse_cmd_subst_unquoted_while_loop() {
5151        let result = parse("X=$(while false; do echo x; done)").unwrap();
5152        let stmts = match &result.statements[0] {
5153            Stmt::Assignment(a) => match &a.value {
5154                Expr::CommandSubst(s) => s,
5155                other => panic!("expected command subst, got {:?}", other),
5156            },
5157            other => panic!("expected assignment, got {:?}", other),
5158        };
5159        assert!(
5160            matches!(stmts.as_slice(), [Stmt::While(w)] if matches!(w.body.as_slice(), [Stmt::Command(c)] if c.name == "echo")),
5161            "expected a single While statement, got {stmts:?}"
5162        );
5163    }
5164
5165    #[test]
5166    fn parse_cmd_subst_unquoted_if_else() {
5167        let result = parse("X=$(if true; then echo one; else echo two; fi)").unwrap();
5168        let stmts = match &result.statements[0] {
5169            Stmt::Assignment(a) => match &a.value {
5170                Expr::CommandSubst(s) => s,
5171                other => panic!("expected command subst, got {:?}", other),
5172            },
5173            other => panic!("expected assignment, got {:?}", other),
5174        };
5175        match stmts.as_slice() {
5176            [Stmt::If(i)] => {
5177                assert!(i.else_branch.is_some(), "expected an else branch");
5178                assert!(matches!(i.then_branch.as_slice(), [Stmt::Command(c)] if c.name == "echo"));
5179            }
5180            other => panic!("expected a single If statement, got {:?}", other),
5181        }
5182    }
5183
5184    #[test]
5185    fn parse_cmd_subst_unquoted_case() {
5186        // An unpaired case-branch pattern (`a)`, no leading `(`) is the
5187        // sharpest case for the balance tracker: its `)` has no matching
5188        // open on the depth counter, so it must not be read as the
5189        // substitution's own close (see `CmdSubstFrames`).
5190        let result = parse("X=$(case a in a) echo hit;; esac)").unwrap();
5191        let stmts = match &result.statements[0] {
5192            Stmt::Assignment(a) => match &a.value {
5193                Expr::CommandSubst(s) => s,
5194                other => panic!("expected command subst, got {:?}", other),
5195            },
5196            other => panic!("expected assignment, got {:?}", other),
5197        };
5198        match stmts.as_slice() {
5199            [Stmt::Case(c)] => {
5200                assert_eq!(c.branches.len(), 1);
5201                assert_eq!(c.branches[0].patterns, vec!["a".to_string()]);
5202            }
5203            other => panic!("expected a single Case statement, got {:?}", other),
5204        }
5205    }
5206
5207    #[test]
5208    fn parse_cmd_subst_unquoted_case_with_parenthesized_pattern() {
5209        // The *paired* form (`(a)`) — a `Paren` frame handles this one on
5210        // its own, since the `(` pushed it.
5211        let result = parse("X=$(case a in (a) echo hit;; esac)").unwrap();
5212        let stmts = match &result.statements[0] {
5213            Stmt::Assignment(a) => match &a.value {
5214                Expr::CommandSubst(s) => s,
5215                other => panic!("expected command subst, got {:?}", other),
5216            },
5217            other => panic!("expected assignment, got {:?}", other),
5218        };
5219        assert!(
5220            matches!(stmts.as_slice(), [Stmt::Case(c)] if c.branches.len() == 1),
5221            "expected a single Case statement, got {stmts:?}"
5222        );
5223    }
5224
5225    #[test]
5226    fn parse_cmd_subst_unquoted_case_parenthesized_pattern_with_bareword_esac_in_body() {
5227        // The parenthesized twin of
5228        // `parse_cmd_subst_unquoted_esac_as_bareword_inside_still_open_case`:
5229        // the FIRST branch's pattern is `(a)` instead of bare `a)`. Popping
5230        // the `Paren` frame the leading `(` pushed used to leave the `Case`
5231        // frame beneath stuck at `awaiting_pattern: true` — the contract
5232        // `CmdSubstFrame::Case`'s own docstring states ("false once a
5233        // pattern's `)` has been consumed") went unmet for this spelling —
5234        // so the bareword `esac` in `y=esac` (branch `a)`'s whole body) read
5235        // as the case's own closer, popping the frame early and corrupting
5236        // everything the tracker reads after it.
5237        let result = parse("X=$(case a in (a) y=esac;; b) echo two;; esac)").unwrap();
5238        let stmts = match &result.statements[0] {
5239            Stmt::Assignment(a) => match &a.value {
5240                Expr::CommandSubst(s) => s,
5241                other => panic!("expected command subst, got {:?}", other),
5242            },
5243            other => panic!("expected assignment, got {:?}", other),
5244        };
5245        match stmts.as_slice() {
5246            [Stmt::Case(c)] => {
5247                assert_eq!(c.branches.len(), 2);
5248                assert_eq!(c.branches[0].patterns, vec!["a".to_string()]);
5249                assert_eq!(c.branches[1].patterns, vec!["b".to_string()]);
5250            }
5251            other => panic!("expected a single Case statement with two branches, got {:?}", other),
5252        }
5253    }
5254
5255    #[test]
5256    fn parse_cmd_subst_unquoted_nested_case_parenthesized_pattern_esac_in_outer_body() {
5257        // Nesting: a `case` inside a `case` branch's body, both inside
5258        // `$(...)`, both patterns parenthesized. The inner case resolves
5259        // and closes cleanly (its own `;;` sets `awaiting_pattern` back to
5260        // `true` before its `esac`, independent of the bug), which masked
5261        // this defect in isolation — the outer `Case` frame's stuck
5262        // `awaiting_pattern: true` only surfaces once the inner case's
5263        // frame is popped and the outer frame is innermost again: the
5264        // bareword `esac` in the outer branch's own `y=esac`, reached AFTER
5265        // the inner case fully closes, must not read as the outer case's
5266        // closer either.
5267        let result = parse(
5268            "X=$(case a in (a) case b in (b) echo z;; esac; y=esac;; c) echo two;; esac)",
5269        )
5270        .unwrap();
5271        let stmts = match &result.statements[0] {
5272            Stmt::Assignment(a) => match &a.value {
5273                Expr::CommandSubst(s) => s,
5274                other => panic!("expected command subst, got {:?}", other),
5275            },
5276            other => panic!("expected assignment, got {:?}", other),
5277        };
5278        match stmts.as_slice() {
5279            [Stmt::Case(c)] => {
5280                assert_eq!(c.branches.len(), 2);
5281                assert_eq!(c.branches[0].patterns, vec!["a".to_string()]);
5282                assert_eq!(c.branches[1].patterns, vec!["c".to_string()]);
5283            }
5284            other => panic!("expected a single Case statement with two branches, got {:?}", other),
5285        }
5286    }
5287
5288    #[test]
5289    fn parse_cmd_subst_unquoted_case_eq_argv_key() {
5290        // `case` is a valid `key=value` argv key (same as `in=a`/`do=b` —
5291        // see `keyword_key_argv_assignment_parses` in parser_tests.rs), but
5292        // it's the only one of those keywords that pushes a structural
5293        // frame on the `$(...)` balance tracker. Pushing one unconditionally
5294        // reads `case=x` as a case-statement opener, then reads the
5295        // substitution's real closing `)` as a pattern terminator instead —
5296        // the tracker never sees a `)` to stop on and reports "unterminated"
5297        // even though the input is well-formed.
5298        let result = parse("X=$(echo case=x)").unwrap();
5299        let stmts = match &result.statements[0] {
5300            Stmt::Assignment(a) => match &a.value {
5301                Expr::CommandSubst(s) => s,
5302                other => panic!("expected command subst, got {:?}", other),
5303            },
5304            other => panic!("expected assignment, got {:?}", other),
5305        };
5306        let cmd = match stmts.as_slice() {
5307            [Stmt::Command(c)] => c,
5308            other => panic!("expected a single echo command, got {:?}", other),
5309        };
5310        assert_eq!(cmd.name, "echo");
5311        match &cmd.args[0] {
5312            Arg::WordAssign { key, value } => {
5313                assert_eq!(key, "case");
5314                match value {
5315                    Expr::Literal(Value::String(s)) => assert_eq!(s, "x"),
5316                    other => panic!("expected string \"x\", got {:?}", other),
5317                }
5318            }
5319            other => panic!("expected WordAssign arg, got {:?}", other),
5320        }
5321    }
5322
5323    #[test]
5324    fn parse_cmd_subst_unquoted_case_eq_argv_key_with_sibling_keyword_keys() {
5325        // `case=x` alongside the already-covered sibling keyword keys
5326        // (`do=y`), inside `$(...)` — the balance tracker must treat all of
5327        // them uniformly.
5328        let result = parse("X=$(tool case=x do=y)").unwrap();
5329        let stmts = match &result.statements[0] {
5330            Stmt::Assignment(a) => match &a.value {
5331                Expr::CommandSubst(s) => s,
5332                other => panic!("expected command subst, got {:?}", other),
5333            },
5334            other => panic!("expected assignment, got {:?}", other),
5335        };
5336        let cmd = match stmts.as_slice() {
5337            [Stmt::Command(c)] => c,
5338            other => panic!("expected a single tool command, got {:?}", other),
5339        };
5340        assert_eq!(cmd.name, "tool");
5341        assert_eq!(cmd.args.len(), 2);
5342        assert!(matches!(&cmd.args[0], Arg::WordAssign { key, .. } if key == "case"));
5343        assert!(matches!(&cmd.args[1], Arg::WordAssign { key, .. } if key == "do"));
5344    }
5345
5346    #[test]
5347    fn parse_cmd_subst_unquoted_case_inside_nested_subst() {
5348        // A flat depth counter conflates a case-branch pattern's unpaired
5349        // `)` with a nested `$(...)`'s own close once both are open at
5350        // once: `depth > 0` fires before the case check ever runs, so the
5351        // pattern terminator wrongly closes the inner substitution instead
5352        // of being consumed as body text (see `CmdSubstFrames`). The stack
5353        // asks each `)` about the frame it actually belongs to instead.
5354        let result = parse("X=$(echo $(case b in b) echo x;; esac))").unwrap();
5355        let outer_stmts = match &result.statements[0] {
5356            Stmt::Assignment(a) => match &a.value {
5357                Expr::CommandSubst(s) => s,
5358                other => panic!("expected command subst, got {:?}", other),
5359            },
5360            other => panic!("expected assignment, got {:?}", other),
5361        };
5362        let outer_cmd = match outer_stmts.as_slice() {
5363            [Stmt::Command(c)] => c,
5364            other => panic!("expected a single echo command, got {:?}", other),
5365        };
5366        assert_eq!(outer_cmd.name, "echo");
5367        let inner_stmts = match &outer_cmd.args[0] {
5368            Arg::Positional(Expr::CommandSubst(s)) => s,
5369            other => panic!("expected nested command subst arg, got {:?}", other),
5370        };
5371        assert!(
5372            matches!(inner_stmts.as_slice(), [Stmt::Case(c)] if c.branches.len() == 1),
5373            "expected a single Case statement inside the inner $(), got {inner_stmts:?}"
5374        );
5375    }
5376
5377    #[test]
5378    fn parse_cmd_subst_unquoted_esac_as_bareword() {
5379        // `Esac` is also the literal bareword "esac" in argument position
5380        // (`keyword_as_bareword`, same as `done`/`fi`). No case is open at
5381        // all here, so the tracker must not touch a `Case` frame it never
5382        // pushed.
5383        let result = parse("X=$(echo esac)").unwrap();
5384        let stmts = match &result.statements[0] {
5385            Stmt::Assignment(a) => match &a.value {
5386                Expr::CommandSubst(s) => s,
5387                other => panic!("expected command subst, got {:?}", other),
5388            },
5389            other => panic!("expected assignment, got {:?}", other),
5390        };
5391        let cmd = match stmts.as_slice() {
5392            [Stmt::Command(c)] => c,
5393            other => panic!("expected a single echo command, got {:?}", other),
5394        };
5395        assert_eq!(cmd.name, "echo");
5396        assert!(
5397            matches!(&cmd.args[0], Arg::Positional(Expr::Literal(Value::String(s))) if s == "esac"),
5398            "expected \"esac\" as a literal argument, got {:?}",
5399            cmd.args[0]
5400        );
5401    }
5402
5403    #[test]
5404    fn parse_cmd_subst_unquoted_esac_as_bareword_inside_still_open_case() {
5405        // The sharper form of the previous test: `esac` as a bareword
5406        // *inside a case that is genuinely still open* (its own closer
5407        // hasn't been reached yet) — `y=esac` is the first branch's whole
5408        // body. Popping the `Case` frame whenever it's merely innermost
5409        // (rather than only while `awaiting_pattern`) treats this bareword
5410        // as the closer too, and the branch's real `;;`/pattern/`esac`
5411        // tokens then run with no `Case` frame protecting them — the same
5412        // failure mode a flat counter has, just one level more specific.
5413        let result = parse("X=$(case a in a) y=esac;; b) echo two;; esac)").unwrap();
5414        let stmts = match &result.statements[0] {
5415            Stmt::Assignment(a) => match &a.value {
5416                Expr::CommandSubst(s) => s,
5417                other => panic!("expected command subst, got {:?}", other),
5418            },
5419            other => panic!("expected assignment, got {:?}", other),
5420        };
5421        match stmts.as_slice() {
5422            [Stmt::Case(c)] => {
5423                assert_eq!(c.branches.len(), 2);
5424                assert_eq!(c.branches[0].patterns, vec!["a".to_string()]);
5425                assert_eq!(c.branches[1].patterns, vec!["b".to_string()]);
5426            }
5427            other => panic!("expected a single Case statement with two branches, got {:?}", other),
5428        }
5429    }
5430
5431    #[test]
5432    fn parse_quoted_cmd_subst_case_pattern_paren_not_miscounted() {
5433        // `parse_interpolated_string`'s own `$(...)` scan is a THIRD site
5434        // with the same bug class as `CmdSubstFrames`, but at the character
5435        // level: it used to count raw `(`/`)` chars, so a case-branch
5436        // pattern's unpaired `)` truncated the substitution's captured
5437        // content at "case v in v" and the malformed remainder failed to
5438        // parse. It now tokenizes the remainder and reuses
5439        // `find_cmd_subst_close` — the same rule `CmdSubstFrames` uses —
5440        // instead of a second, independent counter.
5441        let result = parse(r#"X="pre $(case v in v) echo x;; esac) post""#).unwrap();
5442        let parts = match &result.statements[0] {
5443            Stmt::Assignment(a) => match &a.value {
5444                Expr::Interpolated(parts) => parts,
5445                other => panic!("expected an interpolated string, got {:?}", other),
5446            },
5447            other => panic!("expected assignment, got {:?}", other),
5448        };
5449        let stmts = match parts.as_slice() {
5450            [StringPart::Literal(pre), StringPart::CommandSubst(stmts), StringPart::Literal(post)] =>
5451            {
5452                assert_eq!(pre, "pre ");
5453                assert_eq!(post, " post");
5454                stmts
5455            }
5456            other => panic!("expected [literal, command subst, literal], got {:?}", other),
5457        };
5458        assert!(
5459            matches!(stmts.as_slice(), [Stmt::Case(c)] if c.branches.len() == 1),
5460            "expected a single Case statement inside the quoted $(...), got {stmts:?}"
5461        );
5462    }
5463
5464    #[test]
5465    fn parse_quoted_cmd_subst_case_parenthesized_pattern_esac_in_body_not_miscounted() {
5466        // `find_cmd_subst_close` (shared with `CmdSubstFrames::step`) drives
5467        // `parse_interpolated_string`'s own `$(...)` scan too — the
5468        // parenthesized-pattern defect must not resurface there either.
5469        let result = parse(r#"X="pre $(case a in (a) y=esac;; b) echo two;; esac) post""#).unwrap();
5470        let parts = match &result.statements[0] {
5471            Stmt::Assignment(a) => match &a.value {
5472                Expr::Interpolated(parts) => parts,
5473                other => panic!("expected an interpolated string, got {:?}", other),
5474            },
5475            other => panic!("expected assignment, got {:?}", other),
5476        };
5477        let stmts = match parts.as_slice() {
5478            [StringPart::Literal(pre), StringPart::CommandSubst(stmts), StringPart::Literal(post)] =>
5479            {
5480                assert_eq!(pre, "pre ");
5481                assert_eq!(post, " post");
5482                stmts
5483            }
5484            other => panic!("expected [literal, command subst, literal], got {:?}", other),
5485        };
5486        match stmts.as_slice() {
5487            [Stmt::Case(c)] => {
5488                assert_eq!(c.branches.len(), 2);
5489                assert_eq!(c.branches[0].patterns, vec!["a".to_string()]);
5490                assert_eq!(c.branches[1].patterns, vec!["b".to_string()]);
5491            }
5492            other => panic!("expected a single Case statement with two branches, got {:?}", other),
5493        }
5494    }
5495
5496    #[test]
5497    fn parse_quoted_cmd_subst_case_eq_argv_key_not_miscounted() {
5498        // Same shared-scan concern for the `case=x` defect: the quoted
5499        // `$(...)` form must accept it too.
5500        let result = parse(r#"X="pre $(echo case=x) post""#).unwrap();
5501        let parts = match &result.statements[0] {
5502            Stmt::Assignment(a) => match &a.value {
5503                Expr::Interpolated(parts) => parts,
5504                other => panic!("expected an interpolated string, got {:?}", other),
5505            },
5506            other => panic!("expected assignment, got {:?}", other),
5507        };
5508        let stmts = match parts.as_slice() {
5509            [StringPart::Literal(pre), StringPart::CommandSubst(stmts), StringPart::Literal(post)] =>
5510            {
5511                assert_eq!(pre, "pre ");
5512                assert_eq!(post, " post");
5513                stmts
5514            }
5515            other => panic!("expected [literal, command subst, literal], got {:?}", other),
5516        };
5517        let cmd = match stmts.as_slice() {
5518            [Stmt::Command(c)] => c,
5519            other => panic!("expected a single echo command, got {:?}", other),
5520        };
5521        assert_eq!(cmd.name, "echo");
5522        assert!(matches!(&cmd.args[0], Arg::WordAssign { key, .. } if key == "case"));
5523    }
5524
5525    #[test]
5526    fn parse_cmd_subst_unquoted_nested_with_control_structure() {
5527        // Nesting question #2 from the route-C verification list: a control
5528        // structure inside the INNER `$(...)`, reached through an outer one.
5529        let result = parse("X=$(echo $(for f in a; do echo $f; done))").unwrap();
5530        let outer_stmts = match &result.statements[0] {
5531            Stmt::Assignment(a) => match &a.value {
5532                Expr::CommandSubst(s) => s,
5533                other => panic!("expected command subst, got {:?}", other),
5534            },
5535            other => panic!("expected assignment, got {:?}", other),
5536        };
5537        let outer_cmd = match outer_stmts.as_slice() {
5538            [Stmt::Command(c)] => c,
5539            other => panic!("expected a single echo command, got {:?}", other),
5540        };
5541        assert_eq!(outer_cmd.name, "echo");
5542        let inner_stmts = match &outer_cmd.args[0] {
5543            Arg::Positional(Expr::CommandSubst(s)) => s,
5544            other => panic!("expected nested command subst arg, got {:?}", other),
5545        };
5546        assert!(
5547            matches!(inner_stmts.as_slice(), [Stmt::For(f)] if f.variable == "f"),
5548            "expected a single For statement inside the inner $(), got {inner_stmts:?}"
5549        );
5550    }
5551
5552    #[test]
5553    fn parse_cmd_subst_unquoted_pipeline() {
5554        // Verification question from GH #194's route-C plan: a pipeline
5555        // (the ordinary, non-compound kind) inside `$(...)` still works once
5556        // the body goes through the real `pipeline_parser` instead of its
5557        // own hand-rolled copy.
5558        let result = parse("X=$(cat f | grep pat | wc -l)").unwrap();
5559        let value = match &result.statements[0] {
5560            Stmt::Assignment(a) => a.value.clone(),
5561            other => panic!("expected assignment, got {:?}", other),
5562        };
5563        let pipeline = subst_pipeline(&value);
5564        assert_eq!(pipeline_commands(pipeline).len(), 3);
5565        assert_eq!(pipeline_commands(pipeline)[2].name, "wc");
5566    }
5567
5568    #[test]
5569    fn parse_quoted_cmd_subst_with_for_loop_still_works() {
5570        // The quoted form was never broken (it goes through
5571        // `parse_interpolated_string`'s own recursive `parse()` call, not
5572        // `cmd_subst_parser`) — pinned so route C cannot regress it.
5573        let result = parse(r#"out="$(for f in a b; do echo $f; done)""#).unwrap();
5574        match &result.statements[0] {
5575            Stmt::Assignment(a) => assert_eq!(a.name(), "out"),
5576            other => panic!("expected assignment, got {:?}", other),
5577        }
5578    }
5579
5580    #[test]
5581    fn parse_cmd_subst_body_error_reports_span_inside_body_not_at_dollar_paren() {
5582        // Route C's sharpest failure mode: a `try_map` rejection deep inside
5583        // a `$(...)` body can lose its span to chumsky's choice/alt
5584        // bookkeeping and surface as a generic error at the `$(` itself
5585        // (`validate_cmd_subst_bodies`'s doc comment has the mechanism).
5586        // `done` here is swallowed as a second positional arg to `echo`
5587        // (`keyword_as_bareword` accepts it as a bareword), so the `for`
5588        // loop's own `done` never arrives and the body runs out of tokens.
5589        let source = "echo $(for f in a; do echo $f done)";
5590        let errs = parse(source).expect_err("missing loop terminator must be a parse error");
5591        let dollar_paren = source.find("$(").expect("fixture contains $(");
5592        assert!(
5593            errs.iter().all(|e| e.span.start > dollar_paren + 1),
5594            "error span must point inside the $() body, not at '$(' itself: {errs:?}"
5595        );
5596        // The message names what's actually missing, not a generic
5597        // "expected expression" from an unrelated sibling `choice` arm.
5598        assert!(
5599            errs.iter().any(|e| e.message.contains("done")),
5600            "expected the missing-`done` diagnostic, got: {errs:?}"
5601        );
5602    }
5603
5604    #[test]
5605    fn parse_cmd_subst_unterminated_reports_error() {
5606        let result = parse("echo $(for f in a; do echo $f; done");
5607        assert!(result.is_err(), "a missing `)` must be a parse error");
5608    }
5609
5610    // ═══════════════════════════════════════════════════════════════════════════
5611    // Inline env-prefix (`NAME=value command`) Tests
5612    // ═══════════════════════════════════════════════════════════════════════════
5613
5614    #[test]
5615    fn parse_env_prefix_single() {
5616        let result = parse("FOO=bar echo hi").unwrap();
5617        match &result.statements[0] {
5618            Stmt::EnvScoped { assignments, body } => {
5619                assert_eq!(assignments.len(), 1);
5620                assert_eq!(assignments[0].name(), "FOO");
5621                assert!(!assignments[0].local);
5622                match body.as_ref() {
5623                    Stmt::Command(cmd) => assert_eq!(cmd.name, "echo"),
5624                    other => panic!("expected command body, got {other:?}"),
5625                }
5626            }
5627            other => panic!("expected env-scoped, got {other:?}"),
5628        }
5629    }
5630
5631    #[test]
5632    fn parse_env_prefix_multiple() {
5633        let result = parse("A=1 B=2 run").unwrap();
5634        match &result.statements[0] {
5635            Stmt::EnvScoped { assignments, body } => {
5636                assert_eq!(assignments.len(), 2);
5637                assert_eq!(assignments[0].name(), "A");
5638                assert_eq!(assignments[1].name(), "B");
5639                assert!(matches!(body.as_ref(), Stmt::Command(c) if c.name == "run"));
5640            }
5641            other => panic!("expected env-scoped, got {other:?}"),
5642        }
5643    }
5644
5645    #[test]
5646    fn parse_bare_assignment_is_not_env_scoped() {
5647        // No command follows — stays a plain (persistent) assignment.
5648        let result = parse("FOO=bar").unwrap();
5649        assert!(
5650            matches!(&result.statements[0], Stmt::Assignment(a) if a.name() == "FOO"),
5651            "got {:?}",
5652            result.statements[0]
5653        );
5654    }
5655
5656    #[test]
5657    fn parse_assignment_then_and_chain_does_not_over_capture() {
5658        // `FOO=bar && echo` is a (persistent) assignment chained with `&&`, NOT
5659        // an env-prefixed command — the `&&` is not a command for the prefix.
5660        let result = parse("FOO=bar && echo hi").unwrap();
5661        match &result.statements[0] {
5662            Stmt::AndChain { left, right } => {
5663                assert!(matches!(left.as_ref(), Stmt::Assignment(a) if a.name() == "FOO"));
5664                assert!(matches!(right.as_ref(), Stmt::Command(c) if c.name == "echo"));
5665            }
5666            other => panic!("expected and-chain, got {other:?}"),
5667        }
5668    }
5669
5670    #[test]
5671    fn parse_env_prefix_pipeline_body() {
5672        let result = parse("FOO=bar cat | grep x").unwrap();
5673        match &result.statements[0] {
5674            Stmt::EnvScoped { assignments, body } => {
5675                assert_eq!(assignments[0].name(), "FOO");
5676                match body.as_ref() {
5677                    Stmt::Pipeline(p) => assert_eq!(pipeline_commands(p).len(), 2),
5678                    other => panic!("expected pipeline body, got {other:?}"),
5679                }
5680            }
5681            other => panic!("expected env-scoped, got {other:?}"),
5682        }
5683    }
5684
5685    // ═══════════════════════════════════════════════════════════════════════════
5686    // Argv-splat rejection (adjacent unquoted words)
5687    // ═══════════════════════════════════════════════════════════════════════════
5688
5689    fn parse_err_message(source: &str) -> String {
5690        parse(source)
5691            .expect_err("expected a parse error")
5692            .iter()
5693            .map(|e| e.message.clone())
5694            .collect::<Vec<_>>()
5695            .join(" ")
5696    }
5697
5698    #[test]
5699    fn argv_splat_cmdsubst_glued_to_path_is_rejected() {
5700        // `/tmp/$(echo x).txt` lexes as 3 adjacent tokens; unquoted it would
5701        // silently splat into 3 args. Reject with a quote-it hint.
5702        let msg = parse_err_message("echo /tmp/$(echo x).txt");
5703        assert!(msg.contains("quote"), "expected quote hint, got: {msg}");
5704    }
5705
5706    #[test]
5707    fn argv_splat_var_glued_to_path_is_rejected() {
5708        assert!(parse("echo $dir/out.txt").is_err());
5709    }
5710
5711    #[test]
5712    fn argv_splat_three_way_glue_is_rejected() {
5713        assert!(parse("echo foo$(echo bar)baz").is_err());
5714    }
5715
5716    #[test]
5717    fn argv_splat_quoted_word_is_accepted() {
5718        // The supported idiom: quote the whole interpolated word.
5719        assert!(parse(r#"echo "/tmp/$(echo x).txt""#).is_ok());
5720        assert!(parse(r#"echo "$dir/out.txt""#).is_ok());
5721    }
5722
5723    #[test]
5724    fn argv_single_token_words_are_not_splat() {
5725        // These lex as a single token each — no adjacency, must still parse.
5726        assert!(parse("echo file.txt").is_ok(), "file.txt");
5727        assert!(parse("echo a.b.c").is_ok(), "a.b.c");
5728        assert!(parse("echo v1.2.3").is_ok(), "v1.2.3");
5729    }
5730
5731    #[test]
5732    fn argv_spaced_words_are_not_splat() {
5733        assert!(parse("echo a b c").is_ok());
5734        assert!(parse("echo /tmp/x $(echo y)").is_ok());
5735    }
5736
5737    #[test]
5738    fn parse_cmd_subst_in_command_arg() {
5739        let result = parse("echo $(whoami)").unwrap();
5740        match &result.statements[0] {
5741            Stmt::Command(cmd) => {
5742                assert_eq!(cmd.name, "echo");
5743                match &cmd.args[0] {
5744                    Arg::Positional(expr) => {
5745                        assert_eq!(subst_cmd(expr).name, "whoami");
5746                    }
5747                    other => panic!("expected command subst, got {:?}", other),
5748                }
5749            }
5750            other => panic!("expected command, got {:?}", other),
5751        }
5752    }
5753
5754    // ═══════════════════════════════════════════════════════════════════════════
5755    // Logical Operator Tests (&&, ||)
5756    // ═══════════════════════════════════════════════════════════════════════════
5757
5758    #[test]
5759    fn parse_condition_and() {
5760        // Shell-compatible: commands chained with &&
5761        let result = parse("if check-a && check-b; then echo; fi").unwrap();
5762        match &result.statements[0] {
5763            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
5764                Expr::BinaryOp { left, op, right } => {
5765                    assert_eq!(*op, BinaryOp::And);
5766                    assert!(matches!(left.as_ref(), Expr::Command(_)));
5767                    assert!(matches!(right.as_ref(), Expr::Command(_)));
5768                }
5769                other => panic!("expected binary op, got {:?}", other),
5770            },
5771            other => panic!("expected if, got {:?}", other),
5772        }
5773    }
5774
5775    #[test]
5776    fn parse_condition_or() {
5777        let result = parse("if try-a || try-b; then echo; fi").unwrap();
5778        match &result.statements[0] {
5779            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
5780                Expr::BinaryOp { left, op, right } => {
5781                    assert_eq!(*op, BinaryOp::Or);
5782                    assert!(matches!(left.as_ref(), Expr::Command(_)));
5783                    assert!(matches!(right.as_ref(), Expr::Command(_)));
5784                }
5785                other => panic!("expected binary op, got {:?}", other),
5786            },
5787            other => panic!("expected if, got {:?}", other),
5788        }
5789    }
5790
5791    #[test]
5792    fn parse_condition_and_or_precedence() {
5793        // a && b || c should parse as (a && b) || c
5794        let result = parse("if cmd-a && cmd-b || cmd-c; then echo; fi").unwrap();
5795        match &result.statements[0] {
5796            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
5797                Expr::BinaryOp { left, op, right } => {
5798                    // Top level should be ||
5799                    assert_eq!(*op, BinaryOp::Or);
5800                    // Left side should be && expression
5801                    match left.as_ref() {
5802                        Expr::BinaryOp { op: inner_op, .. } => {
5803                            assert_eq!(*inner_op, BinaryOp::And);
5804                        }
5805                        other => panic!("expected binary op (&&), got {:?}", other),
5806                    }
5807                    // Right side should be command
5808                    assert!(matches!(right.as_ref(), Expr::Command(_)));
5809                }
5810                other => panic!("expected binary op, got {:?}", other),
5811            },
5812            other => panic!("expected if, got {:?}", other),
5813        }
5814    }
5815
5816    #[test]
5817    fn parse_condition_multiple_and() {
5818        let result = parse("if cmd-a && cmd-b && cmd-c; then echo; fi").unwrap();
5819        match &result.statements[0] {
5820            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
5821                Expr::BinaryOp { left, op, .. } => {
5822                    assert_eq!(*op, BinaryOp::And);
5823                    // Left side should also be &&
5824                    match left.as_ref() {
5825                        Expr::BinaryOp { op: inner_op, .. } => {
5826                            assert_eq!(*inner_op, BinaryOp::And);
5827                        }
5828                        other => panic!("expected binary op, got {:?}", other),
5829                    }
5830                }
5831                other => panic!("expected binary op, got {:?}", other),
5832            },
5833            other => panic!("expected if, got {:?}", other),
5834        }
5835    }
5836
5837    #[test]
5838    fn parse_condition_mixed_comparison_and_logical() {
5839        // Shell-compatible: use [[ ]] for comparisons, && to chain them
5840        let result = parse("if [[ ${X} == 5 ]] && [[ ${Y} -gt 0 ]]; then echo; fi").unwrap();
5841        match &result.statements[0] {
5842            Stmt::If(if_stmt) => match if_stmt.condition.as_ref() {
5843                Expr::BinaryOp { left, op, right } => {
5844                    assert_eq!(*op, BinaryOp::And);
5845                    // Left: [[ ${X} == 5 ]]
5846                    match left.as_ref() {
5847                        Expr::Test(test) => match test.as_ref() {
5848                            TestExpr::Comparison { op: left_op, .. } => {
5849                                assert_eq!(*left_op, TestCmpOp::Eq);
5850                            }
5851                            other => panic!("expected comparison, got {:?}", other),
5852                        },
5853                        other => panic!("expected test, got {:?}", other),
5854                    }
5855                    // Right: [[ ${Y} -gt 0 ]]
5856                    match right.as_ref() {
5857                        Expr::Test(test) => match test.as_ref() {
5858                            TestExpr::Comparison { op: right_op, .. } => {
5859                                assert_eq!(*right_op, TestCmpOp::NumGt);
5860                            }
5861                            other => panic!("expected comparison, got {:?}", other),
5862                        },
5863                        other => panic!("expected test, got {:?}", other),
5864                    }
5865                }
5866                other => panic!("expected binary op, got {:?}", other),
5867            },
5868            other => panic!("expected if, got {:?}", other),
5869        }
5870    }
5871
5872    // ═══════════════════════════════════════════════════════════════════════════
5873    // Integration Tests - Complete Scripts
5874    // ═══════════════════════════════════════════════════════════════════════════
5875
5876    /// Level 1: Linear script using core features
5877    #[test]
5878    fn script_level1_linear() {
5879        let script = r#"
5880NAME="kaish"
5881VERSION=1
5882TIMEOUT=30
5883ITEMS="alpha beta gamma"
5884
5885echo "Starting ${NAME} v${VERSION}"
5886cat "README.md" | grep pattern="install" | head count=5
5887fetch url="https://api.example.com/status" timeout=${TIMEOUT} > "/tmp/status.json"
5888echo "Items: ${ITEMS}"
5889"#;
5890        let result = parse(script).unwrap();
5891        let stmts: Vec<_> = result.statements.iter()
5892            .filter(|s| !matches!(s, Stmt::Empty))
5893            .collect();
5894
5895        assert_eq!(stmts.len(), 8);
5896        assert!(matches!(stmts[0], Stmt::Assignment(_)));  // set NAME
5897        assert!(matches!(stmts[1], Stmt::Assignment(_)));  // set VERSION
5898        assert!(matches!(stmts[2], Stmt::Assignment(_)));  // set TIMEOUT
5899        assert!(matches!(stmts[3], Stmt::Assignment(_)));  // set ITEMS
5900        assert!(matches!(stmts[4], Stmt::Command(_)));     // echo "Starting..."
5901        assert!(matches!(stmts[5], Stmt::Pipeline(_)));    // cat | grep | head
5902        assert!(matches!(stmts[6], Stmt::Pipeline(_)));    // fetch (with redirect - Pipeline since it has redirects)
5903        assert!(matches!(stmts[7], Stmt::Command(_)));     // echo "Items: ${ITEMS}"
5904    }
5905
5906    /// Level 2: Script with conditionals (shell-compatible syntax)
5907    #[test]
5908    fn script_level2_branching() {
5909        let script = r#"
5910RESULT=$(kaish-validate "input.json")
5911
5912if [[ ${RESULT.ok} == true ]]; then
5913    echo "Validation passed"
5914    process "input.json" > "output.json"
5915else
5916    echo "Validation failed: ${RESULT.err}"
5917fi
5918
5919if [[ ${COUNT} -gt 0 ]] && [[ ${COUNT} -le 100 ]]; then
5920    echo "Count in valid range"
5921fi
5922
5923if check-network || check-cache; then
5924    fetch url=${URL}
5925fi
5926"#;
5927        let result = parse(script).unwrap();
5928        let stmts: Vec<_> = result.statements.iter()
5929            .filter(|s| !matches!(s, Stmt::Empty))
5930            .collect();
5931
5932        assert_eq!(stmts.len(), 4);
5933
5934        // First: assignment with command substitution
5935        match stmts[0] {
5936            Stmt::Assignment(a) => {
5937                assert_eq!(a.name(), "RESULT");
5938                assert!(matches!(&a.value, Expr::CommandSubst(_)));
5939            }
5940            other => panic!("expected assignment, got {:?}", other),
5941        }
5942
5943        // Second: if/else
5944        match stmts[1] {
5945            Stmt::If(if_stmt) => {
5946                assert_eq!(if_stmt.then_branch.len(), 2);
5947                assert!(if_stmt.else_branch.is_some());
5948                assert_eq!(if_stmt.else_branch.as_ref().unwrap().len(), 1);
5949            }
5950            other => panic!("expected if, got {:?}", other),
5951        }
5952
5953        // Third: if with && condition
5954        match stmts[2] {
5955            Stmt::If(if_stmt) => {
5956                match if_stmt.condition.as_ref() {
5957                    Expr::BinaryOp { op, .. } => assert_eq!(*op, BinaryOp::And),
5958                    other => panic!("expected && condition, got {:?}", other),
5959                }
5960            }
5961            other => panic!("expected if, got {:?}", other),
5962        }
5963
5964        // Fourth: if with || of commands
5965        match stmts[3] {
5966            Stmt::If(if_stmt) => {
5967                match if_stmt.condition.as_ref() {
5968                    Expr::BinaryOp { op, left, right } => {
5969                        assert_eq!(*op, BinaryOp::Or);
5970                        assert!(matches!(left.as_ref(), Expr::Command(_)));
5971                        assert!(matches!(right.as_ref(), Expr::Command(_)));
5972                    }
5973                    other => panic!("expected || condition, got {:?}", other),
5974                }
5975            }
5976            other => panic!("expected if, got {:?}", other),
5977        }
5978    }
5979
5980    /// Level 3: Script with loops and function definitions
5981    #[test]
5982    fn script_level3_loops_and_functions() {
5983        let script = r#"
5984greet() {
5985    echo "Hello, $1!"
5986}
5987
5988fetch_all() {
5989    for URL in $@; do
5990        fetch url=${URL}
5991    done
5992}
5993
5994USERS="alice bob charlie"
5995
5996for USER in ${USERS}; do
5997    greet ${USER}
5998    if [[ ${USER} == "bob" ]]; then
5999        echo "Found Bob!"
6000    fi
6001done
6002
6003long-running-task &
6004"#;
6005        let result = parse(script).unwrap();
6006        let stmts: Vec<_> = result.statements.iter()
6007            .filter(|s| !matches!(s, Stmt::Empty))
6008            .collect();
6009
6010        assert_eq!(stmts.len(), 5);
6011
6012        // First function def
6013        match stmts[0] {
6014            Stmt::ToolDef(t) => {
6015                assert_eq!(t.name, "greet");
6016                assert!(t.params.is_empty());
6017            }
6018            other => panic!("expected function def, got {:?}", other),
6019        }
6020
6021        // Second function def with nested for loop
6022        match stmts[1] {
6023            Stmt::ToolDef(t) => {
6024                assert_eq!(t.name, "fetch_all");
6025                assert_eq!(t.body.len(), 1);
6026                assert!(matches!(&t.body[0], Stmt::For(_)));
6027            }
6028            other => panic!("expected function def, got {:?}", other),
6029        }
6030
6031        // Assignment
6032        assert!(matches!(stmts[2], Stmt::Assignment(_)));
6033
6034        // For loop with nested if
6035        match stmts[3] {
6036            Stmt::For(f) => {
6037                assert_eq!(f.variable, "USER");
6038                assert_eq!(f.body.len(), 2);
6039                assert!(matches!(&f.body[0], Stmt::Command(_)));
6040                assert!(matches!(&f.body[1], Stmt::If(_)));
6041            }
6042            other => panic!("expected for loop, got {:?}", other),
6043        }
6044
6045        // Background job
6046        match stmts[4] {
6047            Stmt::Pipeline(p) => {
6048                assert!(p.background);
6049                assert_eq!(pipeline_commands(p)[0].name, "long-running-task");
6050            }
6051            other => panic!("expected pipeline (background), got {:?}", other),
6052        }
6053    }
6054
6055    /// Level 4: Complex nested control flow (shell-compatible syntax)
6056    #[test]
6057    fn script_level4_complex_nesting() {
6058        let script = r#"
6059RESULT=$(cat "config.json" | jq query=".servers" | kaish-validate schema="server-schema.json")
6060
6061if ping host=${HOST} && [[ ${RESULT} == true ]]; then
6062    for SERVER in "prod-1 prod-2"; do
6063        deploy target=${SERVER} port=8080
6064        if [[ $? -ne 0 ]]; then
6065            notify channel="ops" message="Deploy failed"
6066        fi
6067    done
6068fi
6069"#;
6070        let result = parse(script).unwrap();
6071        let stmts: Vec<_> = result.statements.iter()
6072            .filter(|s| !matches!(s, Stmt::Empty))
6073            .collect();
6074
6075        assert_eq!(stmts.len(), 2);
6076
6077        // Command substitution with pipeline
6078        match stmts[0] {
6079            Stmt::Assignment(a) => {
6080                assert_eq!(a.name(), "RESULT");
6081                assert_eq!(pipeline_commands(subst_pipeline(&a.value)).len(), 3);
6082            }
6083            other => panic!("expected assignment, got {:?}", other),
6084        }
6085
6086        // If with && condition, containing for loop with nested if
6087        match stmts[1] {
6088            Stmt::If(if_stmt) => {
6089                match if_stmt.condition.as_ref() {
6090                    Expr::BinaryOp { op, .. } => assert_eq!(*op, BinaryOp::And),
6091                    other => panic!("expected && condition, got {:?}", other),
6092                }
6093                assert_eq!(if_stmt.then_branch.len(), 1);
6094                match &if_stmt.then_branch[0] {
6095                    Stmt::For(f) => {
6096                        assert_eq!(f.body.len(), 2);
6097                        assert!(matches!(&f.body[1], Stmt::If(_)));
6098                    }
6099                    other => panic!("expected for in if body, got {:?}", other),
6100                }
6101            }
6102            other => panic!("expected if, got {:?}", other),
6103        }
6104    }
6105
6106    /// Level 5: Edge cases and parser stress test
6107    #[test]
6108    fn script_level5_edge_cases() {
6109        let script = r#"
6110echo ""
6111echo "quotes: \"nested\" here"
6112echo "escapes: \n\t\r\\"
6113echo "unicode: \u2764"
6114
6115X=-99999
6116Y=3.14159265358979
6117Z=-0.001
6118
6119cmd a=1 b="two" c=true d=false e=null
6120
6121if true; then
6122    if false; then
6123        echo "inner"
6124    else
6125        echo "else"
6126    fi
6127fi
6128
6129for I in "a b c"; do
6130    echo ${I}
6131done
6132
6133no_params() {
6134    echo "no params"
6135}
6136
6137function all_args {
6138    echo "args: $@"
6139}
6140
6141a | b | c | d | e &
6142cmd 2> "errors.log"
6143cmd &> "all.log"
6144cmd >> "append.log"
6145cmd < "input.txt"
6146"#;
6147        let result = parse(script).unwrap();
6148        let stmts: Vec<_> = result.statements.iter()
6149            .filter(|s| !matches!(s, Stmt::Empty))
6150            .collect();
6151
6152        // Verify it parses without error
6153        assert!(stmts.len() >= 10, "expected many statements, got {}", stmts.len());
6154
6155        // Background pipeline
6156        let bg_stmt = stmts.iter().find(|s| matches!(s, Stmt::Pipeline(p) if p.background));
6157        assert!(bg_stmt.is_some(), "expected background pipeline");
6158
6159        match bg_stmt.unwrap() {
6160            Stmt::Pipeline(p) => {
6161                assert_eq!(pipeline_commands(p).len(), 5);
6162                assert!(p.background);
6163            }
6164            _ => unreachable!(),
6165        }
6166    }
6167
6168    // ═══════════════════════════════════════════════════════════════════════════
6169    // Edge Case Tests: Ambiguity Resolution
6170    // ═══════════════════════════════════════════════════════════════════════════
6171
6172    #[test]
6173    fn parse_keyword_as_variable_rejected() {
6174        // Keywords CANNOT be used as variable names - this is intentional
6175        // to avoid ambiguity. Use different names instead.
6176        let result = parse(r#"if="value""#);
6177        assert!(result.is_err(), "if= should fail - 'if' is a keyword");
6178
6179        let result = parse("while=true");
6180        assert!(result.is_err(), "while= should fail - 'while' is a keyword");
6181
6182        let result = parse(r#"then="next""#);
6183        assert!(result.is_err(), "then= should fail - 'then' is a keyword");
6184    }
6185
6186    #[test]
6187    fn parse_set_command_with_flag() {
6188        let result = parse("set -e");
6189        assert!(result.is_ok(), "failed to parse set -e: {:?}", result);
6190        let program = result.unwrap();
6191        match &program.statements[0] {
6192            Stmt::Command(cmd) => {
6193                assert_eq!(cmd.name, "set");
6194                assert_eq!(cmd.args.len(), 1);
6195                match &cmd.args[0] {
6196                    Arg::ShortFlag(f) => assert_eq!(f, "e"),
6197                    other => panic!("expected ShortFlag, got {:?}", other),
6198                }
6199            }
6200            other => panic!("expected Command, got {:?}", other),
6201        }
6202    }
6203
6204    #[test]
6205    fn parse_set_command_no_args() {
6206        let result = parse("set");
6207        assert!(result.is_ok(), "failed to parse set: {:?}", result);
6208        let program = result.unwrap();
6209        match &program.statements[0] {
6210            Stmt::Command(cmd) => {
6211                assert_eq!(cmd.name, "set");
6212                assert_eq!(cmd.args.len(), 0);
6213            }
6214            other => panic!("expected Command, got {:?}", other),
6215        }
6216    }
6217
6218    #[test]
6219    fn parse_set_assignment_vs_command() {
6220        // X=5 should be assignment
6221        let result = parse("X=5");
6222        assert!(result.is_ok());
6223        let program = result.unwrap();
6224        assert!(matches!(&program.statements[0], Stmt::Assignment(_)));
6225
6226        // set -e should be command
6227        let result = parse("set -e");
6228        assert!(result.is_ok());
6229        let program = result.unwrap();
6230        assert!(matches!(&program.statements[0], Stmt::Command(_)));
6231    }
6232
6233    #[test]
6234    fn parse_true_as_command() {
6235        let result = parse("true");
6236        assert!(result.is_ok());
6237        let program = result.unwrap();
6238        match &program.statements[0] {
6239            Stmt::Command(cmd) => assert_eq!(cmd.name, "true"),
6240            other => panic!("expected Command(true), got {:?}", other),
6241        }
6242    }
6243
6244    #[test]
6245    fn parse_false_as_command() {
6246        let result = parse("false");
6247        assert!(result.is_ok());
6248        let program = result.unwrap();
6249        match &program.statements[0] {
6250            Stmt::Command(cmd) => assert_eq!(cmd.name, "false"),
6251            other => panic!("expected Command(false), got {:?}", other),
6252        }
6253    }
6254
6255    #[test]
6256    fn parse_dot_as_source_alias() {
6257        let result = parse(". script.kai");
6258        assert!(result.is_ok(), "failed to parse . script.kai: {:?}", result);
6259        let program = result.unwrap();
6260        match &program.statements[0] {
6261            Stmt::Command(cmd) => {
6262                assert_eq!(cmd.name, ".");
6263                assert_eq!(cmd.args.len(), 1);
6264            }
6265            other => panic!("expected Command(.), got {:?}", other),
6266        }
6267    }
6268
6269    #[test]
6270    fn parse_source_command() {
6271        let result = parse("source utils.kai");
6272        assert!(result.is_ok(), "failed to parse source: {:?}", result);
6273        let program = result.unwrap();
6274        match &program.statements[0] {
6275            Stmt::Command(cmd) => {
6276                assert_eq!(cmd.name, "source");
6277                assert_eq!(cmd.args.len(), 1);
6278            }
6279            other => panic!("expected Command(source), got {:?}", other),
6280        }
6281    }
6282
6283    #[test]
6284    fn parse_test_expr_file_test() {
6285        // Paths must be quoted strings in test expressions
6286        let result = parse(r#"[[ -f "/path/file" ]]"#);
6287        assert!(result.is_ok(), "failed to parse file test: {:?}", result);
6288    }
6289
6290    #[test]
6291    fn parse_test_expr_comparison() {
6292        let result = parse(r#"[[ $X == "value" ]]"#);
6293        assert!(result.is_ok(), "failed to parse comparison test: {:?}", result);
6294    }
6295
6296    #[test]
6297    fn parse_test_expr_single_eq() {
6298        // = and == are equivalent inside [[ ]] (matching bash behavior)
6299        let result = parse(r#"[[ $X = "value" ]]"#);
6300        assert!(result.is_ok(), "failed to parse single-= comparison: {:?}", result);
6301        let program = result.unwrap();
6302        match &program.statements[0] {
6303            Stmt::Test(TestExpr::Comparison { op, .. }) => {
6304                assert_eq!(op, &TestCmpOp::Eq);
6305            }
6306            other => panic!("expected Test(Comparison), got {:?}", other),
6307        }
6308    }
6309
6310    #[test]
6311    fn parse_while_loop() {
6312        let result = parse("while true; do echo; done");
6313        assert!(result.is_ok(), "failed to parse while loop: {:?}", result);
6314        let program = result.unwrap();
6315        assert!(matches!(&program.statements[0], Stmt::While(_)));
6316    }
6317
6318    #[test]
6319    fn parse_break_with_level() {
6320        let result = parse("break 2");
6321        assert!(result.is_ok());
6322        let program = result.unwrap();
6323        match &program.statements[0] {
6324            Stmt::Break(Some(n)) => assert_eq!(*n, 2),
6325            other => panic!("expected Break(2), got {:?}", other),
6326        }
6327    }
6328
6329    #[test]
6330    fn parse_continue_with_level() {
6331        let result = parse("continue 3");
6332        assert!(result.is_ok());
6333        let program = result.unwrap();
6334        match &program.statements[0] {
6335            Stmt::Continue(Some(n)) => assert_eq!(*n, 3),
6336            other => panic!("expected Continue(3), got {:?}", other),
6337        }
6338    }
6339
6340    #[test]
6341    fn parse_exit_with_code() {
6342        let result = parse("exit 1");
6343        assert!(result.is_ok());
6344        let program = result.unwrap();
6345        match &program.statements[0] {
6346            Stmt::Exit(Some(expr)) => {
6347                match expr.as_ref() {
6348                    Expr::Literal(Value::Int(n)) => assert_eq!(*n, 1),
6349                    other => panic!("expected Int(1), got {:?}", other),
6350                }
6351            }
6352            other => panic!("expected Exit(1), got {:?}", other),
6353        }
6354    }
6355
6356    // ========================================================================
6357    // parse_interpolated_string_spanned — body-internal span tracking for
6358    // heredoc bodies. The byte offsets these tests pin become validator
6359    // issue spans via the HereDocBody → SpannedPart flow.
6360    // ========================================================================
6361
6362    #[test]
6363    fn spanned_literal_only_records_byte_range() {
6364        let parts = parse_interpolated_string_spanned("hello world", 100).unwrap();
6365        assert_eq!(parts.len(), 1);
6366        assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "hello world"));
6367        assert_eq!(parts[0].offset, 100, "base_offset must propagate to literals");
6368        assert_eq!(parts[0].len, 11);
6369    }
6370
6371    #[test]
6372    fn spanned_braced_var_at_zero() {
6373        let parts = parse_interpolated_string_spanned("${X}", 50).unwrap();
6374        assert_eq!(parts.len(), 1);
6375        assert!(matches!(&parts[0].part, StringPart::Var(_)));
6376        assert_eq!(parts[0].offset, 50);
6377        assert_eq!(parts[0].len, 4); // "${X}"
6378    }
6379
6380    #[test]
6381    fn spanned_simple_var_then_literal() {
6382        let parts = parse_interpolated_string_spanned("$X end", 10).unwrap();
6383        assert_eq!(parts.len(), 2);
6384        assert!(matches!(&parts[0].part, StringPart::Var(_)));
6385        assert_eq!(parts[0].offset, 10);
6386        assert_eq!(parts[0].len, 2); // "$X"
6387        assert!(matches!(&parts[1].part, StringPart::Literal(s) if s == " end"));
6388        assert_eq!(parts[1].offset, 12);
6389        assert_eq!(parts[1].len, 4);
6390    }
6391
6392    #[test]
6393    fn spanned_mixed_literal_var_literal() {
6394        let parts = parse_interpolated_string_spanned("hi ${X} bye", 0).unwrap();
6395        assert_eq!(parts.len(), 3);
6396        // "hi "
6397        assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "hi "));
6398        assert_eq!(parts[0].offset, 0);
6399        assert_eq!(parts[0].len, 3);
6400        // ${X}
6401        assert!(matches!(&parts[1].part, StringPart::Var(_)));
6402        assert_eq!(parts[1].offset, 3);
6403        assert_eq!(parts[1].len, 4);
6404        // " bye"
6405        assert!(matches!(&parts[2].part, StringPart::Literal(s) if s == " bye"));
6406        assert_eq!(parts[2].offset, 7);
6407        assert_eq!(parts[2].len, 4);
6408    }
6409
6410    #[test]
6411    fn spanned_positional_param() {
6412        let parts = parse_interpolated_string_spanned("$1 done", 0).unwrap();
6413        assert_eq!(parts.len(), 2);
6414        assert!(matches!(&parts[0].part, StringPart::Positional(1)));
6415        assert_eq!(parts[0].offset, 0);
6416        assert_eq!(parts[0].len, 2); // "$1"
6417    }
6418
6419    #[test]
6420    fn spanned_special_dollar_dollar() {
6421        let parts = parse_interpolated_string_spanned("$$", 5).unwrap();
6422        assert_eq!(parts.len(), 1);
6423        assert!(matches!(&parts[0].part, StringPart::CurrentPid));
6424        assert_eq!(parts[0].offset, 5);
6425        assert_eq!(parts[0].len, 2);
6426    }
6427
6428    #[test]
6429    fn spanned_arithmetic_marker_recognised() {
6430        // The lexer wraps arithmetic markers as ${__ARITH:expr__} for
6431        // interpolated heredocs; the spanned parser must produce
6432        // StringPart::Arithmetic for that shape.
6433        let parts = parse_interpolated_string_spanned("${__ARITH:1+2__}", 0).unwrap();
6434        assert_eq!(parts.len(), 1);
6435        assert!(matches!(&parts[0].part, StringPart::Arithmetic(e) if e == "1+2"));
6436    }
6437
6438    #[test]
6439    fn spanned_default_separator_yields_var_with_default() {
6440        let parts = parse_interpolated_string_spanned("${X:-fallback}", 0).unwrap();
6441        assert_eq!(parts.len(), 1);
6442        assert!(matches!(&parts[0].part, StringPart::VarWithDefault { .. }));
6443        assert_eq!(parts[0].offset, 0);
6444        assert_eq!(parts[0].len, 14); // "${X:-fallback}"
6445    }
6446
6447    #[test]
6448    fn spanned_no_dollar_runs_one_literal() {
6449        let parts = parse_interpolated_string_spanned("plain text only", 7).unwrap();
6450        assert_eq!(parts.len(), 1);
6451        assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "plain text only"));
6452        assert_eq!(parts[0].offset, 7);
6453        assert_eq!(parts[0].len, 15);
6454    }
6455
6456    #[test]
6457    fn spanned_matches_unspanned_part_count() {
6458        // Spanned and spanless variants must agree on the part decomposition.
6459        // Bug fixes in one should land in the other.
6460        let cases = [
6461            "hello",
6462            "$X",
6463            "${X}",
6464            "${X:-d}",
6465            "hi $A and $B",
6466            "$0 $1 $2",
6467            "$$ $? $#",
6468        ];
6469        for s in &cases {
6470            let unspanned = parse_interpolated_string(s).expect("test input parses");
6471            let spanned = parse_interpolated_string_spanned(s, 0).unwrap();
6472            assert_eq!(
6473                unspanned.len(),
6474                spanned.len(),
6475                "part count differs for {:?}",
6476                s
6477            );
6478        }
6479    }
6480
6481    #[test]
6482    fn spanned_multibyte_utf8_before_var_uses_byte_offsets() {
6483        // 🚀 is 4 bytes in UTF-8 and a space is 1 byte, so the literal
6484        // prefix is 5 bytes total. `${X}` then sits at byte offset 5.
6485        // Right-by-luck for char-vs-byte indexing is precisely what this
6486        // test catches: if someone swaps .len_utf8() for 1, offset becomes 2.
6487        let parts = parse_interpolated_string_spanned("🚀 ${X}", 0).unwrap();
6488        assert_eq!(parts.len(), 2);
6489
6490        assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "🚀 "));
6491        assert_eq!(parts[0].offset, 0);
6492        assert_eq!(parts[0].len, 5, "literal len must be bytes, not chars");
6493
6494        assert!(matches!(&parts[1].part, StringPart::Var(_)));
6495        assert_eq!(parts[1].offset, 5, "var offset must be bytes, not chars");
6496        assert_eq!(parts[1].len, 4);
6497    }
6498
6499    #[test]
6500    fn spanned_multibyte_utf8_pure_literal_is_byte_length() {
6501        // "hello 世界 world": 5 + 1 + 6 (3 per CJK char) + 1 + 5 = 18 bytes,
6502        // 13 chars. The `len` field must report 18, not 13.
6503        let parts = parse_interpolated_string_spanned("hello 世界 world", 0).unwrap();
6504        assert_eq!(parts.len(), 1);
6505        assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "hello 世界 world"));
6506        assert_eq!(parts[0].offset, 0);
6507        assert_eq!(parts[0].len, 18);
6508    }
6509
6510    #[test]
6511    fn spanned_escape_dollar_consumes_two_bytes_emits_one_char() {
6512        // `\$` is 2 source bytes and resolves to a single literal `$`.
6513        // The literal part's `len` should reflect the SOURCE length (2).
6514        let parts = parse_interpolated_string_spanned("\\$", 0).unwrap();
6515        assert_eq!(parts.len(), 1);
6516        assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "$"));
6517        assert_eq!(parts[0].offset, 0);
6518        assert_eq!(parts[0].len, 2, "len is source byte length, not rendered length");
6519    }
6520
6521    #[test]
6522    fn spanned_escape_backslash_collapses_pair_to_one() {
6523        let parts = parse_interpolated_string_spanned("\\\\", 0).unwrap();
6524        assert_eq!(parts.len(), 1);
6525        assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "\\"));
6526        assert_eq!(parts[0].len, 2);
6527    }
6528
6529    #[test]
6530    fn spanned_standalone_cr_continuation_realigns_span_start() {
6531        // `\` + bare `\r` (old Mac line ending, no trailing `\n`) is a line
6532        // continuation: 2 source bytes, consumed with no output. Pins the
6533        // `current_text_start` update on that branch (parser.rs's `Some('\r')`
6534        // arm in `parse_interpolated_string_spanned`) — if it failed to
6535        // advance past the consumed `\`+`\r`, the following literal run would
6536        // be misreported starting at byte 0 instead of byte 2, corrupting
6537        // every subsequent span in the string (here, the `${x}` var's offset).
6538        let parts = parse_interpolated_string_spanned("\\\rCD${x}", 0).unwrap();
6539        assert_eq!(parts.len(), 2);
6540        assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "CD"));
6541        assert_eq!(parts[0].offset, 2, "literal run must start after the consumed \\+CR");
6542        assert_eq!(parts[0].len, 2);
6543        assert!(matches!(&parts[1].part, StringPart::Var(_)));
6544        assert_eq!(parts[1].offset, 4);
6545        assert_eq!(parts[1].len, 4); // "${x}"
6546    }
6547
6548    #[test]
6549    fn spanned_standalone_cr_continuation_mid_run_keeps_span_start() {
6550        // Same continuation, but hit mid-run (current_text already holds
6551        // "AB") — current_text_start must stay anchored to the run's true
6552        // start (0), not jump to the post-continuation position, so "AB"
6553        // and "CD" merge into one literal spanning the whole source run.
6554        let parts = parse_interpolated_string_spanned("AB\\\rCD${x}", 0).unwrap();
6555        assert_eq!(parts.len(), 2);
6556        assert!(matches!(&parts[0].part, StringPart::Literal(s) if s == "ABCD"));
6557        assert_eq!(parts[0].offset, 0);
6558        assert_eq!(parts[0].len, 6); // "AB" + "\" + "\r" + "CD" = 6 source bytes
6559        assert!(matches!(&parts[1].part, StringPart::Var(_)));
6560        assert_eq!(parts[1].offset, 6);
6561        assert_eq!(parts[1].len, 4); // "${x}"
6562    }
6563
6564    // ── Collection literals ─────────────────────────────────────────────
6565
6566    /// Extract the RHS `Expr` from a one-statement `NAME=value` assignment.
6567    fn assignment_value(source: &str) -> Expr {
6568        let program = parse(source).unwrap_or_else(|e| panic!("parse {source:?}: {e:?}"));
6569        match program.statements.as_slice() {
6570            [Stmt::Assignment(a)] => a.value.clone(),
6571            other => panic!("expected a single assignment, got {other:?}"),
6572        }
6573    }
6574
6575    #[test]
6576    fn list_literal_three_elements() {
6577        let expr = assignment_value("xs=[a b c]");
6578        match expr {
6579            Expr::ListLiteral(elems) => {
6580                assert_eq!(elems.len(), 3);
6581                assert!(elems.iter().all(|e| matches!(e, ListElem::Item(_))));
6582            }
6583            other => panic!("expected ListLiteral, got {other:?}"),
6584        }
6585    }
6586
6587    #[test]
6588    fn list_literal_empty() {
6589        let expr = assignment_value("xs=[]");
6590        assert!(matches!(expr, Expr::ListLiteral(elems) if elems.is_empty()));
6591    }
6592
6593    #[test]
6594    fn list_literal_single_glued_dog() {
6595        // `[dog]` is glued (no spaces) — the value-position glob-merge
6596        // suppression must still hand it to the parser as a one-element list,
6597        // not a fused GlobWord.
6598        let expr = assignment_value("xs=[dog]");
6599        match expr {
6600            Expr::ListLiteral(elems) => assert_eq!(elems.len(), 1),
6601            other => panic!("expected ListLiteral, got {other:?}"),
6602        }
6603    }
6604
6605    #[test]
6606    fn list_literal_single_int() {
6607        let expr = assignment_value("xs=[1]");
6608        match expr {
6609            Expr::ListLiteral(elems) => match elems.as_slice() {
6610                [ListElem::Item(Expr::Literal(Value::Int(1)))] => {}
6611                other => panic!("expected one Int(1) item, got {other:?}"),
6612            },
6613            other => panic!("expected ListLiteral, got {other:?}"),
6614        }
6615    }
6616
6617    #[test]
6618    fn record_literal_unspaced_colon_equals_spaced() {
6619        let spaced = assignment_value("x={port: 8080}");
6620        let unspaced = assignment_value("x={port:8080}");
6621        assert_eq!(spaced, unspaced, "{{port:8080}} must parse identically to {{port: 8080}}");
6622        match spaced {
6623            Expr::RecordLiteral(entries) => match entries.as_slice() {
6624                [RecordEntry { key: RecordKey::Bare(k), value: Expr::Literal(Value::Int(8080)) }] => {
6625                    assert_eq!(k, "port");
6626                }
6627                other => panic!("expected one port:8080 entry, got {other:?}"),
6628            },
6629            other => panic!("expected RecordLiteral, got {other:?}"),
6630        }
6631    }
6632
6633    #[test]
6634    fn record_literal_name_role() {
6635        let expr = assignment_value("u={name: amy, role: maintainer}");
6636        match expr {
6637            Expr::RecordLiteral(entries) => assert_eq!(entries.len(), 2),
6638            other => panic!("expected RecordLiteral, got {other:?}"),
6639        }
6640    }
6641
6642    #[test]
6643    fn record_literal_multiline_trailing_comma() {
6644        let source = "services={\n  web:    {port: 8080, replicas: 3, healthy: true},\n  api:    {port: 9000, replicas: 2, healthy: false},\n}";
6645        let expr = assignment_value(source);
6646        match expr {
6647            Expr::RecordLiteral(entries) => assert_eq!(entries.len(), 2, "web + api entries"),
6648            other => panic!("expected RecordLiteral, got {other:?}"),
6649        }
6650    }
6651
6652    #[test]
6653    fn record_literal_quoted_key() {
6654        let expr = assignment_value(r#"r={"content-type": x}"#);
6655        match expr {
6656            Expr::RecordLiteral(entries) => match entries.as_slice() {
6657                [RecordEntry { key: RecordKey::Quoted(k), .. }] => assert_eq!(k, "content-type"),
6658                other => panic!("expected one quoted-key entry, got {other:?}"),
6659            },
6660            other => panic!("expected RecordLiteral, got {other:?}"),
6661        }
6662    }
6663
6664    #[test]
6665    fn nested_list_and_record_in_record() {
6666        let expr = assignment_value("x={tags: [a b], meta: {active: true}}");
6667        match expr {
6668            Expr::RecordLiteral(entries) => {
6669                assert_eq!(entries.len(), 2);
6670                assert!(matches!(entries[0].value, Expr::ListLiteral(_)));
6671                assert!(matches!(entries[1].value, Expr::RecordLiteral(_)));
6672            }
6673            other => panic!("expected RecordLiteral, got {other:?}"),
6674        }
6675    }
6676
6677    #[test]
6678    fn spread_and_item_elements() {
6679        let expr = assignment_value("new=[...$xs date]");
6680        match expr {
6681            Expr::ListLiteral(elems) => match elems.as_slice() {
6682                [ListElem::Spread(Expr::VarRef(_)), ListElem::Item(Expr::Literal(Value::String(s)))] => {
6683                    assert_eq!(s, "date");
6684                }
6685                other => panic!("expected [Spread($xs), Item(date)], got {other:?}"),
6686            },
6687            other => panic!("expected ListLiteral, got {other:?}"),
6688        }
6689    }
6690
6691    #[test]
6692    fn spread_of_two_variables() {
6693        let expr = assignment_value("c=[...$a ...$b]");
6694        match expr {
6695            Expr::ListLiteral(elems) => {
6696                assert_eq!(elems.len(), 2);
6697                assert!(elems.iter().all(|e| matches!(e, ListElem::Spread(_))));
6698            }
6699            other => panic!("expected ListLiteral, got {other:?}"),
6700        }
6701    }
6702
6703    #[test]
6704    fn in_rhs_accepts_a_list_literal() {
6705        let program = parse("if [[ $a not in [dog] ]]; then echo hit; fi")
6706            .unwrap_or_else(|e| panic!("parse: {e:?}"));
6707        assert_eq!(program.statements.len(), 1);
6708    }
6709
6710    #[test]
6711    fn multiword_bareword_record_value_is_a_parse_error() {
6712        // Strict quoting inside literals: a record value must be exactly one
6713        // word or one quoted string — never silently split or joined.
6714        assert!(parse("x={msg: hello world}").is_err());
6715    }
6716
6717    // ── Invariant guards: argv/for-head globs must be unaffected ────────
6718
6719    #[test]
6720    fn argv_bracket_glob_stays_a_glob_pattern() {
6721        // `ls [dog]` is argv position — the glued `[dog]` run must still fuse
6722        // to a GlobWord (the value-position suppression only applies right
6723        // after `Eq`/a genuine membership `In`, not after a command name).
6724        let program = parse("ls [dog]").unwrap_or_else(|e| panic!("parse: {e:?}"));
6725        assert_eq!(program.statements.len(), 1);
6726    }
6727
6728    #[test]
6729    fn brace_expansion_at_argv_position_is_unaffected() {
6730        // `*.{rs,go}` is glob/brace-expansion argv syntax (the glob-merge run
6731        // needs a wildcard char present to fuse at all — a bare `{a,b}` with
6732        // no `*`/`?`/`[...]` never fuses into a GlobWord, independent of this
6733        // PR). Value-position literal parsing must not leak into argv.
6734        let program = parse("cmd *.{rs,go}").unwrap_or_else(|e| panic!("parse: {e:?}"));
6735        assert_eq!(program.statements.len(), 1);
6736    }
6737
6738    #[test]
6739    fn for_head_item_is_not_a_literal() {
6740        // `for x in [a]` stays argv (a GlobPattern word list), never a
6741        // ListLiteral — collection literals are value-position only.
6742        let program = parse("for x in [a]; do echo $x; done")
6743            .unwrap_or_else(|e| panic!("parse: {e:?}"));
6744        match program.statements.as_slice() {
6745            [Stmt::For(for_loop)] => {
6746                assert_eq!(for_loop.items.len(), 1);
6747                assert!(
6748                    !matches!(for_loop.items[0], Expr::ListLiteral(_)),
6749                    "for-head item must not be a ListLiteral: {:?}",
6750                    for_loop.items[0]
6751                );
6752            }
6753            other => panic!("expected a single For statement, got {other:?}"),
6754        }
6755    }
6756
6757    /// One layer a [`nested_compound_constructs_always_parse`] source can be
6758    /// wrapped in. Each variant takes the previous layer's source (always a
6759    /// complete, valid statement) and produces a new one, so folding a
6760    /// random sequence of these builds an arbitrarily nested — but always
6761    /// structurally valid — program.
6762    #[derive(Debug, Clone, Copy)]
6763    enum NestingLayer {
6764        /// Unquoted `$(...)`, Route C's own grammar (`cmd_subst_parser`).
6765        CmdSubst,
6766        /// Quoted `"$(...)"`, the separate `parse_interpolated_string` path.
6767        QuotedCmdSubst,
6768        /// `case ... in v) ...;; esac`, an unpaired pattern-terminator `)`.
6769        Case,
6770        If,
6771        For,
6772    }
6773
6774    fn wrap_in_layer(inner: &str, layer: NestingLayer) -> String {
6775        match layer {
6776            NestingLayer::CmdSubst => format!("x=$({inner})"),
6777            NestingLayer::QuotedCmdSubst => format!("x=\"pre $({inner}) post\""),
6778            NestingLayer::Case => format!("case v in v) {inner};; esac"),
6779            NestingLayer::If => format!("if true; then {inner}; fi"),
6780            NestingLayer::For => format!("for f in a; do {inner}; done"),
6781        }
6782    }
6783
6784    proptest::proptest! {
6785        /// This is the exact bug class the `CmdSubstFrames` fixes were found
6786        /// in: a structural nesting COMBINATION (a case pattern's unpaired
6787        /// `)` inside a nested/quoted `$(...)`) that no individually-passing
6788        /// hand-written test happened to cover. Rather than add more
6789        /// hand-picked combinations, generate a grammar-aware random one:
6790        /// fold 1..=4 random `NestingLayer`s onto the trivial leaf statement
6791        /// `echo x` and assert the result always parses. The payload stays
6792        /// trivial on purpose — this tests structural nesting, not
6793        /// expression content.
6794        ///
6795        /// At most one `QuotedCmdSubst` layer: two of them nests a `"$(...)"`
6796        /// inside another `"..."`, and the raw double-quoted-string token
6797        /// (`Token::String`'s lexer regex) has no `$(...)`-awareness at all —
6798        /// it matches to the first unescaped `"`, full stop. That is a real,
6799        /// pre-existing gap (confirmed on `main`, unrelated to any
6800        /// `CmdSubstFrames` frame — it fires before a frame stack ever sees a
6801        /// token), well outside this fix's scope; see the PR body.
6802        #[test]
6803        fn nested_compound_constructs_always_parse(
6804            layers in proptest::collection::vec(
6805                proptest::prop_oneof![
6806                    proptest::strategy::Just(NestingLayer::CmdSubst),
6807                    proptest::strategy::Just(NestingLayer::QuotedCmdSubst),
6808                    proptest::strategy::Just(NestingLayer::Case),
6809                    proptest::strategy::Just(NestingLayer::If),
6810                    proptest::strategy::Just(NestingLayer::For),
6811                ],
6812                1..=4,
6813            ).prop_filter("at most one QuotedCmdSubst layer", |layers| {
6814                layers.iter().filter(|l| matches!(l, NestingLayer::QuotedCmdSubst)).count() <= 1
6815            })
6816        ) {
6817            let source = layers
6818                .iter()
6819                .fold("echo x".to_string(), |inner, &layer| wrap_in_layer(&inner, layer));
6820            proptest::prop_assert!(
6821                parse(&source).is_ok(),
6822                "grammar-nested construct failed to parse: {source:?}"
6823            );
6824        }
6825    }
6826}