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