Skip to main content

ccalc_engine/
parser.rs

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