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