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