Skip to main content

ccalc_engine/
parser.rs

1use crate::eval::{Expr, Op, expr_to_string};
2
3/// A parsed statement entry: `(statement, is_silent, source_line)`.
4///
5/// `is_silent` is `true` when the statement ends with `;` (output suppressed).
6/// `source_line` is the 1-based line number within the input string.
7pub type StmtEntry = (Stmt, bool, usize);
8
9/// Top-level statement returned by [`parse`] and [`parse_stmts`].
10#[derive(Debug)]
11pub enum Stmt {
12    /// Variable assignment: `name = expr`
13    Assign(String, Expr),
14    /// Standalone expression — result goes into `ans`
15    Expr(Expr),
16    /// `if cond; body; elseif cond; ...; else; ...; end`
17    If {
18        /// The condition expression evaluated to decide which branch to take.
19        cond: Expr,
20        /// Statements to execute when `cond` is truthy.
21        body: Vec<StmtEntry>,
22        /// Zero or more `elseif (cond) body` branches, in source order.
23        elseif_branches: Vec<(Expr, Vec<StmtEntry>)>,
24        /// Statements to execute when no condition matched, or `None` if there is no `else`.
25        else_body: Option<Vec<StmtEntry>>,
26    },
27    /// `for var = range_expr; body; end` — iterates over columns of the range matrix
28    For {
29        /// The loop variable assigned on each iteration.
30        var: String,
31        /// Expression that produces the matrix whose columns are iterated.
32        range_expr: Expr,
33        /// Loop body statements.
34        body: Vec<StmtEntry>,
35    },
36    /// `while cond; body; end`
37    While {
38        /// Loop condition — re-evaluated before each iteration.
39        cond: Expr,
40        /// Loop body statements.
41        body: Vec<StmtEntry>,
42    },
43    /// `break` — exits the innermost enclosing loop
44    Break,
45    /// `continue` — advances to next iteration of the innermost enclosing loop
46    Continue,
47    /// `switch expr; case val; body; ...; otherwise; body; end`
48    ///
49    /// Each case carries a list of match expressions (single value today; cell-array
50    /// multi-value is deferred to Phase 11.5b) and a statement body.
51    /// `otherwise` is optional.
52    Switch {
53        /// The expression whose value is matched against each `case`.
54        expr: Expr,
55        /// Each case is a list of match patterns and the body to run on a match.
56        cases: Vec<(Vec<Expr>, Vec<StmtEntry>)>,
57        /// Fallback body executed when no `case` matches, or `None` if there is no `otherwise`.
58        otherwise_body: Option<Vec<StmtEntry>>,
59    },
60    /// `do; body; until (cond)` — Octave-specific post-test loop.
61    ///
62    /// The body always executes at least once. `break` and `continue` work as in `while`.
63    DoUntil {
64        /// Loop body — always executed at least once before the condition is checked.
65        body: Vec<StmtEntry>,
66        /// Condition tested after each iteration; loop exits when it becomes truthy.
67        cond: Expr,
68    },
69    /// `function [outputs] = name(params) body end` — named user function definition.
70    ///
71    /// The body is stored as raw source text and re-parsed on each call by `exec.rs`.
72    /// Named functions execute in an isolated scope (only params and built-in constants visible).
73    FunctionDef {
74        /// The function name (e.g. `"fib"` in `function y = fib(n)`).
75        name: String,
76        /// Output variable names in declaration order.
77        outputs: Vec<String>,
78        /// Parameter names in declaration order.
79        params: Vec<String>,
80        /// Raw source text of the function body, stored verbatim for re-parsing on each call.
81        body_source: String,
82        /// Documentation extracted from `%`-prefixed lines immediately before the `function` keyword.
83        doc: Option<String>,
84    },
85    /// `return` — exits the current function immediately.
86    ///
87    /// Inside a named function, `return` causes the function to return its current output
88    /// variable values. At the top level it is treated as an error by `exec.rs`.
89    Return,
90    /// `[a, b] = f()` — multi-output assignment.
91    ///
92    /// Produced when the LHS is a bracket list of identifiers.
93    /// The RHS must evaluate to a `Value::Tuple`; extra values are discarded,
94    /// missing values produce an error.
95    MultiAssign {
96        /// The list of output variable names on the LHS (e.g. `["a", "b"]` in `[a, b] = f()`).
97        targets: Vec<String>,
98        /// The RHS expression — must evaluate to [`Value::Tuple`](crate::env::Value::Tuple).
99        expr: Expr,
100    },
101    /// `try; body; catch [e]; catch_body; end` — protected block.
102    ///
103    /// If `catch_var` is `Some(name)`, the catch variable is bound to a struct
104    /// with field `message` containing the error string.
105    TryCatch {
106        /// Statements in the protected `try` block.
107        try_body: Vec<StmtEntry>,
108        /// Optional name of the catch variable bound to a struct with a `message` field.
109        catch_var: Option<String>,
110        /// Statements executed when an error is caught.
111        catch_body: Vec<StmtEntry>,
112    },
113    /// `c{i} = v` — cell element assignment.
114    ///
115    /// Updates element `i` (1-based) of the cell array named `name`.
116    CellSet(String, Expr, Expr),
117    /// `s.x = v` / `s.a.b = v` — struct field assignment.
118    ///
119    /// `FieldSet(base_var, field_path, rhs)`:
120    /// - `base_var`: the top-level variable name (e.g. `"s"`).
121    /// - `field_path`: one or more field names leading to the target (e.g. `["x"]` or `["a", "b"]`).
122    /// - `rhs`: the value to store.
123    ///
124    /// At runtime the base variable is loaded (or a fresh empty struct is created),
125    /// the path is walked (creating intermediate structs on demand), and the final
126    /// field is set.
127    FieldSet(String, Vec<String>, Expr),
128    /// `s.(fname) = v` — dynamic struct field assignment.
129    ///
130    /// `DynFieldSet(base_var, field_expr, rhs)`:
131    /// - `base_var`: the top-level variable name.
132    /// - `field_expr`: expression that evaluates to the field name string at runtime.
133    /// - `rhs`: the value to store.
134    DynFieldSet(String, Expr, Expr),
135    /// `s(i).field = v` / `s(i).a.b = v` — struct array element field assignment.
136    ///
137    /// `StructArrayFieldSet(base_var, idx_expr, field_path, rhs)`:
138    /// - `base_var`: the top-level variable name (e.g. `"s"`).
139    /// - `idx_expr`: the 1-based integer index expression (e.g. `1` or `i+1`).
140    /// - `field_path`: one or more field names (e.g. `["x"]` or `["a", "b"]`).
141    /// - `rhs`: the value to store.
142    ///
143    /// At runtime the struct array is loaded (or created), grown if necessary,
144    /// and the field of element `idx` is set.
145    StructArrayFieldSet(String, Expr, Vec<String>, Expr),
146    /// `v(i) = x`, `A(i,j) = x`, `v(1:3) = [1 2 3]` — indexed assignment.
147    ///
148    /// Modifies one or more elements of an existing matrix (or creates/grows it).
149    /// Index expressions follow the same syntax as the read-path (Phase 6):
150    /// `:`, scalars, ranges, and logical masks (Phase 15d).
151    /// A scalar RHS is broadcast to all selected positions.
152    IndexSet {
153        /// The variable name being modified.
154        name: String,
155        /// Index expressions (1 = linear, 2 = row/col).
156        indices: Vec<Expr>,
157        /// The value to write.
158        value: Expr,
159    },
160    /// `global x y z` — declares names as globally shared across function scopes.
161    ///
162    /// Any function that declares the same names as `global` shares the same storage.
163    /// At the top level (REPL/script), global variables are initialized in the shared
164    /// store and behave like ordinary workspace variables.
165    Global(Vec<String>),
166    /// `persistent x y z` — declares names as persistent across calls to the enclosing function.
167    ///
168    /// The values are retained between calls. On the first call the variables are
169    /// initialized to an empty/zero state; on subsequent calls the saved state is restored.
170    /// Valid only inside a named function; at the top level it is accepted but has no effect.
171    Persistent(Vec<String>),
172}
173
174#[derive(Debug, Clone)]
175enum Token {
176    Number(f64),
177    Ident(String),
178    Str(String),       // 'text' char array literal
179    StringObj(String), // "text" string object literal
180    Plus,
181    Minus,
182    Star,
183    Slash,
184    Caret,
185    DotStar,
186    DotSlash,
187    DotCaret,
188    Apostrophe,
189    LParen,
190    RParen,
191    Comma,
192    LBracket,
193    RBracket,
194    Semicolon,
195    Newline, // '\n' inside [...] — treated as a row separator in parse_matrix
196    Colon,
197    // --- Compound assignment ---
198    PlusEq,     // +=
199    MinusEq,    // -=
200    StarEq,     // *=
201    SlashEq,    // /=
202    PlusPlus,   // ++
203    MinusMinus, // --
204    // --- Comparison ---
205    EqEq,  // ==
206    NotEq, // ~=
207    Lt,    // <
208    Gt,    // >
209    LtEq,  // <=
210    GtEq,  // >=
211    // --- Logical ---
212    AmpAmp,   // &&
213    PipePipe, // ||
214    Amp,      // & (element-wise AND)
215    Pipe,     // | (element-wise OR)
216    Tilde,    // ~ / ! (unary NOT)
217    At,       // @ (lambda / function handle prefix)
218    LBrace,   // {
219    RBrace,   // }
220    // --- Additional operators ---
221    StarStar,      // ** (alias for ^)
222    DotApostrophe, // .' (non-conjugate transpose)
223    Backslash,     // \ (left division / linear solve)
224    // --- Struct field access ---
225    Dot, // '.' followed by an ASCII letter (field access)
226}
227
228fn parse_integer_literal(
229    chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
230    radix: u32,
231    prefix: &str,
232) -> Result<f64, String> {
233    let mut digit_str = String::new();
234    while let Some(&d) = chars.peek() {
235        let valid = match radix {
236            16 => d.is_ascii_hexdigit(),
237            2 => d == '0' || d == '1',
238            8 => ('0'..='7').contains(&d),
239            _ => false,
240        };
241        if valid {
242            digit_str.push(d);
243            chars.next();
244        } else {
245            break;
246        }
247    }
248    if digit_str.is_empty() {
249        return Err(format!("Expected digits after '{prefix}'"));
250    }
251    i64::from_str_radix(&digit_str, radix)
252        .map(|i| i as f64)
253        .map_err(|_| format!("Invalid {prefix} literal: '{prefix}{digit_str}'"))
254}
255
256/// If the next chars look like a sci exponent (`e+5`, `E-3`, `e10`), consume and append them.
257/// Uses a cloned iterator for lookahead — only advances the real iterator on a confirmed match.
258fn try_consume_sci_exponent(
259    chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
260    num_str: &mut String,
261) {
262    if !matches!(chars.peek(), Some('e') | Some('E')) {
263        return;
264    }
265    let mut lookahead = chars.clone();
266    let e_char = lookahead.next().unwrap();
267    match lookahead.peek().copied() {
268        Some('+') | Some('-') => {
269            let sign = lookahead.next().unwrap();
270            if lookahead.peek().is_some_and(|d| d.is_ascii_digit()) {
271                chars.next();
272                chars.next();
273                num_str.push(e_char);
274                num_str.push(sign);
275                while let Some(&d) = chars.peek() {
276                    if d.is_ascii_digit() {
277                        num_str.push(d);
278                        chars.next();
279                    } else {
280                        break;
281                    }
282                }
283            }
284        }
285        Some(d) if d.is_ascii_digit() => {
286            chars.next();
287            num_str.push(e_char);
288            while let Some(&d) = chars.peek() {
289                if d.is_ascii_digit() {
290                    num_str.push(d);
291                    chars.next();
292                } else {
293                    break;
294                }
295            }
296        }
297        _ => {}
298    }
299}
300
301/// After parsing a decimal number, if the next char is `i` or `j` and NOT
302/// followed by another identifier character, consume it and emit `* i` so
303/// that `4i` → `4 * i` = `Complex(0, 4)`.
304#[inline]
305fn push_imag_suffix(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, tokens: &mut Vec<Token>) {
306    if matches!(chars.peek(), Some('i') | Some('j')) {
307        let mut la = chars.clone();
308        la.next();
309        if !la.peek().is_some_and(|c| c.is_alphanumeric() || *c == '_') {
310            chars.next(); // consume i/j
311            tokens.push(Token::Star);
312            tokens.push(Token::Ident("i".to_string()));
313        }
314    }
315}
316
317fn tokenize(input: &str) -> Result<Vec<Token>, String> {
318    let mut tokens = Vec::new();
319    let mut chars = input.chars().peekable();
320    // Track whether the character immediately before the current position was
321    // whitespace.  Used to disambiguate `'`: after whitespace it always starts
322    // a char-array literal; without preceding whitespace after a value-producing
323    // token it is a transpose operator.
324    let mut prev_was_ws = true; // treat start-of-input like after whitespace
325    // Bracket nesting depth: inside [...] newlines become Token::Newline row separators.
326    let mut bracket_depth: i32 = 0;
327
328    while let Some(&c) = chars.peek() {
329        match c {
330            ' ' | '\t' => {
331                chars.next();
332                prev_was_ws = true;
333                continue; // skip the prev_was_ws = false at the bottom
334            }
335            '\r' => {
336                chars.next();
337                continue; // skip carriage returns silently
338            }
339            '\n' => {
340                chars.next();
341                if bracket_depth > 0 {
342                    // Inside [...]: newline is a row separator, same as ';'
343                    tokens.push(Token::Newline);
344                }
345                prev_was_ws = true;
346                continue;
347            }
348            '+' => {
349                chars.next();
350                match chars.peek() {
351                    Some('=') => {
352                        chars.next();
353                        tokens.push(Token::PlusEq);
354                    }
355                    Some('+') => {
356                        chars.next();
357                        tokens.push(Token::PlusPlus);
358                    }
359                    _ => tokens.push(Token::Plus),
360                }
361            }
362            '-' => {
363                chars.next();
364                match chars.peek() {
365                    Some('=') => {
366                        chars.next();
367                        tokens.push(Token::MinusEq);
368                    }
369                    Some('-') => {
370                        chars.next();
371                        tokens.push(Token::MinusMinus);
372                    }
373                    _ => tokens.push(Token::Minus),
374                }
375            }
376            '*' => {
377                chars.next();
378                match chars.peek() {
379                    Some('=') => {
380                        chars.next();
381                        tokens.push(Token::StarEq);
382                    }
383                    Some('*') => {
384                        chars.next();
385                        tokens.push(Token::StarStar);
386                    }
387                    _ => tokens.push(Token::Star),
388                }
389            }
390            '/' => {
391                chars.next();
392                if chars.peek() == Some(&'=') {
393                    chars.next();
394                    tokens.push(Token::SlashEq);
395                } else {
396                    tokens.push(Token::Slash);
397                }
398            }
399            '^' => {
400                tokens.push(Token::Caret);
401                chars.next();
402            }
403            '\'' => {
404                // Disambiguate transpose vs char-array literal.
405                // Rule (matches MATLAB): `'` is transpose only when it appears
406                // WITHOUT preceding whitespace after a value-producing token.
407                // With preceding whitespace it always starts a new string literal,
408                // which is the key to making `['a' 'b']` work correctly.
409                let is_transpose = !prev_was_ws
410                    && matches!(
411                        tokens.last(),
412                        Some(
413                            Token::Number(_)
414                                | Token::Ident(_)
415                                | Token::RParen
416                                | Token::RBracket
417                                | Token::Apostrophe
418                                | Token::Str(_)
419                        )
420                    );
421                chars.next(); // consume the opening '
422                if is_transpose {
423                    tokens.push(Token::Apostrophe);
424                } else {
425                    // Parse char array literal; '' inside is an escaped single quote.
426                    let mut content = String::new();
427                    loop {
428                        match chars.next() {
429                            None => return Err("Unterminated string literal".to_string()),
430                            Some('\'') => {
431                                // Check for escaped '' (two single quotes in a row)
432                                if chars.peek().copied() == Some('\'') {
433                                    chars.next();
434                                    content.push('\'');
435                                } else {
436                                    break;
437                                }
438                            }
439                            Some(c) => content.push(c),
440                        }
441                    }
442                    tokens.push(Token::Str(content));
443                }
444            }
445            '"' => {
446                chars.next(); // consume the opening "
447                let mut content = String::new();
448                loop {
449                    match chars.next() {
450                        None => return Err("Unterminated string literal".to_string()),
451                        Some('"') => {
452                            // Check for escaped "" (two double quotes in a row)
453                            if chars.peek().copied() == Some('"') {
454                                chars.next();
455                                content.push('"');
456                            } else {
457                                break;
458                            }
459                        }
460                        Some('\\') => match chars.next() {
461                            Some('n') => content.push('\n'),
462                            Some('t') => content.push('\t'),
463                            Some('\\') => content.push('\\'),
464                            Some('\'') => content.push('\''),
465                            Some('"') => content.push('"'),
466                            Some(other) => {
467                                content.push('\\');
468                                content.push(other);
469                            }
470                            None => return Err("Unterminated string literal".to_string()),
471                        },
472                        Some(c) => content.push(c),
473                    }
474                }
475                tokens.push(Token::StringObj(content));
476            }
477            '.' => {
478                chars.next();
479                match chars.peek().copied() {
480                    Some('.') => {
481                        // Could be '...' (line continuation)
482                        chars.next(); // consume second '.'
483                        if chars.peek() == Some(&'.') {
484                            chars.next(); // consume third '.'
485                            // Line continuation: treat rest of input as a comment
486                            while chars.next().is_some() {}
487                        } else {
488                            return Err("Unexpected '..'".to_string());
489                        }
490                    }
491                    Some('\'') => {
492                        chars.next();
493                        tokens.push(Token::DotApostrophe);
494                    }
495                    Some('*') => {
496                        chars.next();
497                        tokens.push(Token::DotStar);
498                    }
499                    Some('/') => {
500                        chars.next();
501                        tokens.push(Token::DotSlash);
502                    }
503                    Some('^') => {
504                        chars.next();
505                        tokens.push(Token::DotCaret);
506                    }
507                    Some(d) if d.is_ascii_digit() => {
508                        let mut num_str = String::from(".");
509                        while let Some(&d) = chars.peek() {
510                            if d.is_ascii_digit() {
511                                num_str.push(d);
512                                chars.next();
513                            } else {
514                                break;
515                            }
516                        }
517                        try_consume_sci_exponent(&mut chars, &mut num_str);
518                        let n: f64 = num_str
519                            .parse()
520                            .map_err(|_| format!("Invalid number: '{num_str}'"))?;
521                        tokens.push(Token::Number(n));
522                    }
523                    // Field access: '.' followed by an identifier letter/underscore or '('.
524                    // Don't consume the next char — it will be tokenized on the next pass.
525                    Some(c) if c.is_ascii_alphabetic() || c == '_' => {
526                        tokens.push(Token::Dot);
527                    }
528                    Some('(') => {
529                        tokens.push(Token::Dot);
530                    }
531                    _ => return Err("Unexpected '.'".to_string()),
532                }
533            }
534            '%' | '#' => {
535                // '%' / '#' start a comment — stop tokenizing
536                break;
537            }
538            '!' => {
539                chars.next();
540                if chars.peek().copied() == Some('=') {
541                    chars.next();
542                    tokens.push(Token::NotEq);
543                } else {
544                    tokens.push(Token::Tilde);
545                }
546            }
547            '(' => {
548                tokens.push(Token::LParen);
549                chars.next();
550            }
551            ')' => {
552                tokens.push(Token::RParen);
553                chars.next();
554            }
555            ',' => {
556                tokens.push(Token::Comma);
557                chars.next();
558            }
559            '[' => {
560                bracket_depth += 1;
561                tokens.push(Token::LBracket);
562                chars.next();
563            }
564            ']' => {
565                if bracket_depth > 0 {
566                    bracket_depth -= 1;
567                }
568                tokens.push(Token::RBracket);
569                chars.next();
570            }
571            '{' => {
572                tokens.push(Token::LBrace);
573                chars.next();
574            }
575            '}' => {
576                tokens.push(Token::RBrace);
577                chars.next();
578            }
579            ';' => {
580                tokens.push(Token::Semicolon);
581                chars.next();
582            }
583            ':' => {
584                tokens.push(Token::Colon);
585                chars.next();
586            }
587            '=' => {
588                chars.next();
589                if chars.peek().copied() == Some('=') {
590                    chars.next();
591                    tokens.push(Token::EqEq);
592                } else {
593                    return Err("Unexpected '=': use '==' for comparison".to_string());
594                }
595            }
596            '~' => {
597                chars.next();
598                if chars.peek().copied() == Some('=') {
599                    chars.next();
600                    tokens.push(Token::NotEq);
601                } else {
602                    tokens.push(Token::Tilde);
603                }
604            }
605            '<' => {
606                chars.next();
607                if chars.peek().copied() == Some('=') {
608                    chars.next();
609                    tokens.push(Token::LtEq);
610                } else {
611                    tokens.push(Token::Lt);
612                }
613            }
614            '>' => {
615                chars.next();
616                if chars.peek().copied() == Some('=') {
617                    chars.next();
618                    tokens.push(Token::GtEq);
619                } else {
620                    tokens.push(Token::Gt);
621                }
622            }
623            '&' => {
624                chars.next();
625                if chars.peek().copied() == Some('&') {
626                    chars.next();
627                    tokens.push(Token::AmpAmp);
628                } else {
629                    tokens.push(Token::Amp);
630                }
631            }
632            '|' => {
633                chars.next();
634                if chars.peek().copied() == Some('|') {
635                    chars.next();
636                    tokens.push(Token::PipePipe);
637                } else {
638                    tokens.push(Token::Pipe);
639                }
640            }
641            '0'..='9' => {
642                if c == '0' {
643                    chars.next();
644                    match chars.peek().copied() {
645                        Some('x') | Some('X') => {
646                            chars.next();
647                            let n = parse_integer_literal(&mut chars, 16, "0x")?;
648                            tokens.push(Token::Number(n));
649                        }
650                        Some('b') | Some('B') => {
651                            chars.next();
652                            let n = parse_integer_literal(&mut chars, 2, "0b")?;
653                            tokens.push(Token::Number(n));
654                        }
655                        Some('o') | Some('O') => {
656                            chars.next();
657                            let n = parse_integer_literal(&mut chars, 8, "0o")?;
658                            tokens.push(Token::Number(n));
659                        }
660                        _ => {
661                            let mut num_str = String::from("0");
662                            while let Some(&d) = chars.peek() {
663                                if d.is_ascii_digit() {
664                                    num_str.push(d);
665                                    chars.next();
666                                } else if d == '.' {
667                                    // Don't eat '.' if followed by *, /, ^ (element-wise ops)
668                                    let mut la = chars.clone();
669                                    la.next();
670                                    if matches!(la.peek(), Some('*') | Some('/') | Some('^')) {
671                                        break;
672                                    }
673                                    num_str.push('.');
674                                    chars.next();
675                                } else {
676                                    break;
677                                }
678                            }
679                            try_consume_sci_exponent(&mut chars, &mut num_str);
680                            let n: f64 = num_str
681                                .parse()
682                                .map_err(|_| format!("Invalid number: '{num_str}'"))?;
683                            tokens.push(Token::Number(n));
684                            push_imag_suffix(&mut chars, &mut tokens);
685                        }
686                    }
687                } else {
688                    let mut num_str = String::new();
689                    while let Some(&d) = chars.peek() {
690                        if d.is_ascii_digit() {
691                            num_str.push(d);
692                            chars.next();
693                        } else if d == '.' {
694                            // Don't eat '.' if followed by *, /, ^ (element-wise ops)
695                            let mut la = chars.clone();
696                            la.next();
697                            if matches!(la.peek(), Some('*') | Some('/') | Some('^')) {
698                                break;
699                            }
700                            num_str.push('.');
701                            chars.next();
702                        } else {
703                            break;
704                        }
705                    }
706                    try_consume_sci_exponent(&mut chars, &mut num_str);
707                    let n: f64 = num_str
708                        .parse()
709                        .map_err(|_| format!("Invalid number: '{num_str}'"))?;
710                    tokens.push(Token::Number(n));
711                    push_imag_suffix(&mut chars, &mut tokens);
712                }
713            }
714            '@' => {
715                tokens.push(Token::At);
716                chars.next();
717            }
718            '\\' => {
719                tokens.push(Token::Backslash);
720                chars.next();
721            }
722            'a'..='z' | 'A'..='Z' | '_' => {
723                let mut ident = String::new();
724                while let Some(&c) = chars.peek() {
725                    if c.is_alphanumeric() || c == '_' {
726                        ident.push(c);
727                        chars.next();
728                    } else {
729                        break;
730                    }
731                }
732                tokens.push(Token::Ident(ident));
733            }
734            _ => return Err(format!("Unexpected character: '{c}'")),
735        }
736        prev_was_ws = false;
737    }
738
739    Ok(tokens)
740}
741
742/// Detects `base(idx_expr).f1.f2...fn = rhs` at the string level (before tokenising).
743///
744/// Returns `Some((base, idx_str, fields, rhs))` on a match, `None` otherwise.
745fn try_split_struct_array_field_assign(input: &str) -> Option<(String, &str, Vec<String>, &str)> {
746    let trimmed = input.trim();
747    let bytes = trimmed.as_bytes();
748    let mut i = 0;
749
750    // Parse leading identifier
751    if i >= bytes.len() || !(bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') {
752        return None;
753    }
754    let base_start = i;
755    while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
756        i += 1;
757    }
758    let base_var = trimmed[base_start..i].to_string();
759
760    // Expect '('
761    if i >= bytes.len() || bytes[i] != b'(' {
762        return None;
763    }
764    i += 1;
765
766    // Scan for the matching ')' (tracking nested parens/brackets/braces)
767    let idx_start = i;
768    let mut depth = 1usize;
769    while i < bytes.len() && depth > 0 {
770        match bytes[i] {
771            b'(' | b'[' | b'{' => depth += 1,
772            b')' | b']' | b'}' => depth -= 1,
773            _ => {}
774        }
775        i += 1;
776    }
777    if depth != 0 {
778        return None;
779    }
780    let idx_str = &trimmed[idx_start..i - 1]; // exclude the closing ')'
781
782    // Expect '.field' (at least one field access)
783    if i >= bytes.len() || bytes[i] != b'.' {
784        return None;
785    }
786    let mut fields = Vec::new();
787    while i < bytes.len() && bytes[i] == b'.' {
788        i += 1;
789        if i >= bytes.len() || !(bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') {
790            return None;
791        }
792        let field_start = i;
793        while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
794            i += 1;
795        }
796        fields.push(trimmed[field_start..i].to_string());
797    }
798    if fields.is_empty() {
799        return None;
800    }
801
802    // Skip optional whitespace then expect bare `=` (not `==`)
803    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
804        i += 1;
805    }
806    if i >= bytes.len() || bytes[i] != b'=' {
807        return None;
808    }
809    i += 1;
810    if i < bytes.len() && bytes[i] == b'=' {
811        return None; // '==' comparison
812    }
813
814    let rhs = trimmed[i..].trim();
815    if rhs.is_empty() {
816        return None;
817    }
818    Some((base_var, idx_str, fields, rhs))
819}
820
821/// Detects `base.(field_expr) = rhs` at the string level (before tokenising).
822///
823/// Returns `Some((base, field_expr_str, rhs_str))` on a match, `None` otherwise.
824fn try_split_dyn_field_assign(input: &str) -> Option<(String, &str, &str)> {
825    let trimmed = input.trim();
826    let bytes = trimmed.as_bytes();
827    let mut i = 0;
828
829    // Parse leading identifier
830    if i >= bytes.len() || !(bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') {
831        return None;
832    }
833    let name_start = i;
834    while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
835        i += 1;
836    }
837    let base_var = &trimmed[name_start..i];
838
839    // Expect '.' then '('
840    if i >= bytes.len() || bytes[i] != b'.' {
841        return None;
842    }
843    i += 1;
844    if i >= bytes.len() || bytes[i] != b'(' {
845        return None;
846    }
847    i += 1; // consume '('
848
849    // Scan for matching ')' tracking nested paren/bracket/brace depth
850    let field_start = i;
851    let mut depth = 1usize;
852    while i < bytes.len() && depth > 0 {
853        match bytes[i] {
854            b'(' | b'[' | b'{' => depth += 1,
855            b')' | b']' | b'}' => depth -= 1,
856            _ => {}
857        }
858        i += 1;
859    }
860    if depth != 0 {
861        return None;
862    }
863    // i now points one past the closing ')'
864    let field_str = trimmed[field_start..i - 1].trim();
865    if field_str.is_empty() {
866        return None;
867    }
868
869    // Skip optional whitespace then expect bare '=' (not '==')
870    let rest = trimmed[i..].trim_start();
871    if !rest.starts_with('=') || rest.starts_with("==") {
872        return None;
873    }
874    let rhs = rest[1..].trim();
875    if rhs.is_empty() {
876        return None;
877    }
878    Some((base_var.to_string(), field_str, rhs))
879}
880
881/// Detects `base.f1.f2...fn = rhs` at the string level (before tokenising).
882///
883/// Returns `Some((base, fields, rhs))` on a match, `None` otherwise.
884/// The detection is done at string level to avoid tokenising twice.
885fn try_split_field_assign(input: &str) -> Option<(String, Vec<String>, &str)> {
886    let trimmed = input.trim();
887    let bytes = trimmed.as_bytes();
888    let mut i = 0;
889
890    // Parse leading identifier (base variable name)
891    if i >= bytes.len() || !(bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') {
892        return None;
893    }
894    let base_start = i;
895    while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
896        i += 1;
897    }
898    let base_var = trimmed[base_start..i].to_string();
899
900    // Parse one or more `.field` segments
901    let mut fields = Vec::new();
902    while i < bytes.len() && bytes[i] == b'.' {
903        i += 1;
904        if i >= bytes.len() || !(bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') {
905            return None;
906        }
907        let field_start = i;
908        while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
909            i += 1;
910        }
911        fields.push(trimmed[field_start..i].to_string());
912    }
913    if fields.is_empty() {
914        return None;
915    }
916
917    // Skip optional whitespace then expect a bare `=` (not `==`)
918    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
919        i += 1;
920    }
921    if i >= bytes.len() || bytes[i] != b'=' {
922        return None;
923    }
924    i += 1;
925    if i < bytes.len() && bytes[i] == b'=' {
926        return None; // '==' comparison
927    }
928
929    let rhs = trimmed[i..].trim();
930    if rhs.is_empty() {
931        return None;
932    }
933    Some((base_var, fields, rhs))
934}
935
936/// Parses a whitespace- or comma-separated list of valid identifiers.
937///
938/// Used by `global` and `persistent` statement parsers.
939fn parse_name_list(rest: &str) -> Result<Vec<String>, String> {
940    let names: Vec<String> = rest
941        .split(|c: char| c.is_whitespace() || c == ',')
942        .filter(|s| !s.is_empty())
943        .map(String::from)
944        .collect();
945    if names.is_empty() {
946        return Err("Expected at least one variable name".to_string());
947    }
948    for name in &names {
949        if !name.starts_with(|c: char| c.is_alphabetic() || c == '_')
950            || name.chars().any(|c| !c.is_alphanumeric() && c != '_')
951        {
952            return Err(format!("Invalid variable name: '{name}'"));
953        }
954    }
955    Ok(names)
956}
957
958/// Parses a full input string into a [`Stmt`].
959///
960/// Assignment (`name = expr`) is detected first. Everything else is treated as
961/// an expression whose result will be stored in `ans`.
962pub fn parse(input: &str) -> Result<Stmt, String> {
963    let trimmed = input.trim();
964
965    // 'global x y z' — shared global variable declaration
966    if let Some(rest) = trimmed
967        .strip_prefix("global")
968        .filter(|r| r.is_empty() || r.starts_with(|c: char| c.is_whitespace() || c == ','))
969    {
970        return Ok(Stmt::Global(parse_name_list(rest)?));
971    }
972
973    // 'persistent x y z' — function-local persistent variable declaration
974    if let Some(rest) = trimmed
975        .strip_prefix("persistent")
976        .filter(|r| r.is_empty() || r.starts_with(|c: char| c.is_whitespace() || c == ','))
977    {
978        return Ok(Stmt::Persistent(parse_name_list(rest)?));
979    }
980
981    // 'return' statement
982    if trimmed == "return" {
983        return Ok(Stmt::Return);
984    }
985
986    // Struct array element field assignment: name(idx).field[.field]* = rhs
987    if let Some((base_var, idx_str, fields, rhs)) = try_split_struct_array_field_assign(trimmed) {
988        let idx_tokens = tokenize(idx_str)?;
989        if idx_tokens.is_empty() {
990            return Err("Expected index expression inside '()'".to_string());
991        }
992        let mut idx_pos = 0;
993        let idx_expr = parse_logical_or(&idx_tokens, &mut idx_pos)?;
994        if idx_pos != idx_tokens.len() {
995            return Err("Unexpected token in struct array index expression".to_string());
996        }
997        let rhs_tokens = tokenize(rhs)?;
998        if rhs_tokens.is_empty() {
999            return Err("Expected expression after '='".to_string());
1000        }
1001        let mut rhs_pos = 0;
1002        let rhs_expr = parse_logical_or(&rhs_tokens, &mut rhs_pos)?;
1003        if rhs_pos != rhs_tokens.len() {
1004            return Err("Unexpected token after expression".to_string());
1005        }
1006        return Ok(Stmt::StructArrayFieldSet(
1007            base_var, idx_expr, fields, rhs_expr,
1008        ));
1009    }
1010
1011    // Indexed assignment: name(args) = rhs  (Phase 15)
1012    if let Some((name, idx_str, rhs)) = try_split_index_assign(trimmed) {
1013        let idx_tokens = tokenize(idx_str)?;
1014        let indices = parse_index_args(&idx_tokens)?;
1015        if indices.len() > 2 {
1016            return Err("Indexed assignment supports at most 2 indices".to_string());
1017        }
1018        let rhs_tokens = tokenize(rhs)?;
1019        if rhs_tokens.is_empty() {
1020            return Err("Expected expression after '='".to_string());
1021        }
1022        let mut rhs_pos = 0;
1023        let value = parse_logical_or(&rhs_tokens, &mut rhs_pos)?;
1024        if rhs_pos != rhs_tokens.len() {
1025            return Err("Unexpected token after expression".to_string());
1026        }
1027        return Ok(Stmt::IndexSet {
1028            name,
1029            indices,
1030            value,
1031        });
1032    }
1033
1034    // Dynamic struct field assignment: name.(field_expr) = rhs
1035    if let Some((base_var, field_str, rhs)) = try_split_dyn_field_assign(trimmed) {
1036        let field_tokens = tokenize(field_str)?;
1037        if field_tokens.is_empty() {
1038            return Err("Expected expression inside '.()'".to_string());
1039        }
1040        let mut fpos = 0;
1041        let field_expr = parse_logical_or(&field_tokens, &mut fpos)?;
1042        if fpos != field_tokens.len() {
1043            return Err("Unexpected token in dynamic field expression".to_string());
1044        }
1045        let rhs_tokens = tokenize(rhs)?;
1046        if rhs_tokens.is_empty() {
1047            return Err("Expected expression after '='".to_string());
1048        }
1049        let mut rpos = 0;
1050        let rhs_expr = parse_logical_or(&rhs_tokens, &mut rpos)?;
1051        if rpos != rhs_tokens.len() {
1052            return Err("Unexpected token after expression".to_string());
1053        }
1054        return Ok(Stmt::DynFieldSet(base_var, field_expr, rhs_expr));
1055    }
1056
1057    // Struct field assignment: name.field[.field]* = rhs
1058    if let Some((base_var, fields, rhs)) = try_split_field_assign(trimmed) {
1059        let tokens = tokenize(rhs)?;
1060        if tokens.is_empty() {
1061            return Err("Expected expression after '='".to_string());
1062        }
1063        let mut pos = 0;
1064        let rhs_expr = parse_logical_or(&tokens, &mut pos)?;
1065        if pos != tokens.len() {
1066            return Err("Unexpected token after expression".to_string());
1067        }
1068        return Ok(Stmt::FieldSet(base_var, fields, rhs_expr));
1069    }
1070
1071    // Cell element assignment: name{expr} = rhs
1072    if let Some((name, idx_str, rhs)) = try_split_cell_assign(trimmed) {
1073        let idx_tokens = tokenize(idx_str)?;
1074        if idx_tokens.is_empty() {
1075            return Err("Expected index expression inside '{}'".to_string());
1076        }
1077        let mut idx_pos = 0;
1078        let idx_expr = parse_logical_or(&idx_tokens, &mut idx_pos)?;
1079        if idx_pos != idx_tokens.len() {
1080            return Err("Unexpected token in cell index expression".to_string());
1081        }
1082        let rhs_tokens = tokenize(rhs)?;
1083        if rhs_tokens.is_empty() {
1084            return Err("Expected expression after '='".to_string());
1085        }
1086        let mut rhs_pos = 0;
1087        let rhs_expr = parse_logical_or(&rhs_tokens, &mut rhs_pos)?;
1088        if rhs_pos != rhs_tokens.len() {
1089            return Err("Unexpected token after expression".to_string());
1090        }
1091        return Ok(Stmt::CellSet(name.to_string(), idx_expr, rhs_expr));
1092    }
1093
1094    // Multi-assign: [a, b] = expr
1095    if let Some((targets, rhs)) = try_split_multi_assign(trimmed) {
1096        let tokens = tokenize(rhs)?;
1097        if tokens.is_empty() {
1098            return Err("Expected expression after '='".to_string());
1099        }
1100        let mut pos = 0;
1101        let expr = parse_logical_or(&tokens, &mut pos)?;
1102        if pos != tokens.len() {
1103            return Err("Unexpected token after expression".to_string());
1104        }
1105        return Ok(Stmt::MultiAssign { targets, expr });
1106    }
1107
1108    if let Some((name, rhs)) = try_split_assignment(trimmed) {
1109        let tokens = tokenize(rhs)?;
1110        if tokens.is_empty() {
1111            return Err("Expected expression after '='".to_string());
1112        }
1113        let mut pos = 0;
1114        let expr = parse_logical_or(&tokens, &mut pos)?;
1115        if pos != tokens.len() {
1116            return Err("Unexpected token after expression".to_string());
1117        }
1118        return Ok(Stmt::Assign(name.to_string(), expr));
1119    }
1120
1121    let tokens = tokenize(trimmed)?;
1122    if tokens.is_empty() {
1123        return Err("Empty expression".to_string());
1124    }
1125
1126    // Check for compound assignment: x += expr, x -= expr, x *= expr, x /= expr,
1127    // x++, x--, ++x, --x (all desugar to simple Stmt::Assign at parse time).
1128    if let Some(stmt) = try_parse_compound(&tokens)? {
1129        return Ok(stmt);
1130    }
1131
1132    let mut pos = 0;
1133    let expr = parse_logical_or(&tokens, &mut pos)?;
1134    if pos != tokens.len() {
1135        return Err("Unexpected token after expression".to_string());
1136    }
1137    Ok(Stmt::Expr(expr))
1138}
1139
1140/// Tries to parse a compound assignment or increment/decrement statement from an already-
1141/// tokenised token list. Returns `Ok(Some(stmt))` on a match, `Ok(None)` otherwise.
1142///
1143/// Supported forms (all desugar to `Stmt::Assign` — no new AST nodes required):
1144/// - `x op= rhs`  →  `x = x op rhs`   (`op` ∈ {+, −, ×, ÷})
1145/// - `x++`        →  `x = x + 1`
1146/// - `x--`        →  `x = x - 1`
1147/// - `++x`        →  `x = x + 1`   (prefix)
1148/// - `--x`        →  `x = x - 1`   (prefix)
1149///
1150/// **Limitation**: `++`/`--` are statement-level only. Using them inside a larger
1151/// expression (e.g. `b = a - b--`) is not supported.
1152fn try_parse_compound(tokens: &[Token]) -> Result<Option<Stmt>, String> {
1153    // Prefix ++x / --x
1154    if tokens.len() == 2
1155        && let Token::Ident(name) = &tokens[1]
1156    {
1157        let op = match &tokens[0] {
1158            Token::PlusPlus => Some(Op::Add),
1159            Token::MinusMinus => Some(Op::Sub),
1160            _ => None,
1161        };
1162        if let Some(op) = op {
1163            let expr = Expr::BinOp(
1164                Box::new(Expr::Var(name.clone())),
1165                op,
1166                Box::new(Expr::Number(1.0)),
1167            );
1168            return Ok(Some(Stmt::Assign(name.clone(), expr)));
1169        }
1170    }
1171
1172    // All remaining forms start with an identifier
1173    let name = match tokens.first() {
1174        Some(Token::Ident(n)) => n.clone(),
1175        _ => return Ok(None),
1176    };
1177
1178    if tokens.len() < 2 {
1179        return Ok(None);
1180    }
1181
1182    match &tokens[1] {
1183        // Suffix x++ / x--
1184        Token::PlusPlus | Token::MinusMinus if tokens.len() == 2 => {
1185            let op = if matches!(&tokens[1], Token::PlusPlus) {
1186                Op::Add
1187            } else {
1188                Op::Sub
1189            };
1190            let expr = Expr::BinOp(
1191                Box::new(Expr::Var(name.clone())),
1192                op,
1193                Box::new(Expr::Number(1.0)),
1194            );
1195            Ok(Some(Stmt::Assign(name, expr)))
1196        }
1197
1198        // x op= rhs
1199        Token::PlusEq | Token::MinusEq | Token::StarEq | Token::SlashEq => {
1200            let op = match &tokens[1] {
1201                Token::PlusEq => Op::Add,
1202                Token::MinusEq => Op::Sub,
1203                Token::StarEq => Op::Mul,
1204                Token::SlashEq => Op::Div,
1205                _ => unreachable!(),
1206            };
1207            let rhs_tokens = &tokens[2..];
1208            if rhs_tokens.is_empty() {
1209                let op_str = match op {
1210                    Op::Add => "+=",
1211                    Op::Sub => "-=",
1212                    Op::Mul => "*=",
1213                    Op::Div => "/=",
1214                    _ => "op=",
1215                };
1216                return Err(format!("Expected expression after '{op_str}'"));
1217            }
1218            let mut pos = 0;
1219            let rhs = parse_logical_or(rhs_tokens, &mut pos)?;
1220            if pos != rhs_tokens.len() {
1221                return Err("Unexpected token after expression".to_string());
1222            }
1223            let expr = Expr::BinOp(Box::new(Expr::Var(name.clone())), op, Box::new(rhs));
1224            Ok(Some(Stmt::Assign(name, expr)))
1225        }
1226
1227        _ => Ok(None),
1228    }
1229}
1230
1231/// Returns true if the input looks like a partial expression
1232/// (i.e. starts with an operator that needs a left-hand operand).
1233pub fn is_partial(input: &str) -> bool {
1234    let mut chars = input.trim_start().chars();
1235    match chars.next() {
1236        // '++' and '--' are prefix increment/decrement, not binary operators
1237        Some('+') => !matches!(chars.next(), Some('+')),
1238        Some('-') => !matches!(chars.next(), Some('-')),
1239        Some('*' | '/' | '^' | '<' | '>') => true,
1240        // '.*', './', '.^' are element-wise binary operators
1241        Some('.') => matches!(chars.next(), Some('*' | '/' | '^')),
1242        // '==' comparison; '~=' not-equal
1243        Some('=') => chars.next() == Some('='),
1244        Some('~') => chars.next() == Some('='),
1245        // '&&', '||' short-circuit logical
1246        Some('&') => chars.next() == Some('&'),
1247        Some('|') => chars.next() == Some('|'),
1248        _ => false,
1249    }
1250}
1251
1252// ──────────────────────────────────────────────────────────────────────────────
1253// Multi-line block parsing (Phase 11a)
1254// ──────────────────────────────────────────────────────────────────────────────
1255
1256/// Splits a raw input line into `(statement_str, silent)` pairs.
1257///
1258/// - Strips inline `%` comments (outside string literals).
1259/// - Splits on `;` outside string literals and outside `[...]` brackets.
1260/// - `silent = true` when the statement was terminated by `;`.
1261pub fn split_stmts(input: &str) -> Vec<(&str, bool)> {
1262    // (position, is_silent): ';' → silent=true, ',' → silent=false
1263    let mut separators: Vec<(usize, bool)> = Vec::new();
1264    let mut comment_at = input.len();
1265    let mut in_sq = false;
1266    let mut in_dq = false;
1267    let mut paren_depth: i32 = 0;
1268    let mut bracket_depth: i32 = 0;
1269    let mut brace_depth: i32 = 0;
1270
1271    let chars: Vec<(usize, char)> = input.char_indices().collect();
1272    let mut ci = 0;
1273    while ci < chars.len() {
1274        let (i, c) = chars[ci];
1275        let at_depth0 =
1276            !in_sq && !in_dq && paren_depth == 0 && bracket_depth == 0 && brace_depth == 0;
1277        match c {
1278            '\'' if !in_dq => {
1279                if in_sq {
1280                    // Check for '' (escaped single quote) — stay inside the string.
1281                    let next = chars.get(ci + 1).map(|&(_, c)| c);
1282                    if next == Some('\'') {
1283                        ci += 1; // skip the second '
1284                    } else {
1285                        in_sq = false;
1286                    }
1287                } else {
1288                    let before = input[..i].trim_end_matches([' ', '\t']);
1289                    let is_transpose = before.ends_with(|c: char| {
1290                        c.is_alphanumeric()
1291                            || c == '_'
1292                            || c == ')'
1293                            || c == ']'
1294                            || c == '\''
1295                            || c == '.'
1296                    });
1297                    if !is_transpose {
1298                        in_sq = true;
1299                    }
1300                }
1301            }
1302            '"' if !in_sq => in_dq = !in_dq,
1303            '(' if !in_sq && !in_dq => paren_depth += 1,
1304            ')' if !in_sq && !in_dq && paren_depth > 0 => {
1305                paren_depth -= 1;
1306            }
1307            '[' if !in_sq && !in_dq => bracket_depth += 1,
1308            ']' if !in_sq && !in_dq && bracket_depth > 0 => {
1309                bracket_depth -= 1;
1310            }
1311            '{' if !in_sq && !in_dq => brace_depth += 1,
1312            '}' if !in_sq && !in_dq && brace_depth > 0 => {
1313                brace_depth -= 1;
1314            }
1315            '%' | '#' if at_depth0 => {
1316                comment_at = i;
1317                break;
1318            }
1319            ';' if at_depth0 => separators.push((i, true)),
1320            ',' if at_depth0 => separators.push((i, false)),
1321            _ => {}
1322        }
1323        ci += 1;
1324    }
1325
1326    let content = input[..comment_at].trim_end();
1327    if content.is_empty() {
1328        return Vec::new();
1329    }
1330
1331    let mut result = Vec::new();
1332    let mut start = 0;
1333    for &(sc, silent) in &separators {
1334        if sc >= content.len() {
1335            break;
1336        }
1337        let part = content[start..sc].trim();
1338        if !part.is_empty() {
1339            result.push((part, silent));
1340        }
1341        start = sc + 1;
1342    }
1343    if start <= content.len() {
1344        let last = content[start..].trim();
1345        if !last.is_empty() {
1346            result.push((last, false));
1347        }
1348    }
1349    result
1350}
1351
1352/// Returns the net block-depth change for a single (comment-stripped, trimmed) line.
1353///
1354/// Used by the REPL to decide whether to buffer more lines before executing.
1355/// `if`/`for`/`while` → +1; `end` → -1; all other lines → 0.
1356pub fn block_depth_delta(line: &str) -> i32 {
1357    let trimmed = line.trim();
1358    // Block comment delimiters must be checked before strip_line_comment, which
1359    // would otherwise consume everything starting at the leading '%'.
1360    if trimmed.starts_with("%{") || trimmed.starts_with("#{") {
1361        let rest = &trimmed[2..];
1362        // Self-contained %{ … %} on one line → net delta 0
1363        return if rest.contains("%}") || rest.contains("#}") {
1364            0
1365        } else {
1366            1
1367        };
1368    }
1369    if trimmed.starts_with("%}") || trimmed.starts_with("#}") {
1370        return -1;
1371    }
1372    let stripped = strip_line_comment(line).trim();
1373    match leading_keyword(stripped) {
1374        Some("if") | Some("for") | Some("while") | Some("switch") | Some("do")
1375        | Some("function") | Some("try") => 1,
1376        Some("end") | Some("until") => -1,
1377        _ => 0,
1378    }
1379}
1380
1381/// Returns the net bracket-depth change for `line` (counting `[` as +1, `]` as -1).
1382///
1383/// Respects string literals and inline `%`/`#` comments. Used by the REPL to detect
1384/// unclosed matrix literals that span multiple input lines.
1385pub fn bracket_depth_delta(line: &str) -> i32 {
1386    let mut depth: i32 = 0;
1387    let mut in_sq = false;
1388    let mut in_dq = false;
1389    let chars: Vec<(usize, char)> = line.char_indices().collect();
1390    let mut ci = 0;
1391    while ci < chars.len() {
1392        let (i, c) = chars[ci];
1393        match c {
1394            '\'' if !in_dq => {
1395                if in_sq {
1396                    let next = chars.get(ci + 1).map(|&(_, c)| c);
1397                    if next == Some('\'') {
1398                        ci += 1; // skip escaped ''
1399                    } else {
1400                        in_sq = false;
1401                    }
1402                } else {
1403                    let before = line[..i].trim_end_matches([' ', '\t']);
1404                    let is_transpose = before.ends_with(|c: char| {
1405                        c.is_alphanumeric()
1406                            || c == '_'
1407                            || c == ')'
1408                            || c == ']'
1409                            || c == '\''
1410                            || c == '.'
1411                    });
1412                    if !is_transpose {
1413                        in_sq = true;
1414                    }
1415                }
1416            }
1417            '"' if !in_sq => in_dq = !in_dq,
1418            '%' | '#' if !in_sq && !in_dq => break, // rest is a comment
1419            '[' if !in_sq && !in_dq => depth += 1,
1420            ']' if !in_sq && !in_dq => depth -= 1,
1421            _ => {}
1422        }
1423        ci += 1;
1424    }
1425    depth
1426}
1427
1428/// Returns `true` when `line` is a self-contained single-line block, e.g.
1429/// `if cond; body; end`.  These lines start with a block-opening keyword but
1430/// also close themselves with `end` / `until` in the same line, so they do
1431/// not need multi-line buffering.
1432pub fn is_single_line_block(line: &str) -> bool {
1433    let stripped = strip_line_comment(line).trim();
1434    if !matches!(
1435        leading_keyword(stripped),
1436        Some("if" | "for" | "while" | "switch" | "do")
1437    ) {
1438        return false;
1439    }
1440    let parts = split_block_line(stripped);
1441    matches!(
1442        parts.last().map(|s| leading_keyword(s.trim())),
1443        Some(Some("end" | "until"))
1444    )
1445}
1446
1447/// Strips block comments (`%{ … %}` or `#{ … #}`) from a slice of lines.
1448///
1449/// The opening `%{` and closing `%}` lines (and all content between them) are
1450/// replaced with empty strings, preserving the total line count so that any
1451/// subsequent error messages still refer to the correct original line numbers.
1452///
1453/// A same-line form `%{ … %}` is also recognised and blanked.
1454///
1455/// Returns an error if the input ends inside an unterminated block comment.
1456fn strip_block_comments(lines: &[&str]) -> Result<Vec<String>, String> {
1457    let mut result = Vec::with_capacity(lines.len());
1458    let mut in_block = false;
1459
1460    for &line in lines {
1461        let trimmed = line.trim();
1462
1463        if !in_block {
1464            if trimmed.starts_with("%{") || trimmed.starts_with("#{") {
1465                let rest = &trimmed[2..];
1466                if rest.contains("%}") || rest.contains("#}") {
1467                    // Self-contained block comment on one line — blank it
1468                    result.push(String::new());
1469                } else {
1470                    in_block = true;
1471                    result.push(String::new());
1472                }
1473            } else {
1474                result.push(line.to_string());
1475            }
1476        } else {
1477            if trimmed.starts_with("%}") || trimmed.starts_with("#}") {
1478                in_block = false;
1479            }
1480            result.push(String::new());
1481        }
1482    }
1483
1484    if in_block {
1485        Err("Unterminated block comment: missing closing '%}'".to_string())
1486    } else {
1487        Ok(result)
1488    }
1489}
1490
1491/// Joins lines ending with `...` (line continuation) into a single logical line.
1492///
1493/// `...` at the end of a line (after stripping trailing comments) causes the next
1494/// line to be treated as a continuation. The `...` and newline are replaced by a space.
1495fn join_line_continuations(input: &str) -> String {
1496    let mut result = String::new();
1497    let mut pending = String::new();
1498
1499    for line in input.lines() {
1500        let stripped = strip_line_comment(line);
1501        let trimmed = stripped.trim_end();
1502        if let Some(before_dots) = trimmed.strip_suffix("...") {
1503            // Append everything before `...` (and a space) to pending
1504            pending.push_str(before_dots);
1505            pending.push(' ');
1506        } else if pending.is_empty() {
1507            result.push_str(line);
1508            result.push('\n');
1509        } else {
1510            // Continuation: join pending with this line
1511            pending.push_str(line.trim_start());
1512            result.push_str(&pending);
1513            result.push('\n');
1514            pending.clear();
1515        }
1516    }
1517    // Any remaining pending (file ends with `...`)
1518    if !pending.is_empty() {
1519        result.push_str(pending.trim_end());
1520    }
1521    result
1522}
1523
1524/// Splits a single-line block (e.g. `if x > 0; y = 1; end`) into individual
1525/// statement strings, splitting on `;` at depth 0 (outside strings/brackets/parens).
1526fn split_block_line(line: &str) -> Vec<String> {
1527    let mut parts = Vec::new();
1528    let mut current = String::new();
1529    let mut in_sq = false;
1530    let mut in_dq = false;
1531    let mut paren: i32 = 0;
1532    let mut bracket: i32 = 0;
1533    let mut brace: i32 = 0;
1534
1535    for c in line.chars() {
1536        let at_depth0 = !in_sq && !in_dq && paren == 0 && bracket == 0 && brace == 0;
1537        match c {
1538            '\'' if !in_dq => {
1539                in_sq = !in_sq;
1540                current.push(c);
1541            }
1542            '"' if !in_sq => {
1543                in_dq = !in_dq;
1544                current.push(c);
1545            }
1546            '(' if !in_sq && !in_dq => {
1547                paren += 1;
1548                current.push(c);
1549            }
1550            ')' if !in_sq && !in_dq => {
1551                if paren > 0 {
1552                    paren -= 1;
1553                }
1554                current.push(c);
1555            }
1556            '[' if !in_sq && !in_dq => {
1557                bracket += 1;
1558                current.push(c);
1559            }
1560            ']' if !in_sq && !in_dq => {
1561                if bracket > 0 {
1562                    bracket -= 1;
1563                }
1564                current.push(c);
1565            }
1566            '{' if !in_sq && !in_dq => {
1567                brace += 1;
1568                current.push(c);
1569            }
1570            '}' if !in_sq && !in_dq => {
1571                if brace > 0 {
1572                    brace -= 1;
1573                }
1574                current.push(c);
1575            }
1576            ';' if at_depth0 => {
1577                let trimmed = current.trim().to_string();
1578                if !trimmed.is_empty() {
1579                    parts.push(trimmed);
1580                }
1581                current.clear();
1582            }
1583            _ => current.push(c),
1584        }
1585    }
1586    let last = current.trim().to_string();
1587    if !last.is_empty() {
1588        parts.push(last);
1589    }
1590    parts
1591}
1592
1593/// Parses a multi-line block string into a sequence of `(Stmt, silent, line)` triples.
1594///
1595/// The input may contain multiple lines separated by `\n` or `\r\n`.
1596/// Block keywords (`if`/`for`/`while`/`end`/…) are handled recursively.
1597/// Each statement carries a `silent` flag (`true` when terminated by `;`) and a
1598/// 1-based source line number (`usize`) for use in error messages.
1599pub fn parse_stmts(input: &str) -> Result<Vec<StmtEntry>, String> {
1600    let raw_lines: Vec<&str> = input.lines().collect();
1601    let stripped = strip_block_comments(&raw_lines)?;
1602    let stripped_str = stripped.join("\n");
1603    let joined = join_line_continuations(&stripped_str);
1604    let lines: Vec<&str> = joined.lines().collect();
1605    let mut pos = 0;
1606    parse_stmts_from_lines(&lines, &mut pos, &[])
1607}
1608
1609/// Recursive block parser. Reads statements from `lines[*pos..]`, stopping when
1610/// a keyword found in `stop_at` is encountered (without consuming that line).
1611fn parse_stmts_from_lines(
1612    lines: &[&str],
1613    pos: &mut usize,
1614    stop_at: &[&str],
1615) -> Result<Vec<StmtEntry>, String> {
1616    let mut stmts = Vec::new();
1617
1618    while *pos < lines.len() {
1619        let stmt_line = *pos + 1; // 1-based source line number for this statement
1620        let raw = lines[*pos];
1621        let line = strip_line_comment(raw).trim();
1622
1623        if line.is_empty() {
1624            *pos += 1;
1625            continue;
1626        }
1627
1628        // Stop at a terminator keyword — caller is responsible for consuming it.
1629        if let Some(kw) = leading_keyword(line)
1630            && stop_at.contains(&kw)
1631        {
1632            return Ok(stmts);
1633        }
1634
1635        // Single-line block: `if cond; body; end` on one line.
1636        // Detect: line starts with a block opener AND the last semicolon-split part is 'end'.
1637        // Expand to virtual multi-line and re-parse.
1638        if matches!(
1639            leading_keyword(line),
1640            Some("if" | "for" | "while" | "switch" | "do")
1641        ) {
1642            let virtual_parts = split_block_line(line);
1643            let last_kw = virtual_parts
1644                .last()
1645                .map(|s| leading_keyword(s.trim()))
1646                .unwrap_or(None);
1647            if matches!(last_kw, Some("end") | Some("until")) {
1648                let virtual_refs: Vec<&str> = virtual_parts.iter().map(|s| s.as_str()).collect();
1649                let mut vpos = 0;
1650                let inner = parse_stmts_from_lines(&virtual_refs, &mut vpos, stop_at)?;
1651                // All virtual lines map to the same physical source line.
1652                stmts.extend(
1653                    inner
1654                        .into_iter()
1655                        .map(|(s, silent, _)| (s, silent, stmt_line)),
1656                );
1657                *pos += 1;
1658                continue;
1659            }
1660        }
1661
1662        match leading_keyword(line) {
1663            // ── if / elseif / else / end ────────────────────────────────────
1664            Some("if") => {
1665                let cond_str = line["if".len()..].trim();
1666                if cond_str.is_empty() {
1667                    return Err("Expected condition after 'if'".to_string());
1668                }
1669                let cond = parse_condition(cond_str)?;
1670                *pos += 1;
1671
1672                let body = parse_stmts_from_lines(lines, pos, &["elseif", "else", "end"])?;
1673
1674                let mut elseif_branches = Vec::new();
1675                loop {
1676                    if *pos >= lines.len() {
1677                        return Err(
1678                            "Unexpected end of input inside 'if': expected 'end'".to_string()
1679                        );
1680                    }
1681                    let kw_line = strip_line_comment(lines[*pos]).trim();
1682                    if leading_keyword(kw_line) == Some("elseif") {
1683                        let ei_str = kw_line["elseif".len()..].trim();
1684                        if ei_str.is_empty() {
1685                            return Err("Expected condition after 'elseif'".to_string());
1686                        }
1687                        let ei_cond = parse_condition(ei_str)?;
1688                        *pos += 1;
1689                        let ei_body =
1690                            parse_stmts_from_lines(lines, pos, &["elseif", "else", "end"])?;
1691                        elseif_branches.push((ei_cond, ei_body));
1692                    } else {
1693                        break;
1694                    }
1695                }
1696
1697                let else_body = if *pos < lines.len()
1698                    && leading_keyword(strip_line_comment(lines[*pos]).trim()) == Some("else")
1699                {
1700                    *pos += 1; // consume "else"
1701                    Some(parse_stmts_from_lines(lines, pos, &["end"])?)
1702                } else {
1703                    None
1704                };
1705
1706                expect_end(lines, pos, "if")?;
1707
1708                stmts.push((
1709                    Stmt::If {
1710                        cond,
1711                        body,
1712                        elseif_branches,
1713                        else_body,
1714                    },
1715                    false,
1716                    stmt_line,
1717                ));
1718            }
1719
1720            // ── for ─────────────────────────────────────────────────────────
1721            Some("for") => {
1722                let rest = line["for".len()..].trim();
1723                if rest.is_empty() {
1724                    return Err("Expected 'var = expr' after 'for'".to_string());
1725                }
1726                let (var, range_expr) = parse_for_header(rest)?;
1727                *pos += 1;
1728                let body = parse_stmts_from_lines(lines, pos, &["end"])?;
1729                expect_end(lines, pos, "for")?;
1730                stmts.push((
1731                    Stmt::For {
1732                        var,
1733                        range_expr,
1734                        body,
1735                    },
1736                    false,
1737                    stmt_line,
1738                ));
1739            }
1740
1741            // ── while ────────────────────────────────────────────────────────
1742            Some("while") => {
1743                let cond_str = line["while".len()..].trim();
1744                if cond_str.is_empty() {
1745                    return Err("Expected condition after 'while'".to_string());
1746                }
1747                let cond = parse_condition(cond_str)?;
1748                *pos += 1;
1749                let body = parse_stmts_from_lines(lines, pos, &["end"])?;
1750                expect_end(lines, pos, "while")?;
1751                stmts.push((Stmt::While { cond, body }, false, stmt_line));
1752            }
1753
1754            // ── break / continue ─────────────────────────────────────────────
1755            Some("break") => {
1756                stmts.push((Stmt::Break, false, stmt_line));
1757                *pos += 1;
1758            }
1759            Some("continue") => {
1760                stmts.push((Stmt::Continue, false, stmt_line));
1761                *pos += 1;
1762            }
1763
1764            // ── switch / case / otherwise / end ──────────────────────────────
1765            Some("switch") => {
1766                let expr_str = line["switch".len()..].trim();
1767                if expr_str.is_empty() {
1768                    return Err("Expected expression after 'switch'".to_string());
1769                }
1770                let expr = parse_condition(expr_str)?;
1771                *pos += 1;
1772
1773                #[allow(clippy::type_complexity)]
1774                let mut cases: Vec<(Vec<Expr>, Vec<StmtEntry>)> = Vec::new();
1775                let mut otherwise_body: Option<Vec<StmtEntry>> = None;
1776
1777                loop {
1778                    if *pos >= lines.len() {
1779                        return Err(
1780                            "Unexpected end of input inside 'switch': expected 'end'".to_string()
1781                        );
1782                    }
1783                    let kw_line = strip_line_comment(lines[*pos]).trim();
1784                    match leading_keyword(kw_line) {
1785                        Some("case") => {
1786                            let case_str = kw_line["case".len()..].trim();
1787                            if case_str.is_empty() {
1788                                return Err("Expected value after 'case'".to_string());
1789                            }
1790                            let case_expr = parse_condition(case_str)?;
1791                            *pos += 1;
1792                            let case_body =
1793                                parse_stmts_from_lines(lines, pos, &["case", "otherwise", "end"])?;
1794                            cases.push((vec![case_expr], case_body));
1795                        }
1796                        Some("otherwise") => {
1797                            *pos += 1;
1798                            let ob = parse_stmts_from_lines(lines, pos, &["end"])?;
1799                            otherwise_body = Some(ob);
1800                            break;
1801                        }
1802                        Some("end") => break,
1803                        _ => {
1804                            return Err(format!(
1805                                "Expected 'case', 'otherwise', or 'end' in switch block, found: '{kw_line}'"
1806                            ));
1807                        }
1808                    }
1809                }
1810
1811                expect_end(lines, pos, "switch")?;
1812                stmts.push((
1813                    Stmt::Switch {
1814                        expr,
1815                        cases,
1816                        otherwise_body,
1817                    },
1818                    false,
1819                    stmt_line,
1820                ));
1821            }
1822
1823            // ── do...until ───────────────────────────────────────────────────
1824            Some("do") => {
1825                *pos += 1;
1826                let body = parse_stmts_from_lines(lines, pos, &["until"])?;
1827                if *pos >= lines.len() {
1828                    return Err("Unexpected end of input inside 'do': expected 'until'".to_string());
1829                }
1830                let until_line = strip_line_comment(lines[*pos]).trim();
1831                if leading_keyword(until_line) != Some("until") {
1832                    return Err(format!("Expected 'until', found: '{until_line}'"));
1833                }
1834                let cond_str = until_line["until".len()..].trim();
1835                if cond_str.is_empty() {
1836                    return Err("Expected condition after 'until'".to_string());
1837                }
1838                let cond = parse_condition(cond_str)?;
1839                *pos += 1;
1840                stmts.push((Stmt::DoUntil { body, cond }, false, stmt_line));
1841            }
1842
1843            // ── function definition ──────────────────────────────────────────
1844            Some("function") => {
1845                let header = line["function".len()..].trim();
1846                if header.is_empty() {
1847                    return Err("Expected function header after 'function'".to_string());
1848                }
1849                let (name, outputs, params) = parse_function_header(header)?;
1850
1851                *pos += 1;
1852                // Collect raw body lines until the matching 'end', tracking nested block depth.
1853                // Single-line blocks (e.g. `if cond; body; end`) count as zero depth change.
1854                let body_start = *pos;
1855
1856                // Extract doc comments (MATLAB H1-line style): consecutive % / # lines
1857                // immediately after the function header.
1858                let doc = {
1859                    let mut doc_lines: Vec<String> = Vec::new();
1860                    let mut scan = body_start;
1861                    while scan < lines.len() {
1862                        let raw = lines[scan].trim();
1863                        if raw.starts_with('%') || raw.starts_with('#') {
1864                            let stripped = raw.trim_start_matches(['%', '#']);
1865                            let text = stripped
1866                                .strip_prefix(' ')
1867                                .unwrap_or(stripped)
1868                                .trim_end()
1869                                .to_string();
1870                            doc_lines.push(text);
1871                            scan += 1;
1872                        } else {
1873                            break;
1874                        }
1875                    }
1876                    if doc_lines.is_empty() {
1877                        None
1878                    } else {
1879                        Some(doc_lines.join("\n"))
1880                    }
1881                };
1882
1883                let mut depth: i32 = 1;
1884                while *pos < lines.len() && depth > 0 {
1885                    let l = strip_line_comment(lines[*pos]).trim();
1886                    let delta = if is_single_line_block(l) {
1887                        0
1888                    } else {
1889                        block_depth_delta(l)
1890                    };
1891                    depth += delta;
1892                    if depth == 0 {
1893                        break;
1894                    }
1895                    *pos += 1;
1896                }
1897                if depth != 0 {
1898                    return Err(format!(
1899                        "Unexpected end of input: expected 'end' to close 'function {name}'"
1900                    ));
1901                }
1902                let body_source = lines[body_start..*pos].join("\n");
1903                *pos += 1; // consume 'end'
1904                stmts.push((
1905                    Stmt::FunctionDef {
1906                        name,
1907                        outputs,
1908                        params,
1909                        body_source,
1910                        doc,
1911                    },
1912                    false,
1913                    stmt_line,
1914                ));
1915            }
1916
1917            // ── return ───────────────────────────────────────────────────────
1918            Some("return") => {
1919                stmts.push((Stmt::Return, false, stmt_line));
1920                *pos += 1;
1921            }
1922
1923            // ── try / catch / end ────────────────────────────────────────────
1924            Some("try") => {
1925                *pos += 1;
1926                let try_body = parse_stmts_from_lines(lines, pos, &["catch", "end"])?;
1927
1928                if *pos >= lines.len() {
1929                    return Err(
1930                        "Unexpected end of input inside 'try': expected 'catch' or 'end'"
1931                            .to_string(),
1932                    );
1933                }
1934                let kw_line = strip_line_comment(lines[*pos]).trim();
1935                let (catch_var, catch_body) = if leading_keyword(kw_line) == Some("catch") {
1936                    let catch_rest = kw_line["catch".len()..].trim();
1937                    let catch_var = if catch_rest.is_empty() {
1938                        None
1939                    } else if is_valid_ident(catch_rest) {
1940                        Some(catch_rest.to_string())
1941                    } else {
1942                        return Err(format!(
1943                            "Expected identifier after 'catch', got '{catch_rest}'"
1944                        ));
1945                    };
1946                    *pos += 1;
1947                    let catch_body = parse_stmts_from_lines(lines, pos, &["end"])?;
1948                    (catch_var, catch_body)
1949                } else {
1950                    // 'end' closes the try block with no catch body
1951                    (None, vec![])
1952                };
1953
1954                expect_end(lines, pos, "try")?;
1955                stmts.push((
1956                    Stmt::TryCatch {
1957                        try_body,
1958                        catch_var,
1959                        catch_body,
1960                    },
1961                    false,
1962                    stmt_line,
1963                ));
1964            }
1965
1966            // ── unexpected terminators ───────────────────────────────────────
1967            Some(kw @ ("end" | "else" | "elseif" | "case" | "otherwise" | "until" | "catch")) => {
1968                return Err(format!("Unexpected '{kw}' without matching block opener"));
1969            }
1970
1971            // ── regular statement(s) — may contain ';' ──────────────────────
1972            _ => {
1973                // Command-style `clear` / `clear x y z` — two-token form that the
1974                // expression parser cannot handle. Convert to a Call so exec_stmts
1975                // can intercept it.
1976                if line == "clear" {
1977                    stmts.push((
1978                        Stmt::Expr(Expr::Call("clear".to_string(), vec![])),
1979                        false,
1980                        stmt_line,
1981                    ));
1982                    *pos += 1;
1983                    continue;
1984                }
1985                if let Some(rest) = line
1986                    .strip_prefix("clear")
1987                    .filter(|r| r.starts_with(|c: char| c.is_whitespace()))
1988                {
1989                    let names: Vec<Expr> = rest
1990                        .split_whitespace()
1991                        .map(|n| Expr::StrLiteral(n.to_string()))
1992                        .collect();
1993                    stmts.push((
1994                        Stmt::Expr(Expr::Call("clear".to_string(), names)),
1995                        false,
1996                        stmt_line,
1997                    ));
1998                    *pos += 1;
1999                    continue;
2000                }
2001
2002                // Command-style `format` / `format short` / `format compact` etc.
2003                if line == "format"
2004                    || line
2005                        .strip_prefix("format")
2006                        .is_some_and(|r| r.starts_with(|c: char| c.is_whitespace()))
2007                {
2008                    let arg = line
2009                        .strip_prefix("format")
2010                        .map(str::trim)
2011                        .unwrap_or("")
2012                        .to_string();
2013                    let args = if arg.is_empty() {
2014                        vec![]
2015                    } else {
2016                        vec![Expr::StrLiteral(arg)]
2017                    };
2018                    stmts.push((
2019                        Stmt::Expr(Expr::Call("format".to_string(), args)),
2020                        true,
2021                        stmt_line,
2022                    ));
2023                    *pos += 1;
2024                    continue;
2025                }
2026
2027                // Collect continuation lines when a matrix literal spans multiple physical
2028                // lines (e.g. `A = [1 2\n3 4]`). Strip comments from each physical line
2029                // before joining so that `%` inside a bracket never reaches the tokenizer.
2030                let joined_buf: String;
2031                let effective_raw: &str;
2032                let stripped_first = strip_line_comment(raw);
2033                if bracket_depth_delta(stripped_first) > 0 {
2034                    let mut buf = stripped_first.to_string();
2035                    while bracket_depth_delta(&buf) > 0 && *pos + 1 < lines.len() {
2036                        *pos += 1;
2037                        buf.push('\n');
2038                        buf.push_str(strip_line_comment(lines[*pos]));
2039                    }
2040                    joined_buf = buf;
2041                    effective_raw = &joined_buf;
2042                } else {
2043                    joined_buf = String::new();
2044                    effective_raw = raw;
2045                }
2046                let _ = &joined_buf; // keep binding alive for effective_raw lifetime
2047                for (stmt_str, silent) in split_stmts(effective_raw) {
2048                    stmts.push((parse(stmt_str)?, silent, stmt_line));
2049                }
2050                *pos += 1;
2051            }
2052        }
2053    }
2054
2055    Ok(stmts)
2056}
2057
2058/// Expects `lines[*pos]` to contain `end`, consumes it, or returns an error.
2059fn expect_end(lines: &[&str], pos: &mut usize, opener: &str) -> Result<(), String> {
2060    if *pos >= lines.len() {
2061        return Err(format!(
2062            "Unexpected end of input: expected 'end' to close '{opener}'"
2063        ));
2064    }
2065    let kw_line = strip_line_comment(lines[*pos]).trim();
2066    if leading_keyword(kw_line) != Some("end") {
2067        return Err(format!(
2068            "Expected 'end' to close '{opener}', found '{kw_line}'"
2069        ));
2070    }
2071    *pos += 1;
2072    Ok(())
2073}
2074
2075/// Strips a trailing `%` comment from a line, respecting single- and double-quoted strings.
2076fn strip_line_comment(line: &str) -> &str {
2077    let mut in_sq = false;
2078    let mut in_dq = false;
2079    for (i, c) in line.char_indices() {
2080        match c {
2081            '\'' if !in_dq => in_sq = !in_sq,
2082            '"' if !in_sq => in_dq = !in_dq,
2083            '%' | '#' if !in_sq && !in_dq => return &line[..i],
2084            _ => {}
2085        }
2086    }
2087    line
2088}
2089
2090/// Returns the leading keyword of a trimmed line if it is a recognised block keyword.
2091///
2092/// Uses word-boundary detection so `if_flag` → `None` but `if x > 0` → `Some("if")`.
2093fn leading_keyword(line: &str) -> Option<&str> {
2094    let end = line
2095        .find(|c: char| !c.is_alphanumeric() && c != '_')
2096        .unwrap_or(line.len());
2097    let word = &line[..end];
2098    match word {
2099        "if" | "elseif" | "else" | "end" | "for" | "while" | "break" | "continue" | "switch"
2100        | "case" | "otherwise" | "do" | "until" | "function" | "return" | "try" | "catch" => {
2101            Some(word)
2102        }
2103        _ => None,
2104    }
2105}
2106
2107/// Parses the function header text (everything after `function` keyword).
2108///
2109/// Handles three forms:
2110/// - `name(params)` — no outputs
2111/// - `y = name(params)` — single output
2112/// - `[y1, y2] = name(params)` — multiple outputs
2113fn parse_function_header(header: &str) -> Result<(String, Vec<String>, Vec<String>), String> {
2114    // Detect output list if there is an `=` (that is not `==`)
2115    if let Some(eq_pos) = header.find('=')
2116        && !header[eq_pos + 1..].starts_with('=')
2117    {
2118        let lhs = header[..eq_pos].trim();
2119        let rhs = header[eq_pos + 1..].trim();
2120        let outputs = parse_output_list(lhs)?;
2121        let (name, params) = parse_func_name_params(rhs)?;
2122        return Ok((name, outputs, params));
2123    }
2124    // No outputs: just name(params)
2125    let (name, params) = parse_func_name_params(header.trim())?;
2126    Ok((name, vec![], params))
2127}
2128
2129/// Parses an output variable list: `y`, `[y1, y2]`.
2130fn parse_output_list(lhs: &str) -> Result<Vec<String>, String> {
2131    let lhs = lhs.trim();
2132    if lhs.starts_with('[') && lhs.ends_with(']') {
2133        let inner = &lhs[1..lhs.len() - 1];
2134        inner
2135            .split(',')
2136            .map(|s| {
2137                let s = s.trim();
2138                if is_valid_ident(s) {
2139                    Ok(s.to_string())
2140                } else {
2141                    Err(format!("Invalid output variable name: '{s}'"))
2142                }
2143            })
2144            .collect()
2145    } else if is_valid_ident(lhs) {
2146        Ok(vec![lhs.to_string()])
2147    } else {
2148        Err(format!("Invalid function output list: '{lhs}'"))
2149    }
2150}
2151
2152/// Parses `name(p1, p2)` or `name` — returns `(name, params)`.
2153fn parse_func_name_params(s: &str) -> Result<(String, Vec<String>), String> {
2154    let s = s.trim();
2155    if let Some(paren_pos) = s.find('(') {
2156        let name = s[..paren_pos].trim();
2157        if !is_valid_ident(name) {
2158            return Err(format!("Invalid function name: '{name}'"));
2159        }
2160        let rest = s[paren_pos + 1..].trim();
2161        if !rest.ends_with(')') {
2162            return Err(format!("Expected ')' in function header: '{s}'"));
2163        }
2164        let params_str = rest[..rest.len() - 1].trim();
2165        let params = if params_str.is_empty() {
2166            vec![]
2167        } else {
2168            params_str
2169                .split(',')
2170                .map(|p| {
2171                    let p = p.trim();
2172                    if is_valid_ident(p) {
2173                        Ok(p.to_string())
2174                    } else {
2175                        Err(format!("Invalid parameter name: '{p}'"))
2176                    }
2177                })
2178                .collect::<Result<Vec<_>, _>>()?
2179        };
2180        Ok((name.to_string(), params))
2181    } else {
2182        if !is_valid_ident(s) {
2183            return Err(format!("Invalid function name: '{s}'"));
2184        }
2185        Ok((s.to_string(), vec![]))
2186    }
2187}
2188
2189/// Parses `cond_str` (the text after `if`/`elseif`/`while`) as an expression.
2190fn parse_condition(cond_str: &str) -> Result<Expr, String> {
2191    match parse(cond_str)? {
2192        Stmt::Expr(e) => Ok(e),
2193        Stmt::Assign(_, _) => Err("Expected condition expression, found assignment".to_string()),
2194        _ => Err("Expected condition expression".to_string()),
2195    }
2196}
2197
2198/// Parses the `for` header `var = range_expr`.
2199fn parse_for_header(rest: &str) -> Result<(String, Expr), String> {
2200    match parse(rest)? {
2201        Stmt::Assign(var, expr) => Ok((var, expr)),
2202        _ => Err(format!(
2203            "Expected 'variable = expression' in 'for' header, found: '{rest}'"
2204        )),
2205    }
2206}
2207
2208// ──────────────────────────────────────────────────────────────────────────────
2209
2210/// If `input` matches `"[a, b] = rhs"` (not `==`), returns the target names and rhs string.
2211/// All targets must be valid identifiers or `~` (discard placeholder).
2212fn try_split_multi_assign(input: &str) -> Option<(Vec<String>, &str)> {
2213    let trimmed = input.trim();
2214    if !trimmed.starts_with('[') {
2215        return None;
2216    }
2217    let close = trimmed.find(']')?;
2218    let rest = trimmed[close + 1..].trim();
2219    if !rest.starts_with('=') || rest.starts_with("==") {
2220        return None;
2221    }
2222    let rhs = rest[1..].trim();
2223    let inner = trimmed[1..close].trim();
2224    if inner.is_empty() {
2225        return None;
2226    }
2227    let targets: Vec<String> = inner.split(',').map(|s| s.trim().to_string()).collect();
2228    for t in &targets {
2229        if t != "~" && !is_valid_ident(t) {
2230            return None;
2231        }
2232    }
2233    Some((targets, rhs))
2234}
2235
2236/// If `input` matches `"name{idx} = rhs"` (not `==`), returns `Some((name, idx, rhs))`.
2237/// The name must be a valid identifier; otherwise returns `None`.
2238fn try_split_cell_assign(input: &str) -> Option<(&str, &str, &str)> {
2239    let trimmed = input.trim();
2240    // Find an identifier followed immediately by '{'
2241    let brace_pos = trimmed.find('{')?;
2242    let name = trimmed[..brace_pos].trim();
2243    if !is_valid_ident(name) {
2244        return None;
2245    }
2246    // Find the matching '}'
2247    let after_open = &trimmed[brace_pos + 1..];
2248    let close_pos = after_open.find('}')?;
2249    let idx_str = after_open[..close_pos].trim();
2250    // After '}' must be '=' (not '==')
2251    let after_close = after_open[close_pos + 1..].trim();
2252    if !after_close.starts_with('=') || after_close.starts_with("==") {
2253        return None;
2254    }
2255    let rhs = after_close[1..].trim();
2256    Some((name, idx_str, rhs))
2257}
2258
2259/// If `input` matches `"name(args) = rhs"` (not `==`), returns `Some((name, args_str, rhs))`.
2260///
2261/// The name must be a valid identifier and no `.field` may follow the closing `)` (those
2262/// patterns are handled by `try_split_struct_array_field_assign`).
2263fn try_split_index_assign(input: &str) -> Option<(String, &str, &str)> {
2264    let trimmed = input.trim();
2265    let bytes = trimmed.as_bytes();
2266    let mut i = 0;
2267
2268    // Parse leading identifier
2269    if i >= bytes.len() || !(bytes[i].is_ascii_alphabetic() || bytes[i] == b'_') {
2270        return None;
2271    }
2272    let name_start = i;
2273    while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
2274        i += 1;
2275    }
2276    let name = trimmed[name_start..i].to_string();
2277
2278    // Skip optional whitespace then expect '('
2279    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
2280        i += 1;
2281    }
2282    if i >= bytes.len() || bytes[i] != b'(' {
2283        return None;
2284    }
2285    i += 1;
2286
2287    // Scan for the matching ')' (tracking nested parens/brackets/braces)
2288    let idx_start = i;
2289    let mut depth = 1usize;
2290    while i < bytes.len() && depth > 0 {
2291        match bytes[i] {
2292            b'(' | b'[' | b'{' => depth += 1,
2293            b')' | b']' | b'}' => depth -= 1,
2294            _ => {}
2295        }
2296        i += 1;
2297    }
2298    if depth != 0 {
2299        return None;
2300    }
2301    let idx_str = trimmed[idx_start..i - 1].trim();
2302
2303    // After ')' must not be '.' (that is struct-array-field-assign, handled earlier)
2304    let rest = trimmed[i..].trim_start();
2305    if rest.starts_with('.') {
2306        return None;
2307    }
2308    // After ')' must be bare '=' (not '==')
2309    if !rest.starts_with('=') || rest.starts_with("==") {
2310        return None;
2311    }
2312    let rhs = rest[1..].trim();
2313    if rhs.is_empty() {
2314        return None;
2315    }
2316    Some((name, idx_str, rhs))
2317}
2318
2319/// Parses a comma-separated list of index arguments (`:` allowed) from a token slice.
2320fn parse_index_args(tokens: &[Token]) -> Result<Vec<Expr>, String> {
2321    if tokens.is_empty() {
2322        return Err("Expected index expression inside '()'".to_string());
2323    }
2324    let mut pos = 0;
2325    let mut args = Vec::new();
2326    loop {
2327        args.push(parse_call_arg(tokens, &mut pos)?);
2328        match tokens.get(pos) {
2329            Some(Token::Comma) => {
2330                pos += 1;
2331            }
2332            None => break,
2333            Some(_) => return Err("Unexpected token in index expression".to_string()),
2334        }
2335    }
2336    Ok(args)
2337}
2338
2339/// If `input` matches `"name = rhs"` (not `==`), returns `Some((name, rhs))`.
2340/// The name must be a valid identifier; otherwise returns `None`.
2341fn try_split_assignment(input: &str) -> Option<(&str, &str)> {
2342    let trimmed = input.trim();
2343    let eq_pos = trimmed.find('=')?;
2344    // Reject `==`
2345    if trimmed[eq_pos + 1..].starts_with('=') {
2346        return None;
2347    }
2348    let lhs = trimmed[..eq_pos].trim();
2349    let rhs = trimmed[eq_pos + 1..].trim();
2350    if is_valid_ident(lhs) {
2351        Some((lhs, rhs))
2352    } else {
2353        None
2354    }
2355}
2356
2357fn is_valid_ident(s: &str) -> bool {
2358    let mut chars = s.chars();
2359    match chars.next() {
2360        Some(c) if c.is_alphabetic() || c == '_' => chars.all(|c| c.is_alphanumeric() || c == '_'),
2361        _ => false,
2362    }
2363}
2364
2365// call_arg = ':' | logical_or_expr
2366// Used when parsing function call / index arguments.
2367// A bare ':' at the start of an argument position becomes Expr::Colon (all-elements index).
2368fn parse_call_arg(tokens: &[Token], pos: &mut usize) -> Result<Expr, String> {
2369    if matches!(tokens.get(*pos), Some(Token::Colon)) {
2370        *pos += 1;
2371        return Ok(Expr::Colon);
2372    }
2373    parse_logical_or(tokens, pos)
2374}
2375
2376// logical_or = logical_and ('||' logical_and)*
2377fn parse_logical_or(tokens: &[Token], pos: &mut usize) -> Result<Expr, String> {
2378    let mut left = parse_logical_and(tokens, pos)?;
2379    while matches!(tokens.get(*pos), Some(Token::PipePipe)) {
2380        *pos += 1;
2381        let right = parse_logical_and(tokens, pos)?;
2382        left = Expr::BinOp(Box::new(left), Op::Or, Box::new(right));
2383    }
2384    Ok(left)
2385}
2386
2387// logical_and = elem_or ('&&' elem_or)*
2388fn parse_logical_and(tokens: &[Token], pos: &mut usize) -> Result<Expr, String> {
2389    let mut left = parse_elem_or(tokens, pos)?;
2390    while matches!(tokens.get(*pos), Some(Token::AmpAmp)) {
2391        *pos += 1;
2392        let right = parse_elem_or(tokens, pos)?;
2393        left = Expr::BinOp(Box::new(left), Op::And, Box::new(right));
2394    }
2395    Ok(left)
2396}
2397
2398// elem_or = elem_and ('|' elem_and)*  -- element-wise OR, lower precedence than '&'
2399fn parse_elem_or(tokens: &[Token], pos: &mut usize) -> Result<Expr, String> {
2400    let mut left = parse_elem_and(tokens, pos)?;
2401    while matches!(tokens.get(*pos), Some(Token::Pipe)) {
2402        *pos += 1;
2403        let right = parse_elem_and(tokens, pos)?;
2404        left = Expr::BinOp(Box::new(left), Op::ElemOr, Box::new(right));
2405    }
2406    Ok(left)
2407}
2408
2409// elem_and = comparison ('&' comparison)*  -- element-wise AND
2410fn parse_elem_and(tokens: &[Token], pos: &mut usize) -> Result<Expr, String> {
2411    let mut left = parse_comparison(tokens, pos)?;
2412    while matches!(tokens.get(*pos), Some(Token::Amp)) {
2413        *pos += 1;
2414        let right = parse_comparison(tokens, pos)?;
2415        left = Expr::BinOp(Box::new(left), Op::ElemAnd, Box::new(right));
2416    }
2417    Ok(left)
2418}
2419
2420// comparison = range_expr (('==' | '~=' | '<' | '>' | '<=' | '>=') range_expr)?
2421// Comparison operators are non-associative (no chaining: `a < b < c` is an error).
2422fn parse_comparison(tokens: &[Token], pos: &mut usize) -> Result<Expr, String> {
2423    let left = parse_range(tokens, pos)?;
2424    let op = match tokens.get(*pos) {
2425        Some(Token::EqEq) => Op::Eq,
2426        Some(Token::NotEq) => Op::NotEq,
2427        Some(Token::Lt) => Op::Lt,
2428        Some(Token::Gt) => Op::Gt,
2429        Some(Token::LtEq) => Op::LtEq,
2430        Some(Token::GtEq) => Op::GtEq,
2431        _ => return Ok(left),
2432    };
2433    *pos += 1;
2434    let right = parse_range(tokens, pos)?;
2435    Ok(Expr::BinOp(Box::new(left), op, Box::new(right)))
2436}
2437
2438// range_expr = expr (':' expr (':' expr)?)?
2439// Range has lower precedence than arithmetic: `1+1:5` = `2:5`.
2440// Two-colon form: `a:step:b`; one-colon form: `a:b` (step defaults to 1).
2441fn parse_range(tokens: &[Token], pos: &mut usize) -> Result<Expr, String> {
2442    let start = parse_expr(tokens, pos)?;
2443    if !matches!(tokens.get(*pos), Some(Token::Colon)) {
2444        return Ok(start);
2445    }
2446    *pos += 1;
2447    let second = parse_expr(tokens, pos)?;
2448    if !matches!(tokens.get(*pos), Some(Token::Colon)) {
2449        // a:b form — start:stop with implicit step 1
2450        return Ok(Expr::Range(Box::new(start), None, Box::new(second)));
2451    }
2452    *pos += 1;
2453    let third = parse_expr(tokens, pos)?;
2454    // a:step:b form
2455    Ok(Expr::Range(
2456        Box::new(start),
2457        Some(Box::new(second)),
2458        Box::new(third),
2459    ))
2460}
2461
2462// expr = term (('+' | '-') term)*
2463fn parse_expr(tokens: &[Token], pos: &mut usize) -> Result<Expr, String> {
2464    let mut left = parse_term(tokens, pos)?;
2465
2466    while *pos < tokens.len() {
2467        match &tokens[*pos] {
2468            Token::Plus => {
2469                *pos += 1;
2470                let right = parse_term(tokens, pos)?;
2471                left = Expr::BinOp(Box::new(left), Op::Add, Box::new(right));
2472            }
2473            Token::Minus => {
2474                *pos += 1;
2475                let right = parse_term(tokens, pos)?;
2476                left = Expr::BinOp(Box::new(left), Op::Sub, Box::new(right));
2477            }
2478            _ => break,
2479        }
2480    }
2481
2482    Ok(left)
2483}
2484
2485// term = unary (('*' | '/' | '.*' | './') unary | '(' expr ')' )*
2486// '(' without an operator triggers implicit multiplication.
2487fn parse_term(tokens: &[Token], pos: &mut usize) -> Result<Expr, String> {
2488    let mut left = parse_unary(tokens, pos)?;
2489
2490    while *pos < tokens.len() {
2491        match &tokens[*pos] {
2492            Token::Star => {
2493                *pos += 1;
2494                let right = parse_unary(tokens, pos)?;
2495                left = Expr::BinOp(Box::new(left), Op::Mul, Box::new(right));
2496            }
2497            Token::Slash => {
2498                *pos += 1;
2499                let right = parse_unary(tokens, pos)?;
2500                left = Expr::BinOp(Box::new(left), Op::Div, Box::new(right));
2501            }
2502            Token::DotStar => {
2503                *pos += 1;
2504                let right = parse_unary(tokens, pos)?;
2505                left = Expr::BinOp(Box::new(left), Op::ElemMul, Box::new(right));
2506            }
2507            Token::DotSlash => {
2508                *pos += 1;
2509                let right = parse_unary(tokens, pos)?;
2510                left = Expr::BinOp(Box::new(left), Op::ElemDiv, Box::new(right));
2511            }
2512            Token::Backslash => {
2513                *pos += 1;
2514                let right = parse_unary(tokens, pos)?;
2515                left = Expr::BinOp(Box::new(left), Op::LDiv, Box::new(right));
2516            }
2517            Token::LParen => {
2518                // Implicit multiplication: expr(...)
2519                let right = parse_unary(tokens, pos)?;
2520                left = Expr::BinOp(Box::new(left), Op::Mul, Box::new(right));
2521            }
2522            _ => break,
2523        }
2524    }
2525
2526    Ok(left)
2527}
2528
2529// power = primary (('^' | '.^' | '**') unary)?   -- right-associative
2530// '**' is an Octave alias for '^'.
2531// Unary minus binds less tightly than power: -x^2 = -(x^2), matching MATLAB/Octave.
2532fn parse_power(tokens: &[Token], pos: &mut usize) -> Result<Expr, String> {
2533    let base = parse_primary(tokens, pos)?;
2534    if *pos < tokens.len() {
2535        match &tokens[*pos] {
2536            Token::Caret | Token::StarStar => {
2537                *pos += 1;
2538                let exp = parse_unary(tokens, pos)?;
2539                return Ok(Expr::BinOp(Box::new(base), Op::Pow, Box::new(exp)));
2540            }
2541            Token::DotCaret => {
2542                *pos += 1;
2543                let exp = parse_unary(tokens, pos)?;
2544                return Ok(Expr::BinOp(Box::new(base), Op::ElemPow, Box::new(exp)));
2545            }
2546            _ => {}
2547        }
2548    }
2549    Ok(base)
2550}
2551
2552// unary = '+' unary | '-' unary | '~' unary | power
2553// Unary '+' is a no-op: `+x` = `x`, `+[1 2 3]` = `[1 2 3]`.
2554// Power (^, .^) binds more tightly than unary minus (MATLAB/Octave semantics).
2555fn parse_unary(tokens: &[Token], pos: &mut usize) -> Result<Expr, String> {
2556    if *pos < tokens.len() {
2557        match &tokens[*pos] {
2558            Token::Plus => {
2559                *pos += 1;
2560                return parse_unary(tokens, pos); // noop
2561            }
2562            Token::Minus => {
2563                *pos += 1;
2564                let expr = parse_unary(tokens, pos)?;
2565                return Ok(Expr::UnaryMinus(Box::new(expr)));
2566            }
2567            Token::Tilde => {
2568                *pos += 1;
2569                let expr = parse_unary(tokens, pos)?;
2570                return Ok(Expr::UnaryNot(Box::new(expr)));
2571            }
2572            _ => {}
2573        }
2574    }
2575    parse_power(tokens, pos)
2576}
2577
2578// primary = ident '(' expr ')' | ident '(' ')' | '(' expr ')' | '[' matrix ']' | number | ident
2579// followed by optional postfix transpose: expr '\''*
2580fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr, String> {
2581    if *pos >= tokens.len() {
2582        return Err("Unexpected end of expression".to_string());
2583    }
2584
2585    let mut expr = match &tokens[*pos] {
2586        Token::Number(n) => {
2587            let n = *n;
2588            *pos += 1;
2589            Expr::Number(n)
2590        }
2591        Token::LBrace => {
2592            *pos += 1;
2593            // Cell literal: { expr, expr, ... }
2594            let mut elems = Vec::new();
2595            loop {
2596                match tokens.get(*pos) {
2597                    None => return Err("Expected '}'".to_string()),
2598                    Some(Token::RBrace) => {
2599                        *pos += 1;
2600                        break;
2601                    }
2602                    Some(Token::Comma) => {
2603                        *pos += 1;
2604                    }
2605                    _ => {
2606                        elems.push(parse_logical_or(tokens, pos)?);
2607                    }
2608                }
2609            }
2610            Expr::CellLiteral(elems)
2611        }
2612        Token::Ident(name) => {
2613            let name = name.clone();
2614            *pos += 1;
2615            // Cell brace-indexing: ident '{' expr '}'
2616            if *pos < tokens.len()
2617                && let Token::LBrace = &tokens[*pos]
2618            {
2619                *pos += 1;
2620                let idx = parse_logical_or(tokens, pos)?;
2621                if *pos >= tokens.len() {
2622                    return Err("Expected '}'".to_string());
2623                }
2624                match &tokens[*pos] {
2625                    Token::RBrace => {
2626                        *pos += 1;
2627                        Expr::CellIndex(Box::new(Expr::Var(name)), Box::new(idx))
2628                    }
2629                    _ => return Err("Expected '}'".to_string()),
2630                }
2631            // Function call: ident '(' [expr (',' expr)*] ')'
2632            } else if *pos < tokens.len()
2633                && let Token::LParen = &tokens[*pos]
2634            {
2635                *pos += 1;
2636                let args = if *pos < tokens.len() {
2637                    if let Token::RParen = &tokens[*pos] {
2638                        // Empty call: no arguments. Builtins and lambdas inject `ans` at eval
2639                        // time; user functions receive truly empty arg lists (varargin = {}).
2640                        vec![]
2641                    } else {
2642                        let mut list = vec![parse_call_arg(tokens, pos)?];
2643                        while *pos < tokens.len() {
2644                            if let Token::Comma = &tokens[*pos] {
2645                                *pos += 1;
2646                                list.push(parse_call_arg(tokens, pos)?);
2647                            } else {
2648                                break;
2649                            }
2650                        }
2651                        list
2652                    }
2653                } else {
2654                    return Err("Expected closing ')'".to_string());
2655                };
2656                if *pos >= tokens.len() {
2657                    return Err("Expected closing ')'".to_string());
2658                }
2659                match &tokens[*pos] {
2660                    Token::RParen => {
2661                        *pos += 1;
2662                        Expr::Call(name, args)
2663                    }
2664                    _ => return Err("Expected closing ')'".to_string()),
2665                }
2666            } else {
2667                // Built-in constants
2668                match name.as_str() {
2669                    "pi" => Expr::Number(std::f64::consts::PI),
2670                    // 'e' is a variable-shadowing constant: env lookup first, fallback to Euler's number.
2671                    "e" => Expr::Var("e".to_string()),
2672                    "nan" | "NaN" => Expr::Number(f64::NAN),
2673                    "inf" | "Inf" => Expr::Number(f64::INFINITY),
2674                    // NaT → Value::DateTime(NaN); parser-level constant to prevent shadowing.
2675                    "NaT" => Expr::NaT,
2676                    // All other identifiers → variable reference (resolved at eval time)
2677                    _ => Expr::Var(name),
2678                }
2679            }
2680        }
2681        Token::LParen => {
2682            *pos += 1;
2683            let inner = parse_logical_or(tokens, pos)?;
2684            if *pos >= tokens.len() {
2685                return Err("Expected closing ')'".to_string());
2686            }
2687            match &tokens[*pos] {
2688                Token::RParen => {
2689                    *pos += 1;
2690                    inner
2691                }
2692                _ => return Err("Expected closing ')'".to_string()),
2693            }
2694        }
2695        Token::LBracket => {
2696            *pos += 1;
2697            parse_matrix(tokens, pos)?
2698        }
2699        Token::Str(s) => {
2700            let s = s.clone();
2701            *pos += 1;
2702            Expr::StrLiteral(s)
2703        }
2704        Token::StringObj(s) => {
2705            let s = s.clone();
2706            *pos += 1;
2707            Expr::StringObjLiteral(s)
2708        }
2709        Token::At => {
2710            *pos += 1;
2711            // @funcname — function handle (wraps named function as a lambda)
2712            if let Some(Token::Ident(name)) = tokens.get(*pos) {
2713                let name = name.clone();
2714                *pos += 1;
2715                return Ok(Expr::FuncHandle(name));
2716            }
2717            // @(params) body — anonymous function (lambda)
2718            if !matches!(tokens.get(*pos), Some(Token::LParen)) {
2719                return Err("Expected '(' or identifier after '@'".to_string());
2720            }
2721            *pos += 1;
2722            let mut params = Vec::new();
2723            loop {
2724                match tokens.get(*pos) {
2725                    Some(Token::RParen) => {
2726                        *pos += 1;
2727                        break;
2728                    }
2729                    Some(Token::Ident(name)) => {
2730                        params.push(name.clone());
2731                        *pos += 1;
2732                        if matches!(tokens.get(*pos), Some(Token::Comma)) {
2733                            *pos += 1;
2734                        }
2735                    }
2736                    None => return Err("Expected ')' in lambda parameter list".to_string()),
2737                    _ => return Err("Expected parameter name in lambda".to_string()),
2738                }
2739            }
2740            let body = parse_logical_or(tokens, pos)?;
2741            let source = format!("@({}) {}", params.join(", "), expr_to_string(&body));
2742            Expr::Lambda {
2743                params,
2744                body: Box::new(body),
2745                source,
2746            }
2747        }
2748        _ => {
2749            return Err(
2750                "Expected number, function, variable, string, '-', '[', '@', or '('".to_string(),
2751            );
2752        }
2753    };
2754
2755    // Postfix operators: field access (`.field`), transpose (`'`), plain-transpose (`.'`),
2756    // and package/struct call (`a.b(args)`). All bind tighter than any binary operator.
2757    loop {
2758        match tokens.get(*pos) {
2759            Some(Token::Dot) => {
2760                *pos += 1;
2761                match tokens.get(*pos) {
2762                    Some(Token::LParen) => {
2763                        // Dynamic field access: expr.(field_expr)
2764                        *pos += 1;
2765                        let field_expr = parse_logical_or(tokens, pos)?;
2766                        if !matches!(tokens.get(*pos), Some(Token::RParen)) {
2767                            return Err("Expected closing ')' in dynamic field access".to_string());
2768                        }
2769                        *pos += 1;
2770                        expr = Expr::DynFieldGet(Box::new(expr), Box::new(field_expr));
2771                    }
2772                    Some(Token::Ident(field)) => {
2773                        let field = field.clone();
2774                        *pos += 1;
2775                        expr = Expr::FieldGet(Box::new(expr), field);
2776                    }
2777                    _ => return Err("Expected field name after '.'".to_string()),
2778                }
2779            }
2780            Some(Token::LParen) => {
2781                // `a.b(args)` or `a.b.c(args)`: postfix call on a dot-chain.
2782                // Only valid when expr is a pure Var/FieldGet chain (2+ segments).
2783                if let Some(segs) = field_chain_segments(&expr)
2784                    && segs.len() >= 2
2785                {
2786                    *pos += 1;
2787                    let args = if matches!(tokens.get(*pos), Some(Token::RParen)) {
2788                        vec![]
2789                    } else {
2790                        let mut list = vec![parse_call_arg(tokens, pos)?];
2791                        while matches!(tokens.get(*pos), Some(Token::Comma)) {
2792                            *pos += 1;
2793                            list.push(parse_call_arg(tokens, pos)?);
2794                        }
2795                        list
2796                    };
2797                    if !matches!(tokens.get(*pos), Some(Token::RParen)) {
2798                        return Err("Expected closing ')'".to_string());
2799                    }
2800                    *pos += 1;
2801                    expr = Expr::DotCall(segs, args);
2802                } else {
2803                    break;
2804                }
2805            }
2806            Some(Token::Apostrophe) => {
2807                *pos += 1;
2808                expr = Expr::Transpose(Box::new(expr));
2809            }
2810            Some(Token::DotApostrophe) => {
2811                *pos += 1;
2812                expr = Expr::PlainTranspose(Box::new(expr));
2813            }
2814            _ => break,
2815        }
2816    }
2817
2818    Ok(expr)
2819}
2820
2821/// Extracts the dot-separated name segments from a pure `Var`/`FieldGet` chain.
2822///
2823/// Returns `Some(vec!["a", "b"])` for `FieldGet(Var("a"), "b")`, or `None` if
2824/// the expression contains any non-Var/FieldGet node (e.g. a `Call`).
2825fn field_chain_segments(e: &Expr) -> Option<Vec<String>> {
2826    match e {
2827        Expr::Var(name) => Some(vec![name.clone()]),
2828        Expr::FieldGet(inner, field) => {
2829            let mut segs = field_chain_segments(inner)?;
2830            segs.push(field.clone());
2831            Some(segs)
2832        }
2833        _ => None,
2834    }
2835}
2836
2837/// Parses the contents of a matrix literal after the opening `[` has been consumed.
2838fn parse_matrix(tokens: &[Token], pos: &mut usize) -> Result<Expr, String> {
2839    // Handle empty matrix []
2840    if matches!(tokens.get(*pos), Some(Token::RBracket)) {
2841        *pos += 1;
2842        return Ok(Expr::Matrix(vec![]));
2843    }
2844    let mut rows: Vec<Vec<Expr>> = Vec::new();
2845    let mut current_row: Vec<Expr> = Vec::new();
2846    loop {
2847        match tokens.get(*pos) {
2848            None => return Err("Expected ']'".to_string()),
2849            Some(Token::RBracket) => {
2850                *pos += 1;
2851                if !current_row.is_empty() {
2852                    rows.push(current_row);
2853                }
2854                break;
2855            }
2856            Some(Token::Semicolon | Token::Newline) => {
2857                *pos += 1;
2858                if !current_row.is_empty() {
2859                    rows.push(std::mem::take(&mut current_row));
2860                }
2861            }
2862            Some(Token::Comma) => {
2863                *pos += 1;
2864            }
2865            _ => {
2866                current_row.push(parse_logical_or(tokens, pos)?);
2867            }
2868        }
2869    }
2870    Ok(Expr::Matrix(rows))
2871}
2872
2873#[cfg(test)]
2874#[path = "parser_tests.rs"]
2875mod tests;