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