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