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