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