Skip to main content

ccalc_engine/
exec.rs

1use std::collections::HashMap;
2use std::rc::Rc;
3
4/// Parsed function body cache: body source string → pre-parsed, all-silent statements.
5type BodyCache = HashMap<String, Rc<Vec<(Stmt, bool)>>>;
6
7/// Expands a leading `~` to the user's home directory.
8///
9/// On Windows `USERPROFILE` is tried as a fallback for `HOME`. If neither is set the
10/// string is returned unchanged.
11fn expand_tilde(path: &str) -> String {
12    if path == "~" || path.starts_with("~/") || path.starts_with("~\\") {
13        let home = std::env::var("HOME")
14            .or_else(|_| std::env::var("USERPROFILE"))
15            .unwrap_or_default();
16        if home.is_empty() {
17            return path.to_string();
18        }
19        if path == "~" {
20            home
21        } else {
22            format!("{}{}", home, &path[1..])
23        }
24    } else {
25        path.to_string()
26    }
27}
28
29use indexmap::IndexMap;
30use ndarray::Array2;
31
32use crate::env::{Env, Value};
33use crate::env::{load_workspace, save_workspace, save_workspace_vars};
34use crate::eval::{
35    Base, Expr, FormatMode, autoload_cache_insert, current_func_name, eval_with_io, format_complex,
36    format_scalar, format_value_full, get_display_base, get_display_compact, get_display_fmt,
37    global_declare, global_frame_pop, global_frame_push, global_get, global_init_if_absent,
38    global_refresh_into_env, global_set, is_global, is_persistent, persistent_declare,
39    persistent_frame_pop, persistent_frame_push, persistent_load, persistent_save,
40    set_autoload_hook, set_display_ctx, set_fn_call_hook, set_last_err, set_nargout,
41};
42use crate::io::IoContext;
43use crate::parser::{Stmt, parse_stmts};
44
45thread_local! {
46    /// Tracks the current script nesting depth to prevent infinite recursion via `run()`.
47    static RUN_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
48
49    /// Stack of directories for currently executing scripts.
50    ///
51    /// When `run()`/`source()` starts executing a script, the script's parent directory is
52    /// pushed here. `resolve_script_path` searches this stack (top-first) so that helper
53    /// scripts can be referenced by bare name relative to the calling script's directory.
54    static SCRIPT_DIR_STACK: std::cell::RefCell<Vec<std::path::PathBuf>> =
55        const { std::cell::RefCell::new(Vec::new()) };
56
57    /// Session search path — initialized from `config.toml` at startup.
58    ///
59    /// `addpath`/`rmpath` mutate this list for the current session; changes are never
60    /// written back to `config.toml`. `resolve_script_path` searches here after CWD.
61    static SESSION_PATH: std::cell::RefCell<Vec<std::path::PathBuf>> =
62        const { std::cell::RefCell::new(Vec::new()) };
63
64    /// Parse cache for named function bodies.
65    ///
66    /// Key: body source string (verbatim text between `function` and `end`).
67    /// Value: pre-parsed, all-silent statement sequence.
68    ///
69    /// Populated on the first call to any function; subsequent calls with the
70    /// same body string skip parsing entirely and reuse the shared `Rc`.
71    /// Cache entries are never evicted — acceptable because the number of
72    /// unique function bodies in a session is small.
73    static BODY_CACHE: std::cell::RefCell<BodyCache> =
74        std::cell::RefCell::new(HashMap::new());
75}
76
77/// Recursively marks every statement in `stmts` (and all nested block bodies) as silent.
78///
79/// Function bodies must suppress all output — assignments, expressions, everything.
80/// Because [`parse_stmts`] records the original semicolon flag per statement, a simple
81/// `map(|(s,_)| (s, true))` only silences the top level.  Nested bodies inside `if`,
82/// `for`, `while`, `switch`, `do..until`, and `try..catch` keep their original flags
83/// and would still print.  This function walks the full statement tree.
84fn silence_all(stmts: Vec<(Stmt, bool)>) -> Vec<(Stmt, bool)> {
85    stmts
86        .into_iter()
87        .map(|(stmt, _)| {
88            let stmt = match stmt {
89                Stmt::If {
90                    cond,
91                    body,
92                    elseif_branches,
93                    else_body,
94                } => Stmt::If {
95                    cond,
96                    body: silence_all(body),
97                    elseif_branches: elseif_branches
98                        .into_iter()
99                        .map(|(c, b)| (c, silence_all(b)))
100                        .collect(),
101                    else_body: else_body.map(silence_all),
102                },
103                Stmt::For {
104                    var,
105                    range_expr,
106                    body,
107                } => Stmt::For {
108                    var,
109                    range_expr,
110                    body: silence_all(body),
111                },
112                Stmt::While { cond, body } => Stmt::While {
113                    cond,
114                    body: silence_all(body),
115                },
116                Stmt::DoUntil { body, cond } => Stmt::DoUntil {
117                    body: silence_all(body),
118                    cond,
119                },
120                Stmt::Switch {
121                    expr,
122                    cases,
123                    otherwise_body,
124                } => Stmt::Switch {
125                    expr,
126                    cases: cases
127                        .into_iter()
128                        .map(|(v, b)| (v, silence_all(b)))
129                        .collect(),
130                    otherwise_body: otherwise_body.map(silence_all),
131                },
132                Stmt::TryCatch {
133                    try_body,
134                    catch_var,
135                    catch_body,
136                } => Stmt::TryCatch {
137                    try_body: silence_all(try_body),
138                    catch_var,
139                    catch_body: silence_all(catch_body),
140                },
141                other => other,
142            };
143            (stmt, true)
144        })
145        .collect()
146}
147
148/// Returns a parsed, all-silent body for `body_source`, using the cache when possible.
149///
150/// "All-silent" means every `(Stmt, bool)` has `bool = true` — function bodies
151/// never print output directly. The parse result is shared via `Rc` so that
152/// repeated calls to the same function avoid both allocation and parsing work.
153fn get_or_parse_body(body_source: &str) -> Result<Rc<Vec<(Stmt, bool)>>, String> {
154    BODY_CACHE.with(|cache| {
155        let mut cache = cache.borrow_mut();
156        if let Some(body) = cache.get(body_source) {
157            return Ok(Rc::clone(body));
158        }
159        let stmts =
160            parse_stmts(body_source).map_err(|e| format!("function body parse error: {e}"))?;
161        let silent = silence_all(stmts);
162        let rc = Rc::new(silent);
163        cache.insert(body_source.to_string(), Rc::clone(&rc));
164        Ok(rc)
165    })
166}
167
168/// Flow control signal returned by [`exec_stmts`].
169///
170/// Used to propagate `break`, `continue`, and `return` through nested block calls.
171/// Loop implementations catch `Break`/`Continue`; function call implementation catches `Return`.
172/// Uncaught signals at the top level are reported as errors.
173pub enum Signal {
174    /// `break` — exit the innermost enclosing loop immediately.
175    Break,
176    /// `continue` — skip the rest of the current iteration and advance to the next.
177    Continue,
178    /// `return` inside a named function — carries no value (outputs are read from env).
179    Return,
180}
181
182/// Initialises exec-level hooks in `eval.rs` so that `eval_inner` can call user functions.
183///
184/// Must be called once at program startup before any evaluation takes place.
185pub fn init() {
186    set_fn_call_hook(call_user_function);
187    set_autoload_hook(try_autoload);
188}
189
190/// Searches for `<name>.calc` or `<name>.m` on the session path and loads it.
191///
192/// Mirrors MATLAB/Octave autoload: when a function name is not found in the
193/// environment, ccalc looks for a matching file on the path, loads its primary
194/// function into the autoload cache, and returns `true` on success.
195/// For package-qualified names (containing `.`), delegates to [`try_autoload_pkg`].
196fn try_autoload(name: &str) -> bool {
197    if name.contains('.') {
198        return try_autoload_pkg(name);
199    }
200    let candidates = [format!("{name}.calc"), format!("{name}.m")];
201    for candidate in &candidates {
202        let Some(path) = resolve_script_path(candidate) else {
203            continue;
204        };
205        let Ok(content) = std::fs::read_to_string(&path) else {
206            continue;
207        };
208        let Ok(stmts) = parse_stmts(&content) else {
209            continue;
210        };
211        if !matches!(stmts.first(), Some((Stmt::FunctionDef { .. }, _))) {
212            continue;
213        }
214        let primary_name = match &stmts[0].0 {
215            Stmt::FunctionDef { name, .. } => name.clone(),
216            _ => continue,
217        };
218        let mut locals: IndexMap<String, Value> = IndexMap::new();
219        for (stmt, _) in &stmts {
220            if let Stmt::FunctionDef {
221                name: n,
222                outputs,
223                params,
224                body_source,
225                doc,
226            } = stmt
227                && n != &primary_name
228            {
229                locals.insert(
230                    n.clone(),
231                    Value::Function {
232                        outputs: outputs.clone(),
233                        params: params.clone(),
234                        body_source: body_source.clone(),
235                        locals: IndexMap::new(),
236                        doc: doc.clone(),
237                    },
238                );
239            }
240        }
241        if let Stmt::FunctionDef {
242            outputs,
243            params,
244            body_source,
245            doc,
246            ..
247        } = &stmts[0].0
248        {
249            autoload_cache_insert(
250                primary_name,
251                Value::Function {
252                    outputs: outputs.clone(),
253                    params: params.clone(),
254                    body_source: body_source.clone(),
255                    locals,
256                    doc: doc.clone(),
257                },
258            );
259            return true;
260        }
261    }
262    false
263}
264
265/// Searches for a package-qualified function and loads it into the autoload cache.
266///
267/// A qualified name like `"utils.my_func"` maps to `+utils/my_func.calc`.
268/// Nested packages like `"utils.math.lerp"` map to `+utils/+math/lerp.calc`.
269/// Searches SCRIPT_DIR_STACK → CWD → SESSION_PATH and caches under the qualified name.
270fn try_autoload_pkg(qualified: &str) -> bool {
271    let parts: Vec<&str> = qualified.split('.').collect();
272    if parts.len() < 2 {
273        return false;
274    }
275    let func_name = *parts.last().unwrap();
276    let pkg_parts = &parts[..parts.len() - 1];
277
278    // Build relative path prefix: +pkg1/+pkg2/...
279    let mut rel_prefix = std::path::PathBuf::new();
280    for pkg in pkg_parts {
281        rel_prefix.push(format!("+{pkg}"));
282    }
283
284    let candidates = [
285        rel_prefix.join(format!("{func_name}.calc")),
286        rel_prefix.join(format!("{func_name}.m")),
287    ];
288
289    // Collect search directories: script dirs + CWD + session path.
290    let mut search_dirs: Vec<std::path::PathBuf> = Vec::new();
291    SCRIPT_DIR_STACK.with(|s| search_dirs.extend(s.borrow().iter().cloned()));
292    search_dirs.push(std::path::PathBuf::from("."));
293    SESSION_PATH.with(|s| search_dirs.extend(s.borrow().iter().cloned()));
294
295    for dir in &search_dirs {
296        for candidate in &candidates {
297            let full = dir.join(candidate);
298            let Ok(content) = std::fs::read_to_string(&full) else {
299                continue;
300            };
301            let Ok(stmts) = parse_stmts(&content) else {
302                continue;
303            };
304            if !matches!(stmts.first(), Some((Stmt::FunctionDef { .. }, _))) {
305                continue;
306            }
307            let primary_name = match &stmts[0].0 {
308                Stmt::FunctionDef { name, .. } => name.clone(),
309                _ => continue,
310            };
311            let mut locals: IndexMap<String, Value> = IndexMap::new();
312            for (stmt, _) in &stmts {
313                if let Stmt::FunctionDef {
314                    name: n,
315                    outputs,
316                    params,
317                    body_source,
318                    doc,
319                } = stmt
320                    && n != &primary_name
321                {
322                    locals.insert(
323                        n.clone(),
324                        Value::Function {
325                            outputs: outputs.clone(),
326                            params: params.clone(),
327                            body_source: body_source.clone(),
328                            locals: IndexMap::new(),
329                            doc: doc.clone(),
330                        },
331                    );
332                }
333            }
334            if let Stmt::FunctionDef {
335                outputs,
336                params,
337                body_source,
338                doc,
339                ..
340            } = &stmts[0].0
341            {
342                autoload_cache_insert(
343                    qualified.to_string(),
344                    Value::Function {
345                        outputs: outputs.clone(),
346                        params: params.clone(),
347                        body_source: body_source.clone(),
348                        locals,
349                        doc: doc.clone(),
350                    },
351                );
352                return true;
353            }
354        }
355    }
356    false
357}
358
359/// Push a script directory onto the search stack.
360///
361/// Call this before executing a top-level script file so that `run()`/`source()` calls
362/// inside the script can find helper files by bare name relative to the script's directory.
363/// Always paired with a matching `script_dir_pop`.
364pub fn script_dir_push(dir: &std::path::Path) {
365    SCRIPT_DIR_STACK.with(|s| s.borrow_mut().push(dir.to_path_buf()));
366}
367
368/// Pop the most recently pushed script directory from the search stack.
369pub fn script_dir_pop() {
370    SCRIPT_DIR_STACK.with(|s| s.borrow_mut().pop());
371}
372
373/// Initializes the session search path from the config `path` array.
374///
375/// Called once at startup (after loading `config.toml`). Each entry has `~` already
376/// expanded by the caller.
377pub fn session_path_init(paths: Vec<std::path::PathBuf>) {
378    SESSION_PATH.with(|p| *p.borrow_mut() = paths);
379}
380
381/// Prepends (default) or appends a directory to the session search path.
382///
383/// If the same path is already present it is removed from its current position
384/// before being re-inserted, so the path list contains no duplicates.
385pub fn session_path_add(path: std::path::PathBuf, append: bool) {
386    SESSION_PATH.with(|p| {
387        let mut v = p.borrow_mut();
388        v.retain(|e| e != &path);
389        if append {
390            v.push(path);
391        } else {
392            v.insert(0, path);
393        }
394    });
395}
396
397/// Removes a directory from the session search path (exact match).
398pub fn session_path_remove(path: &std::path::Path) {
399    SESSION_PATH.with(|p| p.borrow_mut().retain(|e| e.as_path() != path));
400}
401
402/// Returns a snapshot of the current session search path.
403pub fn session_path_list() -> Vec<std::path::PathBuf> {
404    SESSION_PATH.with(|p| p.borrow().clone())
405}
406
407/// Called by `eval_inner` whenever a user function (`Value::Function`) is invoked.
408///
409/// Executes the function body in an isolated scope containing only the parameters plus
410/// any callable values (`Function`/`Lambda`) from the caller's environment, enabling
411/// recursion and mutual recursion.
412/// Multi-return: if the function has >1 output, returns `Value::Tuple`.
413fn call_user_function(
414    name: &str,
415    func: &Value,
416    args: &[Value],
417    caller_env: &Env,
418    io: &mut IoContext,
419) -> Result<Value, String> {
420    let Value::Function {
421        outputs,
422        params,
423        body_source,
424        locals,
425        ..
426    } = func
427    else {
428        return Err("call_user_function: not a Function value".to_string());
429    };
430
431    // Push global and persistent tracking frames for this function call.
432    global_frame_push();
433    persistent_frame_push(name); // `name` is the function name from the call site
434
435    // Build isolated scope: seed imaginary unit and ans, then copy all callable
436    // values (Function/Lambda) from the caller's environment so that recursion
437    // and mutual recursion work correctly.
438    let mut local_env = Env::new();
439    local_env.insert("i".to_string(), Value::Complex(0.0, 1.0));
440    local_env.insert("j".to_string(), Value::Complex(0.0, 1.0));
441    local_env.insert("ans".to_string(), Value::Scalar(0.0));
442    // Local helper functions from the same function file (MATLAB-style scoping).
443    // These take priority and are always available regardless of the caller's env.
444    for (fn_name, val) in locals.iter() {
445        local_env.insert(fn_name.clone(), val.clone());
446    }
447    for (var_name, val) in caller_env.iter() {
448        if matches!(val, Value::Function { .. } | Value::Lambda(_)) {
449            local_env.insert(var_name.clone(), val.clone());
450        }
451    }
452
453    // Check for varargin: last parameter is 'varargin' → variadic function.
454    let has_varargin = params.last().is_some_and(|p| p == "varargin");
455    let fixed_params = if has_varargin {
456        &params[..params.len() - 1]
457    } else {
458        params.as_slice()
459    };
460
461    // Trim any trailing args beyond what the function declares.
462    // The parser injects `ans` for empty `f()` calls; for 0-param functions
463    // we must silently ignore it. For N-param functions, allow up to N+1
464    // args before complaining (one implicit `ans` is always present).
465    let effective_args = if args.len() > params.len() {
466        if !has_varargin && args.len() > params.len() + 1 {
467            return Err(format!(
468                "Too many arguments: expected at most {}, got {}",
469                params.len(),
470                args.len()
471            ));
472        }
473        if has_varargin {
474            args
475        } else {
476            // Exactly 1 extra: the implicit `ans` — trim it.
477            &args[..params.len()]
478        }
479    } else {
480        args
481    };
482
483    // Bind fixed parameters
484    for (p, a) in fixed_params.iter().zip(effective_args.iter()) {
485        local_env.insert(p.clone(), a.clone());
486    }
487
488    // If varargin, collect remaining args into a Cell
489    if has_varargin {
490        // Collect extra args beyond the fixed parameters into varargin.
491        // User functions do not receive an injected `ans`; the arg list reflects
492        // exactly what the caller passed.
493        let extra: Vec<Value> = effective_args
494            .get(fixed_params.len()..)
495            .unwrap_or(&[])
496            .to_vec();
497        let varargin = Value::Cell(extra);
498        local_env.insert("varargin".to_string(), varargin);
499    }
500
501    let nargin = effective_args.len().min(params.len());
502    local_env.insert("nargin".to_string(), Value::Scalar(nargin as f64));
503    local_env.insert("nargout".to_string(), Value::Scalar(outputs.len() as f64));
504
505    // Retrieve (or parse-and-cache) the function body, then execute it.
506    let body = get_or_parse_body(body_source)?;
507    let fmt = get_display_fmt();
508    let base = get_display_base();
509    let compact = get_display_compact();
510    let exec_result = exec_stmts(&body, &mut local_env, io, &fmt, base, compact);
511
512    // Save persistent variables before unwinding the frame (even on error).
513    let (func_name_saved, persistent_names) = persistent_frame_pop();
514    for var_name in &persistent_names {
515        if let Some(val) = local_env.get(var_name) {
516            persistent_save(&func_name_saved, var_name, val.clone());
517        }
518    }
519    // Pop the global names frame.
520    global_frame_pop();
521
522    // Propagate any execution error after saving persistent state.
523    match exec_result? {
524        None | Some(Signal::Return) => {}
525        Some(Signal::Break) => return Err("'break' outside loop".to_string()),
526        Some(Signal::Continue) => return Err("'continue' outside loop".to_string()),
527    }
528
529    // Collect return values
530    if outputs.is_empty() {
531        return Ok(Value::Void);
532    }
533
534    // varargout: single output named 'varargout' — expand from cell
535    if outputs.len() == 1 && outputs[0] == "varargout" {
536        let cell = local_env.remove("varargout").unwrap_or(Value::Cell(vec![]));
537        return match cell {
538            Value::Cell(mut v) => {
539                if v.is_empty() {
540                    Ok(Value::Void)
541                } else if v.len() == 1 {
542                    Ok(v.remove(0))
543                } else {
544                    Ok(Value::Tuple(v))
545                }
546            }
547            other => Ok(other),
548        };
549    }
550
551    if outputs.len() == 1 {
552        return Ok(local_env.remove(&outputs[0]).unwrap_or(Value::Void));
553    }
554    let vals: Vec<Value> = outputs
555        .iter()
556        .map(|o| local_env.remove(o).unwrap_or(Value::Void))
557        .collect();
558    Ok(Value::Tuple(vals))
559}
560
561/// Resolves a script filename to an existing path.
562///
563/// If `name` already has an extension, it is used verbatim.
564/// Otherwise, `.calc` is tried first (native ccalc format), then `.m` (Octave/MATLAB compatibility).
565/// The search is relative to the current working directory.
566pub fn resolve_script_path(name: &str) -> Option<std::path::PathBuf> {
567    // Build candidate base paths.
568    //
569    // MATLAB `private/` semantics: a `private/` sub-directory is visible only
570    // to scripts/functions in its parent directory.  Therefore:
571    //   • SCRIPT_DIR_STACK entries (the calling script's own directory) ARE
572    //     allowed to search their `private/` sub-folder — checked first.
573    //   • CWD and SESSION_PATH entries do NOT get a `private/` look-aside;
574    //     they can only see files directly in those directories.
575    let p = std::path::Path::new(name);
576    let mut bases: Vec<std::path::PathBuf> = Vec::new();
577
578    // Stacked script directories (most-recent first): private/ before the dir.
579    SCRIPT_DIR_STACK.with(|stack| {
580        for dir in stack.borrow().iter().rev() {
581            bases.push(dir.join("private").join(p));
582            bases.push(dir.join(p));
583        }
584    });
585
586    // CWD-relative (no private/ look-aside).
587    bases.push(p.to_path_buf());
588
589    // Session search-path entries (no private/ look-aside).
590    SESSION_PATH.with(|sp| {
591        for dir in sp.borrow().iter() {
592            bases.push(dir.join(p));
593        }
594    });
595
596    for base in &bases {
597        if base.extension().is_some() {
598            if base.exists() {
599                return Some(base.clone());
600            }
601            // Explicit extension given but not found — try next base.
602            continue;
603        }
604        let with_calc = base.with_extension("calc");
605        if with_calc.exists() {
606            return Some(with_calc);
607        }
608        let with_m = base.with_extension("m");
609        if with_m.exists() {
610            return Some(with_m);
611        }
612    }
613    None
614}
615
616/// Returns `true` if `val` is considered truthy by MATLAB `if`/`while` semantics.
617///
618/// - Scalar: nonzero and not NaN.
619/// - Matrix: all elements nonzero and not NaN.
620/// - Complex: either part nonzero.
621/// - Str/StringObj: nonempty.
622/// - Void: always false.
623fn is_truthy(val: &Value) -> bool {
624    match val {
625        Value::Scalar(n) => *n != 0.0 && !n.is_nan(),
626        Value::Matrix(m) => m.iter().all(|&x| x != 0.0 && !x.is_nan()),
627        Value::Complex(re, im) => *re != 0.0 || *im != 0.0,
628        Value::Str(s) | Value::StringObj(s) => !s.is_empty(),
629        Value::Void => false,
630        // Functions are truthy (they exist), but comparing them to 0 makes no sense.
631        // Treat as truthy so that `if f` doesn't silently fail.
632        Value::Lambda(_) | Value::Function { .. } | Value::Tuple(_) => true,
633        // A cell is truthy if nonempty.
634        Value::Cell(v) => !v.is_empty(),
635        // A struct / struct array is always truthy.
636        Value::Struct(_) | Value::StructArray(_) => true,
637    }
638}
639
640/// Prints a value to stdout with MATLAB-style formatting.
641///
642/// `label` is `Some("name")` for assignment output and `None` for expression output.
643/// In expression context scalars/complex print without a label; matrices print `ans =`.
644fn print_value(label: Option<&str>, val: &Value, fmt: &FormatMode, base: Base, compact: bool) {
645    match val {
646        Value::Void => {}
647        Value::Scalar(n) => {
648            if let Some(name) = label {
649                println!("{name} = {}", format_scalar(*n, base, fmt));
650            } else {
651                println!("{}", format_scalar(*n, base, fmt));
652            }
653        }
654        Value::Matrix(_) => {
655            if let Some(full) = format_value_full(val, fmt) {
656                let prefix = label.unwrap_or("ans");
657                println!("{prefix} =");
658                println!("{full}");
659                if !compact {
660                    println!();
661                }
662            }
663        }
664        Value::Complex(re, im) => {
665            if let Some(name) = label {
666                println!("{name} = {}", format_complex(*re, *im, fmt));
667            } else {
668                println!("{}", format_complex(*re, *im, fmt));
669            }
670        }
671        Value::Str(s) | Value::StringObj(s) => {
672            if let Some(name) = label {
673                println!("{name} = {s}");
674            } else {
675                println!("{s}");
676            }
677        }
678        Value::Lambda(_) => {
679            if let Some(name) = label {
680                println!("{name} = @<lambda>");
681            } else {
682                println!("@<lambda>");
683            }
684        }
685        Value::Function {
686            outputs, params, ..
687        } => {
688            let params_str = params.join(", ");
689            let out_str = match outputs.len() {
690                0 => String::new(),
691                1 => format!("{} = ", outputs[0]),
692                _ => format!("[{}] = ", outputs.join(", ")),
693            };
694            if let Some(name) = label {
695                println!("{name} = @function {out_str}{name}({params_str})");
696            } else {
697                println!("@function {out_str}f({params_str})");
698            }
699        }
700        Value::Tuple(vals) => {
701            // Tuples are internal and shouldn't normally be displayed at the REPL level.
702            // This can happen if a multi-output function is called without multi-assign.
703            for (i, v) in vals.iter().enumerate() {
704                print_value(label.map(|_| "ans").or(Some("ans")), v, fmt, base, compact);
705                let _ = i;
706            }
707        }
708        Value::Cell(_) | Value::Struct(_) | Value::StructArray(_) => {
709            if let Some(full) = format_value_full(val, fmt) {
710                let prefix = label.unwrap_or("ans");
711                println!("{prefix} =");
712                println!("{full}");
713                if !compact {
714                    println!();
715                }
716            }
717        }
718    }
719}
720
721/// Recursively sets a value at `path` inside a nested struct map.
722///
723/// Ownership-by-value approach: consumes the map, updates it, and returns the updated map.
724/// Intermediate structs are created on demand if a path segment does not yet exist.
725fn set_nested(
726    mut map: IndexMap<String, Value>,
727    path: &[String],
728    val: Value,
729) -> Result<IndexMap<String, Value>, String> {
730    let (first, rest) = path.split_first().expect("set_nested: empty path");
731    if rest.is_empty() {
732        map.insert(first.clone(), val);
733    } else {
734        let inner = match map.shift_remove(first) {
735            Some(Value::Struct(m)) => m,
736            None => IndexMap::new(),
737            Some(other) => {
738                map.insert(first.clone(), other);
739                return Err(format!("'{first}' is not a struct"));
740            }
741        };
742        let updated = set_nested(inner, rest, val)?;
743        map.insert(first.clone(), Value::Struct(updated));
744    }
745    Ok(map)
746}
747
748/// Executes a sequence of parsed statements, handling flow control signals.
749///
750/// Returns:
751/// - `Ok(None)` — normal completion
752/// - `Ok(Some(Signal::Break))` — `break` statement executed
753/// - `Ok(Some(Signal::Continue))` — `continue` statement executed
754/// - `Err(e)` — runtime error
755///
756/// Loop implementations (`For`, `While`) catch `Break`/`Continue` internally.
757/// A signal that escapes to the top-level caller should be reported as an error.
758pub fn exec_stmts(
759    stmts: &[(Stmt, bool)],
760    env: &mut Env,
761    io: &mut IoContext,
762    fmt: &FormatMode,
763    base: Base,
764    compact: bool,
765) -> Result<Option<Signal>, String> {
766    // Propagate display settings to eval.rs so named function bodies can use them.
767    set_display_ctx(fmt, base, compact);
768
769    for (stmt, silent) in stmts {
770        match stmt {
771            Stmt::Assign(name, expr) => {
772                set_nargout(1);
773                let val = eval_with_io(expr, env, io)?;
774                env.insert(name.clone(), val.clone());
775                // Mirror to the shared global store when declared global in this scope.
776                if is_global(name) {
777                    global_set(name, val.clone());
778                }
779                // Write-through for persistent vars: recursive calls can see the update.
780                if is_persistent(name) {
781                    persistent_save(&current_func_name(), name, val.clone());
782                }
783                if !silent && !matches!(val, Value::Void) {
784                    print_value(Some(name), &val, fmt, base, compact);
785                }
786            }
787
788            Stmt::Global(names) => {
789                for name in names {
790                    global_declare(name);
791                    global_init_if_absent(name);
792                    // If the name was already assigned in local env, promote it to global.
793                    // Otherwise restore the current global value into local env.
794                    if let Some(local_val) = env.remove(name) {
795                        global_set(name, local_val.clone());
796                        env.insert(name.clone(), local_val);
797                    } else if let Some(global_val) = global_get(name) {
798                        env.insert(name.clone(), global_val);
799                    }
800                }
801            }
802
803            Stmt::Persistent(names) => {
804                // Persistent is only meaningful inside a named function.
805                // At the top level it is accepted and treated as a no-op.
806                let func = current_func_name();
807                for name in names {
808                    persistent_declare(name);
809                    if let Some(saved) = persistent_load(&func, name) {
810                        // Subsequent call: restore the saved value.
811                        env.insert(name.clone(), saved);
812                    } else {
813                        // First call: initialize to [] (empty matrix), matching MATLAB.
814                        // This makes isempty(x) true so guards like
815                        // `if isempty(x); x = 0; end` work correctly.
816                        env.insert(name.clone(), Value::Matrix(ndarray::Array2::zeros((0, 0))));
817                    }
818                }
819            }
820
821            Stmt::Expr(expr) => {
822                // Intercept addpath()/rmpath()/path() — mutate the session search path.
823                if let Expr::Call(fn_name, args) = expr
824                    && matches!(fn_name.as_str(), "addpath" | "rmpath" | "path")
825                {
826                    match fn_name.as_str() {
827                        "addpath" => {
828                            if args.is_empty() || args.len() > 2 {
829                                return Err(
830                                    "addpath: expects 1 or 2 arguments: addpath(dir) or addpath(dir, '-end')".to_string()
831                                );
832                            }
833                            let path_val = eval_with_io(&args[0], env, io)?;
834                            let path_str = match &path_val {
835                                Value::Str(s) | Value::StringObj(s) => s.clone(),
836                                _ => {
837                                    return Err(
838                                        "addpath: argument must be a string (directory path)"
839                                            .to_string(),
840                                    );
841                                }
842                            };
843                            let append = if args.len() == 2 {
844                                let flag_val = eval_with_io(&args[1], env, io)?;
845                                match &flag_val {
846                                    Value::Str(s) | Value::StringObj(s) if s == "-end" => true,
847                                    Value::Str(_) | Value::StringObj(_) => {
848                                        return Err(
849                                            "addpath: second argument must be '-end' (to append) or omitted (to prepend)".to_string()
850                                        );
851                                    }
852                                    _ => {
853                                        return Err(
854                                            "addpath: second argument must be a string '-end'"
855                                                .to_string(),
856                                        );
857                                    }
858                                }
859                            } else {
860                                false
861                            };
862                            let expanded = expand_tilde(&path_str);
863                            let pb = std::path::PathBuf::from(&expanded);
864                            session_path_add(pb, append);
865                            if !silent {
866                                for p in session_path_list() {
867                                    println!("{}", p.display());
868                                }
869                            }
870                        }
871                        "rmpath" => {
872                            if args.len() != 1 {
873                                return Err("rmpath: expects exactly 1 argument".to_string());
874                            }
875                            let path_val = eval_with_io(&args[0], env, io)?;
876                            let path_str = match &path_val {
877                                Value::Str(s) | Value::StringObj(s) => s.clone(),
878                                _ => {
879                                    return Err(
880                                        "rmpath: argument must be a string (directory path)"
881                                            .to_string(),
882                                    );
883                                }
884                            };
885                            let expanded = expand_tilde(&path_str);
886                            session_path_remove(std::path::Path::new(&expanded));
887                        }
888                        "path" => {
889                            if !args.is_empty() {
890                                return Err("path: takes no arguments".to_string());
891                            }
892                            if !silent {
893                                let paths = session_path_list();
894                                if paths.is_empty() {
895                                    println!("(search path is empty)");
896                                } else {
897                                    for p in &paths {
898                                        println!("{}", p.display());
899                                    }
900                                }
901                            }
902                        }
903                        _ => unreachable!(),
904                    }
905                    continue;
906                }
907
908                // Intercept run()/source() — execute a script file in the current workspace.
909                // Variables defined in the script persist in the caller's scope (MATLAB `run` semantics).
910                if let Expr::Call(fn_name, args) = expr
911                    && matches!(fn_name.as_str(), "run" | "source")
912                    && args.len() == 1
913                {
914                    let path_val = eval_with_io(&args[0], env, io)?;
915                    let filename = match &path_val {
916                        Value::Str(s) | Value::StringObj(s) => s.clone(),
917                        _ => {
918                            return Err(format!("{fn_name}: argument must be a string (filename)"));
919                        }
920                    };
921                    let script_path = resolve_script_path(&filename)
922                        .ok_or_else(|| format!("{fn_name}: script not found: '{filename}'"))?;
923                    let content = std::fs::read_to_string(&script_path).map_err(|e| {
924                        format!("{fn_name}: cannot read '{}': {e}", script_path.display())
925                    })?;
926                    let depth = RUN_DEPTH.with(|d| d.get());
927                    if depth >= 64 {
928                        return Err(format!(
929                            "{fn_name}: maximum script nesting depth (64) exceeded"
930                        ));
931                    }
932                    RUN_DEPTH.with(|d| d.set(depth + 1));
933                    // Push the script's directory so nested run()/source() calls resolve
934                    // helper scripts relative to the calling script's location.
935                    if let Some(dir) = script_path.parent() {
936                        SCRIPT_DIR_STACK.with(|s| s.borrow_mut().push(dir.to_path_buf()));
937                    }
938                    let run_stmts = parse_stmts(&content).map_err(|e| {
939                        format!("{fn_name}: parse error in '{}': {e}", script_path.display())
940                    })?;
941
942                    // MATLAB scoping: if EVERY statement is a function definition,
943                    // treat it as a function file — expose only the primary function and
944                    // bundle all helpers into its `locals` (invisible to the caller).
945                    // A mixed script+function file (functions-at-top style) is NOT a
946                    // function file; its script body must also execute.
947                    let is_fn_file = !run_stmts.is_empty()
948                        && run_stmts
949                            .iter()
950                            .all(|(s, _)| matches!(s, Stmt::FunctionDef { .. }));
951                    let result = if is_fn_file {
952                        let primary_name = match &run_stmts[0].0 {
953                            Stmt::FunctionDef { name, .. } => name.clone(),
954                            _ => unreachable!(),
955                        };
956                        let mut locals: IndexMap<String, Value> = IndexMap::new();
957                        for (stmt, _) in &run_stmts {
958                            if let Stmt::FunctionDef {
959                                name,
960                                outputs,
961                                params,
962                                body_source,
963                                doc,
964                            } = stmt
965                                && name != &primary_name
966                            {
967                                locals.insert(
968                                    name.clone(),
969                                    Value::Function {
970                                        outputs: outputs.clone(),
971                                        params: params.clone(),
972                                        body_source: body_source.clone(),
973                                        locals: IndexMap::new(),
974                                        doc: doc.clone(),
975                                    },
976                                );
977                            }
978                        }
979                        if let Stmt::FunctionDef {
980                            outputs,
981                            params,
982                            body_source,
983                            doc,
984                            ..
985                        } = &run_stmts[0].0
986                        {
987                            env.insert(
988                                primary_name,
989                                Value::Function {
990                                    outputs: outputs.clone(),
991                                    params: params.clone(),
992                                    body_source: body_source.clone(),
993                                    locals,
994                                    doc: doc.clone(),
995                                },
996                            );
997                        }
998                        Ok(None)
999                    } else {
1000                        // Pre-load all local function defs so that forward references
1001                        // within the script work (MATLAB local-function semantics).
1002                        for (stmt, _) in run_stmts.iter() {
1003                            if let Stmt::FunctionDef {
1004                                name,
1005                                outputs,
1006                                params,
1007                                body_source,
1008                                doc,
1009                            } = stmt
1010                            {
1011                                env.insert(
1012                                    name.clone(),
1013                                    Value::Function {
1014                                        outputs: outputs.clone(),
1015                                        params: params.clone(),
1016                                        body_source: body_source.clone(),
1017                                        locals: IndexMap::new(),
1018                                        doc: doc.clone(),
1019                                    },
1020                                );
1021                            }
1022                        }
1023                        exec_stmts(&run_stmts, env, io, fmt, base, compact)
1024                    };
1025                    SCRIPT_DIR_STACK.with(|s| s.borrow_mut().pop());
1026                    RUN_DEPTH.with(|d| d.set(depth));
1027                    // Propagate signals (return/break/continue) but do NOT early-return
1028                    // from exec_stmts — remaining statements in the outer script must run.
1029                    match result? {
1030                        None => {}
1031                        Some(sig) => return Ok(Some(sig)),
1032                    }
1033                    continue;
1034                }
1035
1036                // Intercept clear() / clear('x','y') — workspace variable removal.
1037                if let Expr::Call(fn_name, args) = expr
1038                    && fn_name == "clear"
1039                {
1040                    if args.is_empty() {
1041                        env.clear();
1042                    } else {
1043                        for arg in args {
1044                            let key = match arg {
1045                                Expr::StrLiteral(s) | Expr::StringObjLiteral(s) => s.clone(),
1046                                other => match eval_with_io(other, env, io)? {
1047                                    Value::Str(s) | Value::StringObj(s) => s,
1048                                    _ => continue,
1049                                },
1050                            };
1051                            env.remove(&key);
1052                        }
1053                    }
1054                    continue;
1055                }
1056
1057                // Intercept format — update thread-local display context.
1058                if let Expr::Call(fn_name, args) = expr
1059                    && fn_name == "format"
1060                {
1061                    let arg = match args.first() {
1062                        Some(Expr::StrLiteral(s)) => s.as_str(),
1063                        None => "",
1064                        _ => "",
1065                    };
1066                    let new_fmt = match arg {
1067                        "" | "short" => FormatMode::Short,
1068                        "long" => FormatMode::Long,
1069                        "shorte" | "shortE" => FormatMode::ShortE,
1070                        "longe" | "longE" => FormatMode::LongE,
1071                        "shortg" | "shortG" => FormatMode::ShortG,
1072                        "longg" | "longG" => FormatMode::LongG,
1073                        "bank" => FormatMode::Bank,
1074                        "rat" => FormatMode::Rat,
1075                        "hex" => FormatMode::Hex,
1076                        "+" => FormatMode::Plus,
1077                        "compact" | "loose" => get_display_fmt(),
1078                        s => s
1079                            .parse::<usize>()
1080                            .map(FormatMode::Custom)
1081                            .unwrap_or_else(|_| get_display_fmt()),
1082                    };
1083                    let new_compact = match arg {
1084                        "compact" => true,
1085                        "loose" => false,
1086                        _ => get_display_compact(),
1087                    };
1088                    set_display_ctx(&new_fmt, base, new_compact);
1089                    continue;
1090                }
1091
1092                // Intercept save()/load() — workspace persistence (same semantics as REPL).
1093                if let Expr::Call(fn_name, args) = expr
1094                    && matches!(fn_name.as_str(), "save" | "load" | "ws" | "wl")
1095                {
1096                    let is_save = matches!(fn_name.as_str(), "save" | "ws");
1097                    if is_save {
1098                        let (path_opt, var_names) = if args.is_empty() {
1099                            (None, vec![])
1100                        } else {
1101                            let path_val = eval_with_io(&args[0], env, io)?;
1102                            let path_str = match path_val {
1103                                Value::Str(s) | Value::StringObj(s) => s,
1104                                _ => return Err("save: path argument must be a string".to_string()),
1105                            };
1106                            let mut vars: Vec<String> = Vec::new();
1107                            for a in &args[1..] {
1108                                let v = eval_with_io(a, env, io)?;
1109                                match v {
1110                                    Value::Str(s) | Value::StringObj(s) => vars.push(s),
1111                                    _ => {
1112                                        return Err(
1113                                            "save: variable names must be strings".to_string()
1114                                        );
1115                                    }
1116                                }
1117                            }
1118                            (Some(path_str), vars)
1119                        };
1120                        let result = match &path_opt {
1121                            None => {
1122                                let home = std::env::var("HOME")
1123                                    .or_else(|_| std::env::var("USERPROFILE"))
1124                                    .unwrap_or_default();
1125                                let p = std::path::Path::new(&home)
1126                                    .join(".config")
1127                                    .join("ccalc")
1128                                    .join("workspace.toml");
1129                                save_workspace(env, &p)
1130                            }
1131                            Some(p) if var_names.is_empty() => {
1132                                save_workspace(env, std::path::Path::new(p))
1133                            }
1134                            Some(p) => {
1135                                let refs: Vec<&str> =
1136                                    var_names.iter().map(String::as_str).collect();
1137                                save_workspace_vars(env, std::path::Path::new(p), &refs)
1138                            }
1139                        };
1140                        if let Err(e) = result {
1141                            return Err(format!("save: {e}"));
1142                        }
1143                    } else {
1144                        // load
1145                        let loaded = if args.is_empty() {
1146                            let home = std::env::var("HOME")
1147                                .or_else(|_| std::env::var("USERPROFILE"))
1148                                .unwrap_or_default();
1149                            let p = std::path::Path::new(&home)
1150                                .join(".config")
1151                                .join("ccalc")
1152                                .join("workspace.toml");
1153                            load_workspace(&p)
1154                        } else {
1155                            let path_val = eval_with_io(&args[0], env, io)?;
1156                            let path_str = match path_val {
1157                                Value::Str(s) | Value::StringObj(s) => s,
1158                                _ => return Err("load: path argument must be a string".to_string()),
1159                            };
1160                            load_workspace(std::path::Path::new(&path_str))
1161                        };
1162                        match loaded {
1163                            Ok(ws) => env.extend(ws),
1164                            Err(e) => return Err(format!("load: {e}")),
1165                        }
1166                    }
1167                    continue;
1168                }
1169
1170                let val = eval_with_io(expr, env, io)?;
1171                env.insert("ans".to_string(), val.clone());
1172                if !silent && !matches!(val, Value::Void) {
1173                    print_value(None, &val, fmt, base, compact);
1174                }
1175            }
1176
1177            Stmt::If {
1178                cond,
1179                body,
1180                elseif_branches,
1181                else_body,
1182            } => {
1183                let cond_val = eval_with_io(cond, env, io)?;
1184                let chosen: Option<&[(Stmt, bool)]> = if is_truthy(&cond_val) {
1185                    Some(body)
1186                } else {
1187                    let mut found = None;
1188                    for (ei_cond, ei_body) in elseif_branches {
1189                        if is_truthy(&eval_with_io(ei_cond, env, io)?) {
1190                            found = Some(ei_body.as_slice());
1191                            break;
1192                        }
1193                    }
1194                    if found.is_none() {
1195                        found = else_body.as_deref();
1196                    }
1197                    found
1198                };
1199                if let Some(body_stmts) = chosen
1200                    && let Some(sig) = exec_stmts(body_stmts, env, io, fmt, base, compact)?
1201                {
1202                    return Ok(Some(sig));
1203                }
1204            }
1205
1206            Stmt::For {
1207                var,
1208                range_expr,
1209                body,
1210            } => {
1211                let range_val = eval_with_io(range_expr, env, io)?;
1212                let iter_cols: Vec<Value> = match range_val {
1213                    Value::Scalar(n) => vec![Value::Scalar(n)],
1214                    Value::Matrix(m) => {
1215                        let nrows = m.nrows();
1216                        let ncols = m.ncols();
1217                        (0..ncols)
1218                            .map(|j| {
1219                                if nrows == 1 {
1220                                    // Row vector: yield each element as a scalar
1221                                    Value::Scalar(m[[0, j]])
1222                                } else {
1223                                    // General matrix: yield each column as an M×1 matrix
1224                                    let mut col = Array2::zeros((nrows, 1));
1225                                    for i in 0..nrows {
1226                                        col[[i, 0]] = m[[i, j]];
1227                                    }
1228                                    Value::Matrix(col)
1229                                }
1230                            })
1231                            .collect()
1232                    }
1233                    _ => return Err("'for' range must evaluate to a scalar or matrix".to_string()),
1234                };
1235
1236                'for_loop: for col_val in iter_cols {
1237                    env.insert(var.clone(), col_val);
1238                    match exec_stmts(body, env, io, fmt, base, compact)? {
1239                        None => {}
1240                        Some(Signal::Break) => break 'for_loop,
1241                        Some(Signal::Continue) => continue 'for_loop,
1242                        Some(Signal::Return) => return Ok(Some(Signal::Return)),
1243                    }
1244                }
1245            }
1246
1247            Stmt::While { cond, body } => loop {
1248                if !is_truthy(&eval_with_io(cond, env, io)?) {
1249                    break;
1250                }
1251                match exec_stmts(body, env, io, fmt, base, compact)? {
1252                    None => {}
1253                    Some(Signal::Break) => break,
1254                    Some(Signal::Continue) => continue,
1255                    Some(Signal::Return) => return Ok(Some(Signal::Return)),
1256                }
1257            },
1258
1259            Stmt::Break => return Ok(Some(Signal::Break)),
1260            Stmt::Continue => return Ok(Some(Signal::Continue)),
1261
1262            // ── switch / case / otherwise / end ──────────────────────────────
1263            Stmt::Switch {
1264                expr,
1265                cases,
1266                otherwise_body,
1267            } => {
1268                let switch_val = eval_with_io(expr, env, io)?;
1269                let mut matched = false;
1270                'switch_loop: for (case_exprs, case_body) in cases {
1271                    for case_expr in case_exprs {
1272                        let case_val = eval_with_io(case_expr, env, io)?;
1273                        // When the case expression is a Cell, check if switch_val
1274                        // matches any element of the cell (Phase 12.5c).
1275                        let is_match = if let Value::Cell(cell_elems) = &case_val {
1276                            cell_elems.iter().any(|elem| match (&switch_val, elem) {
1277                                (Value::Scalar(a), Value::Scalar(b)) => a == b,
1278                                _ => {
1279                                    let sv = match &switch_val {
1280                                        Value::Str(s) | Value::StringObj(s) => Some(s.as_str()),
1281                                        _ => None,
1282                                    };
1283                                    let cv = match elem {
1284                                        Value::Str(s) | Value::StringObj(s) => Some(s.as_str()),
1285                                        _ => None,
1286                                    };
1287                                    matches!((sv, cv), (Some(a), Some(b)) if a == b)
1288                                }
1289                            })
1290                        } else {
1291                            match (&switch_val, &case_val) {
1292                                (Value::Scalar(a), Value::Scalar(b)) => a == b,
1293                                _ => {
1294                                    let sv = match &switch_val {
1295                                        Value::Str(s) | Value::StringObj(s) => Some(s.as_str()),
1296                                        _ => None,
1297                                    };
1298                                    let cv = match &case_val {
1299                                        Value::Str(s) | Value::StringObj(s) => Some(s.as_str()),
1300                                        _ => None,
1301                                    };
1302                                    matches!((sv, cv), (Some(a), Some(b)) if a == b)
1303                                }
1304                            }
1305                        };
1306                        if is_match {
1307                            if let Some(sig) = exec_stmts(case_body, env, io, fmt, base, compact)? {
1308                                return Ok(Some(sig));
1309                            }
1310                            matched = true;
1311                            break 'switch_loop;
1312                        }
1313                    }
1314                }
1315                if !matched
1316                    && let Some(ob) = otherwise_body
1317                    && let Some(sig) = exec_stmts(ob, env, io, fmt, base, compact)?
1318                {
1319                    return Ok(Some(sig));
1320                }
1321            }
1322
1323            // ── do...until ───────────────────────────────────────────────────
1324            Stmt::DoUntil { body, cond } => loop {
1325                match exec_stmts(body, env, io, fmt, base, compact)? {
1326                    Some(Signal::Break) => break,
1327                    Some(Signal::Continue) | None => {}
1328                    Some(Signal::Return) => return Ok(Some(Signal::Return)),
1329                }
1330                if is_truthy(&eval_with_io(cond, env, io)?) {
1331                    break;
1332                }
1333            },
1334
1335            // ── try / catch / end ────────────────────────────────────────────
1336            Stmt::TryCatch {
1337                try_body,
1338                catch_var,
1339                catch_body,
1340            } => match exec_stmts(try_body, env, io, fmt, base, compact) {
1341                Ok(None) => {}
1342                Ok(Some(sig)) => return Ok(Some(sig)),
1343                Err(msg) => {
1344                    set_last_err(&msg);
1345                    if let Some(var) = catch_var {
1346                        let mut map = IndexMap::new();
1347                        map.insert("message".to_string(), Value::Str(msg));
1348                        env.insert(var.clone(), Value::Struct(map));
1349                    }
1350                    if let Some(sig) = exec_stmts(catch_body, env, io, fmt, base, compact)? {
1351                        return Ok(Some(sig));
1352                    }
1353                }
1354            },
1355
1356            // ── function definition ──────────────────────────────────────────
1357            Stmt::FunctionDef {
1358                name,
1359                outputs,
1360                params,
1361                body_source,
1362                doc,
1363            } => {
1364                env.insert(
1365                    name.clone(),
1366                    Value::Function {
1367                        outputs: outputs.clone(),
1368                        params: params.clone(),
1369                        body_source: body_source.clone(),
1370                        locals: IndexMap::new(),
1371                        doc: doc.clone(),
1372                    },
1373                );
1374            }
1375
1376            // ── return ───────────────────────────────────────────────────────
1377            Stmt::Return => return Ok(Some(Signal::Return)),
1378
1379            // ── cell element assignment ──────────────────────────────────────
1380            Stmt::CellSet(cell_name, idx_expr, val_expr) => {
1381                // Inject `end` so that c{end+1} works (end = current cell length).
1382                let cell_len = match env.get(cell_name) {
1383                    Some(Value::Cell(v)) => v.len(),
1384                    _ => 0,
1385                };
1386                let env_end = write_env_with_end(env, cell_len);
1387                let idx = eval_with_io(idx_expr, &env_end, io)?;
1388                let rhs = eval_with_io(val_expr, env, io)?;
1389                let i = match idx {
1390                    Value::Scalar(n) => n as isize,
1391                    _ => return Err(format!("{cell_name}{{}}: index must be a scalar integer")),
1392                };
1393                match env.get_mut(cell_name) {
1394                    Some(Value::Cell(v)) => {
1395                        if i < 1 {
1396                            return Err(format!(
1397                                "{cell_name}{{}}: index {i} out of range (1..{})",
1398                                v.len()
1399                            ));
1400                        }
1401                        let idx = (i - 1) as usize;
1402                        // Grow the cell if needed (MATLAB semantics: assigning beyond end grows it)
1403                        if idx >= v.len() {
1404                            v.resize(idx + 1, Value::Scalar(0.0));
1405                        }
1406                        v[idx] = rhs.clone();
1407                    }
1408                    Some(_) => {
1409                        return Err(format!(
1410                            "'{cell_name}' is not a cell array; use () for regular indexing"
1411                        ));
1412                    }
1413                    None => {
1414                        // Auto-create cell if not defined (MATLAB semantics)
1415                        if i < 1 {
1416                            return Err(format!("{cell_name}{{}}: index {i} must be >= 1"));
1417                        }
1418                        let idx = (i - 1) as usize;
1419                        let mut v = vec![Value::Scalar(0.0); idx + 1];
1420                        v[idx] = rhs.clone();
1421                        env.insert(cell_name.clone(), Value::Cell(v));
1422                    }
1423                }
1424                if !silent && let Some(val) = env.get(cell_name) {
1425                    print_value(Some(cell_name), val, fmt, base, compact);
1426                }
1427            }
1428
1429            // ── struct field assignment ──────────────────────────────────────
1430            Stmt::FieldSet(base_name, path, rhs_expr) => {
1431                let rhs = eval_with_io(rhs_expr, env, io)?;
1432                let root = match env.remove(base_name) {
1433                    Some(Value::Struct(m)) => m,
1434                    None => IndexMap::new(),
1435                    Some(other) => {
1436                        env.insert(base_name.clone(), other);
1437                        return Err(format!("'{base_name}' is not a struct"));
1438                    }
1439                };
1440                let updated = set_nested(root, path, rhs)?;
1441                let struct_val = Value::Struct(updated);
1442                if !silent {
1443                    print_value(Some(base_name), &struct_val, fmt, base, compact);
1444                }
1445                env.insert(base_name.clone(), struct_val);
1446            }
1447
1448            // ── struct array element field assignment ────────────────────────
1449            Stmt::StructArrayFieldSet(base_name, idx_expr, path, rhs_expr) => {
1450                let rhs = eval_with_io(rhs_expr, env, io)?;
1451                let idx_val = eval_with_io(idx_expr, env, io)?;
1452                let idx = match &idx_val {
1453                    Value::Scalar(n) => {
1454                        let i = *n as isize;
1455                        if i < 1 {
1456                            return Err(format!(
1457                                "Struct array index must be a positive integer, got {n}"
1458                            ));
1459                        }
1460                        i as usize
1461                    }
1462                    _ => return Err("Struct array index must be a scalar integer".to_string()),
1463                };
1464                // Load or create the struct array
1465                let mut arr: Vec<IndexMap<String, Value>> = match env.remove(base_name) {
1466                    Some(Value::StructArray(v)) => v,
1467                    // A scalar struct with no index yet — promote to 1-element array
1468                    Some(Value::Struct(m)) => vec![m],
1469                    None => Vec::new(),
1470                    Some(other) => {
1471                        env.insert(base_name.clone(), other);
1472                        return Err(format!("'{base_name}' is not a struct array"));
1473                    }
1474                };
1475                // Grow the array if needed (fill with empty structs)
1476                while arr.len() < idx {
1477                    arr.push(IndexMap::new());
1478                }
1479                // Set the field(s) on element idx (1-based → 0-based)
1480                let elem = arr[idx - 1].clone();
1481                let updated_elem = set_nested(elem, path, rhs)?;
1482                arr[idx - 1] = updated_elem;
1483                let arr_val = Value::StructArray(arr);
1484                if !silent {
1485                    print_value(Some(base_name), &arr_val, fmt, base, compact);
1486                }
1487                env.insert(base_name.clone(), arr_val);
1488            }
1489
1490            // ── indexed assignment ────────────────────────────────────────────
1491            Stmt::IndexSet {
1492                name,
1493                indices,
1494                value,
1495            } => {
1496                let rhs = eval_with_io(value, env, io)?;
1497                // For persistent vars: refresh from the store before applying a partial
1498                // update so that values written by recursive calls are not overwritten.
1499                if is_persistent(name) {
1500                    let func = current_func_name();
1501                    if let Some(fresh) = persistent_load(&func, name) {
1502                        env.insert(name.clone(), fresh);
1503                    }
1504                }
1505                exec_index_set(name, indices, rhs, env, io)?;
1506                // Write-through: persist immediately so recursive callers see the update.
1507                if is_persistent(name)
1508                    && let Some(val) = env.get(name)
1509                {
1510                    persistent_save(&current_func_name(), name, val.clone());
1511                }
1512                if !silent && let Some(val) = env.get(name) {
1513                    print_value(Some(name), val, fmt, base, compact);
1514                }
1515            }
1516
1517            // ── multi-assign ─────────────────────────────────────────────────
1518            Stmt::MultiAssign { targets, expr } => {
1519                set_nargout(targets.len());
1520                let val = eval_with_io(expr, env, io)?;
1521                let vals: Vec<Value> = match val {
1522                    Value::Tuple(v) => v,
1523                    other => vec![other],
1524                };
1525                for (i, target) in targets.iter().enumerate() {
1526                    if target == "~" {
1527                        continue; // discard output
1528                    }
1529                    let v = vals.get(i).cloned().unwrap_or(Value::Void);
1530                    env.insert(target.clone(), v.clone());
1531                    if !silent && !matches!(v, Value::Void) {
1532                        print_value(Some(target), &v, fmt, base, compact);
1533                    }
1534                }
1535            }
1536        }
1537    }
1538    // After all statements, refresh global vars in env from the global store.
1539    // This ensures that modifications made inside called functions (which write
1540    // to the global store) are visible in the current scope's environment.
1541    global_refresh_into_env(env);
1542    Ok(None)
1543}
1544
1545// ── indexed assignment helper ──────────────────────────────────────────────
1546
1547/// Resolved index positions (0-based) along one matrix dimension.
1548enum WriteIdx {
1549    /// `:` — all positions in the current dimension.
1550    All,
1551    /// Explicit 0-based positions (may exceed current size → grow).
1552    Positions(Vec<usize>),
1553}
1554
1555/// Resolves one index expression to a `WriteIdx`.
1556///
1557/// Unlike the read-path `resolve_dim`, bounds checking is skipped so that
1558/// out-of-range indices can trigger array growth.  Logical-mask detection
1559/// (all-0/1 vector whose length equals `dim_size`) is supported.
1560fn resolve_write_dim(
1561    expr: &crate::eval::Expr,
1562    dim_size: usize,
1563    env: &Env,
1564    io: &mut IoContext,
1565) -> Result<WriteIdx, String> {
1566    if matches!(expr, crate::eval::Expr::Colon) {
1567        return Ok(WriteIdx::All);
1568    }
1569    let val = eval_with_io(expr, env, io)?;
1570    let floats: Vec<f64> = match val {
1571        Value::Scalar(n) => vec![n],
1572        Value::Complex(re, im) => {
1573            if im != 0.0 {
1574                return Err("Index must be real, not complex".to_string());
1575            }
1576            vec![re]
1577        }
1578        Value::Matrix(m) => {
1579            let total = m.nrows() * m.ncols();
1580            if m.nrows() > 1 && m.ncols() > 1 && total != dim_size {
1581                return Err("Index must be a scalar or vector, not a 2-D matrix".to_string());
1582            }
1583            // Collect in column-major order so mask positions align with linear indexing.
1584            if m.nrows() > 1 && m.ncols() > 1 {
1585                let mut v = Vec::with_capacity(total);
1586                for col in 0..m.ncols() {
1587                    for row in 0..m.nrows() {
1588                        v.push(m[[row, col]]);
1589                    }
1590                }
1591                v
1592            } else {
1593                m.iter().copied().collect()
1594            }
1595        }
1596        _ => return Err("Index must be numeric".to_string()),
1597    };
1598    // Logical mask: a 0/1 array whose element count matches dim_size.
1599    if dim_size > 0 && floats.len() == dim_size && floats.iter().all(|&f| f == 0.0 || f == 1.0) {
1600        let positions: Vec<usize> = floats
1601            .iter()
1602            .enumerate()
1603            .filter(|&(_, &f)| f == 1.0)
1604            .map(|(i, _)| i)
1605            .collect();
1606        return Ok(WriteIdx::Positions(positions));
1607    }
1608    // Numeric 1-based indices → 0-based (no upper-bound check — growth is handled by caller).
1609    let positions: Result<Vec<usize>, String> = floats
1610        .iter()
1611        .map(|&n| {
1612            let i = n.round() as i64;
1613            if i < 1 {
1614                return Err(format!("Index {i} must be >= 1"));
1615            }
1616            Ok(i as usize - 1)
1617        })
1618        .collect();
1619    Ok(WriteIdx::Positions(positions?))
1620}
1621
1622/// Returns a clone of `env` with `end` set to `dim_size` as a `Value::Scalar`.
1623fn write_env_with_end(env: &Env, dim_size: usize) -> Env {
1624    let mut e = env.clone();
1625    e.insert("end".to_string(), Value::Scalar(dim_size as f64));
1626    e
1627}
1628
1629/// Writes `rhs` into `name(indices...)` in `env`, growing the matrix if necessary.
1630///
1631/// Supports:
1632/// - 1 index: linear (column-major) indexing; grows row vectors / creates new ones.
1633/// - 2 indices: 2-D row/column indexing; grows the matrix in either dimension.
1634/// - Scalar RHS is broadcast to all selected positions.
1635/// - Logical mask indices (Phase 15d).
1636fn exec_index_set(
1637    name: &str,
1638    indices: &[crate::eval::Expr],
1639    rhs: Value,
1640    env: &mut Env,
1641    io: &mut IoContext,
1642) -> Result<(), String> {
1643    // Represent target variable as a matrix (scalar → 1×1, empty var → 0×0).
1644    let (mut mat, was_scalar) = match env.get(name) {
1645        Some(Value::Matrix(m)) => (m.clone(), false),
1646        Some(Value::Scalar(n)) => (Array2::from_elem((1, 1), *n), true),
1647        None | Some(Value::Void) => (Array2::zeros((0, 0)), false),
1648        Some(_) => {
1649            return Err(format!(
1650                "'{name}' is not a matrix; cannot use () indexed assignment"
1651            ));
1652        }
1653    };
1654
1655    match indices.len() {
1656        1 => {
1657            let total = mat.nrows() * mat.ncols();
1658            let env_end = write_env_with_end(env, total);
1659            let widx = resolve_write_dim(&indices[0], total, &env_end, io)?;
1660
1661            // Determine which 0-based positions to write.
1662            let positions: Vec<usize> = match widx {
1663                WriteIdx::All => (0..total).collect(),
1664                WriteIdx::Positions(p) => p,
1665            };
1666
1667            // Determine the RHS values to write (scalar broadcasts).
1668            let rhs_vals: Vec<f64> = match &rhs {
1669                Value::Scalar(n) => vec![*n; positions.len()],
1670                Value::Matrix(m) => {
1671                    let flat: Vec<f64> = m.iter().copied().collect();
1672                    if flat.len() != positions.len() {
1673                        return Err(format!(
1674                            "Assignment dimension mismatch: {} positions but {} values",
1675                            positions.len(),
1676                            flat.len()
1677                        ));
1678                    }
1679                    flat
1680                }
1681                _ => {
1682                    return Err(
1683                        "Indexed assignment: RHS must be a numeric scalar or matrix".to_string()
1684                    );
1685                }
1686            };
1687
1688            // Find the required flat size after growth.
1689            let required = positions.iter().copied().max().map(|m| m + 1).unwrap_or(0);
1690            let required = required.max(total);
1691
1692            // Determine output shape: keep original orientation when growing a vector.
1693            let (out_rows, out_cols) = if mat.nrows() == 0 || mat.ncols() == 0 {
1694                // Empty → default to row vector.
1695                (1, required)
1696            } else if mat.nrows() == 1 {
1697                (1, required)
1698            } else if mat.ncols() == 1 {
1699                (required, 1)
1700            } else if required > total {
1701                return Err("Cannot grow a 2-D matrix with linear indexing".to_string());
1702            } else {
1703                (mat.nrows(), mat.ncols())
1704            };
1705
1706            // Grow matrix if needed, preserving existing data at correct column-major positions.
1707            if required > total || out_rows != mat.nrows() || out_cols != mat.ncols() {
1708                let mut new_mat = Array2::<f64>::zeros((out_rows, out_cols));
1709                for old_p in 0..total {
1710                    let old_row = old_p % mat.nrows().max(1);
1711                    let old_col = old_p / mat.nrows().max(1);
1712                    let new_row = old_p % out_rows;
1713                    let new_col = old_p / out_rows;
1714                    if old_row < mat.nrows() && old_col < mat.ncols() {
1715                        new_mat[[new_row, new_col]] = mat[[old_row, old_col]];
1716                    }
1717                }
1718                mat = new_mat;
1719            }
1720
1721            // Write values at column-major positions directly.
1722            for (&pos, &val) in positions.iter().zip(rhs_vals.iter()) {
1723                let row = pos % mat.nrows();
1724                let col = pos / mat.nrows();
1725                mat[[row, col]] = val;
1726            }
1727        }
1728        2 => {
1729            let nrows = mat.nrows();
1730            let ncols = mat.ncols();
1731            let env_r = write_env_with_end(env, nrows);
1732            let env_c = write_env_with_end(env, ncols);
1733            let ridx = resolve_write_dim(&indices[0], nrows, &env_r, io)?;
1734            let cidx = resolve_write_dim(&indices[1], ncols, &env_c, io)?;
1735
1736            let rows: Vec<usize> = match ridx {
1737                WriteIdx::All => (0..nrows.max(1)).collect(),
1738                WriteIdx::Positions(p) => p,
1739            };
1740            let cols: Vec<usize> = match cidx {
1741                WriteIdx::All => (0..ncols.max(1)).collect(),
1742                WriteIdx::Positions(p) => p,
1743            };
1744
1745            let req_rows = rows
1746                .iter()
1747                .copied()
1748                .max()
1749                .map(|m| m + 1)
1750                .unwrap_or(0)
1751                .max(nrows);
1752            let req_cols = cols
1753                .iter()
1754                .copied()
1755                .max()
1756                .map(|m| m + 1)
1757                .unwrap_or(0)
1758                .max(ncols);
1759
1760            // Grow matrix if needed (fill new cells with 0).
1761            if req_rows != nrows || req_cols != ncols {
1762                let mut new_mat = Array2::<f64>::zeros((req_rows, req_cols));
1763                for r in 0..nrows {
1764                    for c in 0..ncols {
1765                        new_mat[[r, c]] = mat[[r, c]];
1766                    }
1767                }
1768                mat = new_mat;
1769            }
1770
1771            // Collect RHS values.
1772            let n_sel = rows.len() * cols.len();
1773            let rhs_vals: Vec<f64> = match &rhs {
1774                Value::Scalar(n) => vec![*n; n_sel],
1775                Value::Matrix(m) => {
1776                    let flat: Vec<f64> = m.iter().copied().collect();
1777                    if flat.len() != n_sel {
1778                        return Err(format!(
1779                            "Assignment dimension mismatch: {}×{} = {} positions but {} values",
1780                            rows.len(),
1781                            cols.len(),
1782                            n_sel,
1783                            flat.len()
1784                        ));
1785                    }
1786                    flat
1787                }
1788                _ => {
1789                    return Err(
1790                        "Indexed assignment: RHS must be a numeric scalar or matrix".to_string()
1791                    );
1792                }
1793            };
1794
1795            // Write values row-major over selected (row, col) pairs.
1796            let mut k = 0;
1797            for &r in &rows {
1798                for &c in &cols {
1799                    mat[[r, c]] = rhs_vals[k];
1800                    k += 1;
1801                }
1802            }
1803        }
1804        _ => return Err("Indexed assignment supports at most 2 indices".to_string()),
1805    }
1806
1807    // Collapse a 1×1 matrix to a scalar.
1808    let result = if mat.nrows() == 1 && mat.ncols() == 1 {
1809        Value::Scalar(mat[[0, 0]])
1810    } else {
1811        Value::Matrix(mat)
1812    };
1813    let _ = was_scalar;
1814    env.insert(name.to_string(), result);
1815    Ok(())
1816}