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