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<StmtEntry>>>;
6
7/// Compiled bytecode cache for function bodies.
8///
9/// Key: body source string (same key as `BODY_CACHE`).
10/// Value: `Some(chunk)` when compilation succeeded, `None` when the body has
11/// constructs the compiler does not yet support (never retried).
12type ChunkCache = HashMap<String, Option<Rc<crate::vm::Chunk>>>;
13
14/// Cached entry for a leaf-function frame chunk.
15struct FrameCacheEntry {
16    chunk: Rc<crate::vm::Chunk>,
17}
18
19/// Compiled frame-optimised chunks keyed by body source string.
20///
21/// A `None` entry means compilation or leaf-eligibility failed — never retry.
22type FrameChunkCache = HashMap<String, Option<FrameCacheEntry>>;
23
24/// Expands a leading `~` to the user's home directory.
25///
26/// On Windows `USERPROFILE` is tried as a fallback for `HOME`. If neither is set the
27/// string is returned unchanged.
28fn expand_tilde(path: &str) -> String {
29    if path == "~" || path.starts_with("~/") || path.starts_with("~\\") {
30        let home = std::env::var("HOME")
31            .or_else(|_| std::env::var("USERPROFILE"))
32            .unwrap_or_default();
33        if home.is_empty() {
34            return path.to_string();
35        }
36        if path == "~" {
37            home
38        } else {
39            format!("{}{}", home, &path[1..])
40        }
41    } else {
42        path.to_string()
43    }
44}
45
46use indexmap::IndexMap;
47use ndarray::Array2;
48use num_complex::Complex;
49
50use crate::env::{Env, Value};
51use crate::env::{load_workspace, save_workspace, save_workspace_vars};
52use crate::eval::{
53    Base, Expr, FormatMode, autoload_cache_insert, current_func_name, eval_with_io, format_complex,
54    format_scalar, format_value_full, get_display_base, get_display_compact, get_display_fmt,
55    global_declare, global_frame_pop, global_frame_push, global_get, global_init_if_absent,
56    global_refresh_into_env, global_set, is_global, is_persistent, persistent_declare,
57    persistent_frame_pop, persistent_frame_push, persistent_load, persistent_save,
58    set_autoload_hook, set_display_ctx, set_eval_str_hook, set_fn_call_hook, set_last_err,
59    set_nargout,
60};
61use crate::io::IoContext;
62use crate::parser::{Stmt, StmtEntry, parse_stmts};
63
64/// Maximum number of nested user-function calls before returning an error.
65///
66/// Prevents Rust-stack overflow on infinite recursion.  Set to 64 to stay
67/// well within the default 8 MB Rust thread stack even in debug builds
68/// (`call_user_function + vm_exec` each consume ~50–100 KB in debug mode due
69/// to Rust's debug frame overhead; 64 levels × ~100 KB = ~6.4 MB headroom).
70const MAX_CALL_DEPTH: u32 = 64;
71
72thread_local! {
73    /// Tracks the current script nesting depth to prevent infinite recursion via `run()`.
74    static RUN_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
75
76    /// Tracks the current user-function call depth to prevent infinite recursion.
77    static CALL_DEPTH: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
78
79    /// Stack of directories for currently executing scripts.
80    ///
81    /// When `run()`/`source()` starts executing a script, the script's parent directory is
82    /// pushed here. `resolve_script_path` searches this stack (top-first) so that helper
83    /// scripts can be referenced by bare name relative to the calling script's directory.
84    static SCRIPT_DIR_STACK: std::cell::RefCell<Vec<std::path::PathBuf>> =
85        const { std::cell::RefCell::new(Vec::new()) };
86
87    /// Session search path — initialized from `config.toml` at startup.
88    ///
89    /// `addpath`/`rmpath` mutate this list for the current session; changes are never
90    /// written back to `config.toml`. `resolve_script_path` searches here after CWD.
91    static SESSION_PATH: std::cell::RefCell<Vec<std::path::PathBuf>> =
92        const { std::cell::RefCell::new(Vec::new()) };
93
94    /// Parse cache for named function bodies.
95    ///
96    /// Key: body source string (verbatim text between `function` and `end`).
97    /// Value: pre-parsed, all-silent statement sequence.
98    ///
99    /// Populated on the first call to any function; subsequent calls with the
100    /// same body string skip parsing entirely and reuse the shared `Rc`.
101    /// Cache entries are never evicted — acceptable because the number of
102    /// unique function bodies in a session is small.
103    static BODY_CACHE: std::cell::RefCell<BodyCache> =
104        std::cell::RefCell::new(HashMap::new());
105
106    /// Compiled bytecode cache for named function bodies.
107    ///
108    /// Populated on the first successful compilation of a function body; `None`
109    /// entries mark bodies whose compilation failed (no retry on future calls).
110    static BODY_CHUNK_CACHE: std::cell::RefCell<ChunkCache> =
111        std::cell::RefCell::new(HashMap::new());
112
113    /// Frame-optimised chunk cache for leaf function bodies.
114    ///
115    /// A [`FrameCacheEntry`] is stored when `compile_fn_body` succeeds and the
116    /// resulting chunk passes `is_leaf_fn`.  A `None` entry permanently disables
117    /// the fast path for that body (non-leaf or compile failure).
118    static BODY_FRAME_CACHE: std::cell::RefCell<FrameChunkCache> =
119        std::cell::RefCell::new(HashMap::new());
120
121    /// Scratch `Env` used exclusively by the Vec-frame fast path.
122    ///
123    /// Leaf functions never read from or write to `env` during execution, so
124    /// this empty map is passed as the `env` parameter and is never touched.
125    /// It is never accessed concurrently because leaf functions cannot recurse
126    /// (empty `chunk.names` means no `CallUser`/`CallBuiltin`/`EvalExpr`).
127    static LEAF_SCRATCH_ENV: std::cell::RefCell<crate::env::Env> =
128        std::cell::RefCell::new(crate::env::Env::new());
129}
130
131/// RAII guard that restores `CALL_DEPTH` to `saved` when dropped.
132///
133/// Ensures the call depth counter is decremented on every exit path from
134/// `call_user_function`, including early returns and `?`-propagated errors.
135struct CallDepthGuard(u32);
136impl Drop for CallDepthGuard {
137    fn drop(&mut self) {
138        CALL_DEPTH.with(|c| c.set(self.0));
139    }
140}
141
142/// Recursively marks every statement in `stmts` (and all nested block bodies) as silent.
143///
144/// Function bodies must suppress all output — assignments, expressions, everything.
145/// Because [`parse_stmts`] records the original semicolon flag per statement, a simple
146/// `map(|(s,_)| (s, true))` only silences the top level.  Nested bodies inside `if`,
147/// `for`, `while`, `switch`, `do..until`, and `try..catch` keep their original flags
148/// and would still print.  This function walks the full statement tree.
149fn silence_all(stmts: Vec<StmtEntry>) -> Vec<StmtEntry> {
150    stmts
151        .into_iter()
152        .map(|(stmt, _, line)| {
153            let stmt = match stmt {
154                Stmt::If {
155                    cond,
156                    body,
157                    elseif_branches,
158                    else_body,
159                } => Stmt::If {
160                    cond,
161                    body: silence_all(body),
162                    elseif_branches: elseif_branches
163                        .into_iter()
164                        .map(|(c, b)| (c, silence_all(b)))
165                        .collect(),
166                    else_body: else_body.map(silence_all),
167                },
168                Stmt::For {
169                    var,
170                    range_expr,
171                    body,
172                } => Stmt::For {
173                    var,
174                    range_expr,
175                    body: silence_all(body),
176                },
177                Stmt::While { cond, body } => Stmt::While {
178                    cond,
179                    body: silence_all(body),
180                },
181                Stmt::DoUntil { body, cond } => Stmt::DoUntil {
182                    body: silence_all(body),
183                    cond,
184                },
185                Stmt::Switch {
186                    expr,
187                    cases,
188                    otherwise_body,
189                } => Stmt::Switch {
190                    expr,
191                    cases: cases
192                        .into_iter()
193                        .map(|(v, b)| (v, silence_all(b)))
194                        .collect(),
195                    otherwise_body: otherwise_body.map(silence_all),
196                },
197                Stmt::TryCatch {
198                    try_body,
199                    catch_var,
200                    catch_body,
201                } => Stmt::TryCatch {
202                    try_body: silence_all(try_body),
203                    catch_var,
204                    catch_body: silence_all(catch_body),
205                },
206                other => other,
207            };
208            (stmt, true, line)
209        })
210        .collect()
211}
212
213/// Returns a parsed, all-silent body for `body_source`, using the cache when possible.
214///
215/// "All-silent" means every [`StmtEntry`]'s `is_silent` flag is `true` — function bodies
216/// never print output directly. The parse result is shared via `Rc` so that
217/// repeated calls to the same function avoid both allocation and parsing work.
218fn get_or_parse_body(body_source: &str) -> Result<Rc<Vec<StmtEntry>>, String> {
219    BODY_CACHE.with(|cache| {
220        let mut cache = cache.borrow_mut();
221        if let Some(body) = cache.get(body_source) {
222            return Ok(Rc::clone(body));
223        }
224        let stmts =
225            parse_stmts(body_source).map_err(|e| format!("function body parse error: {e}"))?;
226        let silent = silence_all(stmts);
227        let rc = Rc::new(silent);
228        cache.insert(body_source.to_string(), Rc::clone(&rc));
229        Ok(rc)
230    })
231}
232
233/// Appends ` near line N` to an error message if it does not already contain one.
234///
235/// `line == 0` means "no source location available" and is passed through unchanged.
236fn annotate_line(e: String, line: usize) -> String {
237    if line == 0 || e.contains("near line") {
238        e
239    } else {
240        format!("{e} near line {line}")
241    }
242}
243
244/// Strips a trailing ` near line N` suffix from an error message.
245///
246/// Used when storing an error in a `catch` variable — `e.message` should contain
247/// the original message only, matching MATLAB/Octave semantics.
248fn strip_near_line(s: String) -> String {
249    if let Some(pos) = s.rfind(" near line ") {
250        let after = &s[pos + " near line ".len()..];
251        if !after.is_empty() && after.bytes().all(|b| b.is_ascii_digit()) {
252            return s[..pos].to_string();
253        }
254    }
255    s
256}
257
258/// Flow control signal returned by [`exec_stmts`].
259///
260/// Used to propagate `break`, `continue`, and `return` through nested block calls.
261/// Loop implementations catch `Break`/`Continue`; function call implementation catches `Return`.
262/// Uncaught signals at the top level are reported as errors.
263pub enum Signal {
264    /// `break` — exit the innermost enclosing loop immediately.
265    Break,
266    /// `continue` — skip the rest of the current iteration and advance to the next.
267    Continue,
268    /// `return` inside a named function — carries no value (outputs are read from env).
269    Return,
270}
271
272/// Initialises exec-level hooks in `eval.rs` so that `eval_inner` can call user functions.
273///
274/// Must be called once at program startup before any evaluation takes place.
275pub fn init() {
276    set_fn_call_hook(call_user_function);
277    set_autoload_hook(try_autoload);
278    set_eval_str_hook(eval_str_impl);
279}
280
281/// Executes a code string against a snapshot clone of `env` and returns `ans`.
282///
283/// Used by `call_builtin` when `eval()` appears in expression context such as
284/// `y = eval('2+2')`. Variable mutations inside the string do **not** propagate
285/// back to the caller — use `eval()` as a standalone statement for that.
286fn eval_str_impl(code: &str, env: &crate::env::Env) -> Result<crate::env::Value, String> {
287    let mut env_clone = env.clone();
288    env_clone
289        .entry("ans".to_string())
290        .or_insert(crate::env::Value::Scalar(0.0));
291    let mut tmp_io = crate::io::IoContext::new();
292    let fmt = get_display_fmt();
293    let base = get_display_base();
294    let compact = get_display_compact();
295    let stmts = parse_stmts(code).map_err(|e| format!("eval: parse error: {e}"))?;
296    exec_stmts(&stmts, &mut env_clone, &mut tmp_io, &fmt, base, compact)?;
297    Ok(env_clone
298        .get("ans")
299        .cloned()
300        .unwrap_or(crate::env::Value::Void))
301}
302
303/// Searches for `<name>.calc` or `<name>.m` on the session path and loads it.
304///
305/// Mirrors MATLAB/Octave autoload: when a function name is not found in the
306/// environment, ccalc looks for a matching file on the path, loads its primary
307/// function into the autoload cache, and returns `true` on success.
308/// For package-qualified names (containing `.`), delegates to [`try_autoload_pkg`].
309fn try_autoload(name: &str) -> bool {
310    if name.contains('.') {
311        return try_autoload_pkg(name);
312    }
313    let candidates = [format!("{name}.calc"), format!("{name}.m")];
314    for candidate in &candidates {
315        let Some(path) = resolve_script_path(candidate) else {
316            continue;
317        };
318        let Ok(content) = std::fs::read_to_string(&path) else {
319            continue;
320        };
321        let Ok(stmts) = parse_stmts(&content) else {
322            continue;
323        };
324        if !matches!(stmts.first(), Some((Stmt::FunctionDef { .. }, _, _))) {
325            continue;
326        }
327        let primary_name = match &stmts[0].0 {
328            Stmt::FunctionDef { name, .. } => name.clone(),
329            _ => continue,
330        };
331        let mut locals: IndexMap<String, Value> = IndexMap::new();
332        for (stmt, _, _) in &stmts {
333            if let Stmt::FunctionDef {
334                name: n,
335                outputs,
336                params,
337                body_source,
338                doc,
339            } = stmt
340                && n != &primary_name
341            {
342                locals.insert(
343                    n.clone(),
344                    Value::Function(Box::new(crate::env::FunctionData {
345                        outputs: outputs.clone(),
346                        params: params.clone(),
347                        body_source: body_source.clone(),
348                        locals: IndexMap::new(),
349                        doc: doc.clone(),
350                    })),
351                );
352            }
353        }
354        if let Stmt::FunctionDef {
355            outputs,
356            params,
357            body_source,
358            doc,
359            ..
360        } = &stmts[0].0
361        {
362            autoload_cache_insert(
363                primary_name,
364                Value::Function(Box::new(crate::env::FunctionData {
365                    outputs: outputs.clone(),
366                    params: params.clone(),
367                    body_source: body_source.clone(),
368                    locals,
369                    doc: doc.clone(),
370                })),
371            );
372            return true;
373        }
374    }
375    false
376}
377
378/// Searches for a package-qualified function and loads it into the autoload cache.
379///
380/// A qualified name like `"utils.my_func"` maps to `+utils/my_func.calc`.
381/// Nested packages like `"utils.math.lerp"` map to `+utils/+math/lerp.calc`.
382/// Searches SCRIPT_DIR_STACK → CWD → SESSION_PATH and caches under the qualified name.
383fn try_autoload_pkg(qualified: &str) -> bool {
384    let parts: Vec<&str> = qualified.split('.').collect();
385    if parts.len() < 2 {
386        return false;
387    }
388    let func_name = *parts.last().unwrap();
389    let pkg_parts = &parts[..parts.len() - 1];
390
391    // Build relative path prefix: +pkg1/+pkg2/...
392    let mut rel_prefix = std::path::PathBuf::new();
393    for pkg in pkg_parts {
394        rel_prefix.push(format!("+{pkg}"));
395    }
396
397    let candidates = [
398        rel_prefix.join(format!("{func_name}.calc")),
399        rel_prefix.join(format!("{func_name}.m")),
400    ];
401
402    // Collect search directories: script dirs + CWD + session path.
403    let mut search_dirs: Vec<std::path::PathBuf> = Vec::new();
404    SCRIPT_DIR_STACK.with(|s| search_dirs.extend(s.borrow().iter().cloned()));
405    search_dirs.push(std::path::PathBuf::from("."));
406    SESSION_PATH.with(|s| search_dirs.extend(s.borrow().iter().cloned()));
407
408    for dir in &search_dirs {
409        for candidate in &candidates {
410            let full = dir.join(candidate);
411            let Ok(content) = std::fs::read_to_string(&full) else {
412                continue;
413            };
414            let Ok(stmts) = parse_stmts(&content) else {
415                continue;
416            };
417            if !matches!(stmts.first(), Some((Stmt::FunctionDef { .. }, _, _))) {
418                continue;
419            }
420            let primary_name = match &stmts[0].0 {
421                Stmt::FunctionDef { name, .. } => name.clone(),
422                _ => continue,
423            };
424            let mut locals: IndexMap<String, Value> = IndexMap::new();
425            for (stmt, _, _) in &stmts {
426                if let Stmt::FunctionDef {
427                    name: n,
428                    outputs,
429                    params,
430                    body_source,
431                    doc,
432                } = stmt
433                    && n != &primary_name
434                {
435                    locals.insert(
436                        n.clone(),
437                        Value::Function(Box::new(crate::env::FunctionData {
438                            outputs: outputs.clone(),
439                            params: params.clone(),
440                            body_source: body_source.clone(),
441                            locals: IndexMap::new(),
442                            doc: doc.clone(),
443                        })),
444                    );
445                }
446            }
447            if let Stmt::FunctionDef {
448                outputs,
449                params,
450                body_source,
451                doc,
452                ..
453            } = &stmts[0].0
454            {
455                autoload_cache_insert(
456                    qualified.to_string(),
457                    Value::Function(Box::new(crate::env::FunctionData {
458                        outputs: outputs.clone(),
459                        params: params.clone(),
460                        body_source: body_source.clone(),
461                        locals,
462                        doc: doc.clone(),
463                    })),
464                );
465                return true;
466            }
467        }
468    }
469    false
470}
471
472/// Push a script directory onto the search stack.
473///
474/// Call this before executing a top-level script file so that `run()`/`source()` calls
475/// inside the script can find helper files by bare name relative to the script's directory.
476/// Always paired with a matching `script_dir_pop`.
477pub fn script_dir_push(dir: &std::path::Path) {
478    SCRIPT_DIR_STACK.with(|s| s.borrow_mut().push(dir.to_path_buf()));
479}
480
481/// Pop the most recently pushed script directory from the search stack.
482pub fn script_dir_pop() {
483    SCRIPT_DIR_STACK.with(|s| s.borrow_mut().pop());
484}
485
486/// Initializes the session search path from the config `path` array.
487///
488/// Called once at startup (after loading `config.toml`). Each entry has `~` already
489/// expanded by the caller.
490pub fn session_path_init(paths: Vec<std::path::PathBuf>) {
491    SESSION_PATH.with(|p| *p.borrow_mut() = paths);
492}
493
494/// Prepends (default) or appends a directory to the session search path.
495///
496/// If the same path is already present it is removed from its current position
497/// before being re-inserted, so the path list contains no duplicates.
498pub fn session_path_add(path: std::path::PathBuf, append: bool) {
499    SESSION_PATH.with(|p| {
500        let mut v = p.borrow_mut();
501        v.retain(|e| e != &path);
502        if append {
503            v.push(path);
504        } else {
505            v.insert(0, path);
506        }
507    });
508}
509
510/// Removes a directory from the session search path (exact match).
511pub fn session_path_remove(path: &std::path::Path) {
512    SESSION_PATH.with(|p| p.borrow_mut().retain(|e| e.as_path() != path));
513}
514
515/// Returns a snapshot of the current session search path.
516pub fn session_path_list() -> Vec<std::path::PathBuf> {
517    SESSION_PATH.with(|p| p.borrow().clone())
518}
519
520/// Called by `eval_inner` whenever a user function (`Value::Function`) is invoked.
521///
522/// Executes the function body in an isolated scope containing only the parameters plus
523/// any callable values (`Function`/`Lambda`) from the caller's environment, enabling
524/// recursion and mutual recursion.
525/// Multi-return: if the function has >1 output, returns `Value::Tuple`.
526pub(crate) fn call_user_function(
527    name: &str,
528    func: &Value,
529    args: &[Value],
530    caller_env: &Env,
531    io: &mut IoContext,
532) -> Result<Value, String> {
533    let Value::Function(fd) = func else {
534        return Err("call_user_function: not a Function value".to_string());
535    };
536    // Depth guard: prevent infinite recursion from overflowing the Rust call stack.
537    let depth = CALL_DEPTH.with(|c| c.get());
538    if depth >= MAX_CALL_DEPTH {
539        return Err(format!(
540            "'{name}': maximum function call depth ({MAX_CALL_DEPTH}) exceeded"
541        ));
542    }
543    CALL_DEPTH.with(|c| c.set(depth + 1));
544    // Guard restores depth on every exit path (including early returns and ? propagation).
545    let _depth_guard = CallDepthGuard(depth);
546    let (outputs, params, body_source, locals) =
547        (&fd.outputs, &fd.params, &fd.body_source, &fd.locals);
548
549    // Push global and persistent tracking frames for this function call.
550    global_frame_push();
551    persistent_frame_push(name); // `name` is the function name from the call site
552
553    // Check for varargin: last parameter is 'varargin' → variadic function.
554    let has_varargin = params.last().is_some_and(|p| p == "varargin");
555    let fixed_params = if has_varargin {
556        &params[..params.len() - 1]
557    } else {
558        params.as_slice()
559    };
560
561    // Trim any trailing args beyond what the function declares.
562    // The parser injects `ans` for empty `f()` calls; for 0-param functions
563    // we must silently ignore it. For N-param functions, allow up to N+1
564    // args before complaining (one implicit `ans` is always present).
565    let effective_args = if args.len() > params.len() {
566        if !has_varargin && args.len() > params.len() + 1 {
567            return Err(format!(
568                "Too many arguments: expected at most {}, got {}",
569                params.len(),
570                args.len()
571            ));
572        }
573        if has_varargin {
574            args
575        } else {
576            // Exactly 1 extra: the implicit `ans` — trim it.
577            &args[..params.len()]
578        }
579    } else {
580        args
581    };
582
583    // ── Vec-frame fast path (leaf functions only) ─────────────────────────────
584    // Eligible when the body compiles via `compile_fn_body`, all params are
585    // slotted, `chunk.names` is empty (no env access during execution), no
586    // varargin/varargout, and no prior parse/compile failure for this body.
587    if !has_varargin && outputs.last().is_none_or(|o| o != "varargout") {
588        // Look up (or populate) the frame-chunk cache entry for this body.
589        let cached = BODY_FRAME_CACHE.with(|cc| {
590            cc.borrow()
591                .get(body_source.as_str())
592                .map(|e| e.as_ref().map(|entry| Rc::clone(&entry.chunk)))
593        });
594
595        let frame_chunk_opt: Option<Rc<crate::vm::Chunk>> = match cached {
596            // Cache hit: Some(chunk) = leaf, None = known non-leaf/non-compilable.
597            Some(rc_opt) => rc_opt,
598            // Cache miss: try compile_fn_body and check leaf eligibility.
599            None => {
600                let rc_opt = get_or_parse_body(body_source)
601                    .ok()
602                    .and_then(|body| {
603                        crate::vm::compile::compile_fn_body(&body, params, outputs).ok()
604                    })
605                    .and_then(|chunk| {
606                        if crate::vm::compile::is_leaf_fn(&chunk) && chunk.n_params == params.len()
607                        {
608                            Some(Rc::new(chunk))
609                        } else {
610                            None
611                        }
612                    });
613                BODY_FRAME_CACHE.with(|cc| {
614                    let mut cache = cc.borrow_mut();
615                    cache.insert(
616                        body_source.to_string(),
617                        rc_opt.as_ref().map(|rc| FrameCacheEntry {
618                            chunk: Rc::clone(rc),
619                        }),
620                    );
621                });
622                rc_opt
623            }
624        };
625
626        if let Some(chunk) = frame_chunk_opt {
627            let fmt = get_display_fmt();
628            let base = get_display_base();
629            let compact = get_display_compact();
630
631            // Build slot frame: params at slots 0..n_params (guaranteed by compile_fn_body).
632            let mut frame = vec![Value::Void; chunk.slot_names.len()];
633            for (i, arg) in effective_args.iter().take(params.len()).enumerate() {
634                frame[i] = arg.clone();
635            }
636
637            // Execute against the shared scratch env (leaf functions never access env).
638            let exec_result = LEAF_SCRATCH_ENV.with(|cell| {
639                let mut scratch = cell.borrow_mut();
640                crate::vm::exec::vm_exec_with_frame(
641                    &chunk,
642                    frame,
643                    &mut scratch,
644                    io,
645                    &fmt,
646                    base,
647                    compact,
648                )
649            });
650
651            // Pop frames regardless of result (mirrors the regular path).
652            let (func_name_saved, persistent_names) = persistent_frame_pop();
653            for var_name in &persistent_names {
654                // Leaf functions cannot have persistent vars, but handle if present.
655                if let Some(val) = LEAF_SCRATCH_ENV.with(|c| c.borrow().get(var_name).cloned()) {
656                    persistent_save(&func_name_saved, var_name, val);
657                }
658            }
659            global_frame_pop();
660
661            let (signal, mut frame) = exec_result?;
662            match signal {
663                None | Some(Signal::Return) => {}
664                Some(Signal::Break) => return Err("'break' outside loop".to_string()),
665                Some(Signal::Continue) => return Err("'continue' outside loop".to_string()),
666            }
667
668            // Extract return values from the frame by slot index.
669            if outputs.is_empty() {
670                return Ok(Value::Void);
671            }
672            if outputs.len() == 1 {
673                let out_slot = chunk.slot_names.iter().position(|n| n == &outputs[0]);
674                return Ok(out_slot
675                    .map(|s| std::mem::replace(&mut frame[s], Value::Void))
676                    .unwrap_or(Value::Void));
677            }
678            let vals: Vec<Value> = outputs
679                .iter()
680                .map(|o| {
681                    chunk
682                        .slot_names
683                        .iter()
684                        .position(|n| n == o)
685                        .map(|s| std::mem::replace(&mut frame[s], Value::Void))
686                        .unwrap_or(Value::Void)
687                })
688                .collect();
689            return Ok(Value::Tuple(vals));
690        }
691    }
692    // ── End Vec-frame fast path — fall through to full-Env path ──────────────
693
694    // Build isolated scope: seed imaginary unit and ans, then copy all callable
695    // values (Function/Lambda) from the caller's environment so that recursion
696    // and mutual recursion work correctly.
697    let mut local_env = Env::new();
698    local_env.insert("i".to_string(), Value::Complex(0.0, 1.0));
699    local_env.insert("j".to_string(), Value::Complex(0.0, 1.0));
700    local_env.insert("ans".to_string(), Value::Scalar(0.0));
701    // Local helper functions from the same function file (MATLAB-style scoping).
702    // These take priority and are always available regardless of the caller's env.
703    for (fn_name, val) in locals.iter() {
704        local_env.insert(fn_name.clone(), val.clone());
705    }
706    for (var_name, val) in caller_env.iter() {
707        if matches!(val, Value::Function(_) | Value::Lambda(_)) {
708            local_env.insert(var_name.clone(), val.clone());
709        }
710    }
711
712    // Bind fixed parameters
713    for (p, a) in fixed_params.iter().zip(effective_args.iter()) {
714        local_env.insert(p.clone(), a.clone());
715    }
716
717    // If varargin, collect remaining args into a Cell
718    if has_varargin {
719        // Collect extra args beyond the fixed parameters into varargin.
720        // User functions do not receive an injected `ans`; the arg list reflects
721        // exactly what the caller passed.
722        let extra: Vec<Value> = effective_args
723            .get(fixed_params.len()..)
724            .unwrap_or(&[])
725            .to_vec();
726        let varargin = Value::Cell(Box::new(extra));
727        local_env.insert("varargin".to_string(), varargin);
728    }
729
730    let nargin = effective_args.len().min(params.len());
731    local_env.insert("nargin".to_string(), Value::Scalar(nargin as f64));
732    local_env.insert("nargout".to_string(), Value::Scalar(outputs.len() as f64));
733
734    // Retrieve (or parse-and-cache) the function body.
735    let body = get_or_parse_body(body_source)?;
736    let fmt = get_display_fmt();
737    let base = get_display_base();
738    let compact = get_display_compact();
739
740    // Try the compiled VM path (cached by body_source).
741    let maybe_chunk: Option<Rc<crate::vm::Chunk>> = BODY_CHUNK_CACHE.with(|cc| {
742        let mut cache = cc.borrow_mut();
743        if let Some(entry) = cache.get(body_source) {
744            return entry.clone();
745        }
746        let result: Option<Rc<crate::vm::Chunk>> = match crate::vm::compile::compile(&body) {
747            Ok(chunk) => Some(Rc::new(chunk)),
748            Err(_) => None,
749        };
750        cache.insert(body_source.to_string(), result.clone());
751        result
752    });
753
754    let exec_result = if let Some(chunk) = maybe_chunk {
755        crate::vm::exec::vm_exec(&chunk, &mut local_env, io, &fmt, base, compact)
756    } else {
757        exec_stmts(&body, &mut local_env, io, &fmt, base, compact)
758    };
759
760    // Save persistent variables before unwinding the frame (even on error).
761    let (func_name_saved, persistent_names) = persistent_frame_pop();
762    for var_name in &persistent_names {
763        if let Some(val) = local_env.get(var_name) {
764            persistent_save(&func_name_saved, var_name, val.clone());
765        }
766    }
767    // Pop the global names frame.
768    global_frame_pop();
769
770    // Propagate any execution error after saving persistent state.
771    match exec_result? {
772        None | Some(Signal::Return) => {}
773        Some(Signal::Break) => return Err("'break' outside loop".to_string()),
774        Some(Signal::Continue) => return Err("'continue' outside loop".to_string()),
775    }
776
777    // Collect return values
778    if outputs.is_empty() {
779        return Ok(Value::Void);
780    }
781
782    // varargout: single output named 'varargout' — expand from cell
783    if outputs.len() == 1 && outputs[0] == "varargout" {
784        let cell = local_env
785            .remove("varargout")
786            .unwrap_or(Value::Cell(Box::default()));
787        return match cell {
788            Value::Cell(mut v) => {
789                if v.is_empty() {
790                    Ok(Value::Void)
791                } else if v.len() == 1 {
792                    Ok(v.remove(0))
793                } else {
794                    Ok(Value::Tuple(*v))
795                }
796            }
797            other => Ok(other),
798        };
799    }
800
801    if outputs.len() == 1 {
802        return Ok(local_env.remove(&outputs[0]).unwrap_or(Value::Void));
803    }
804    let vals: Vec<Value> = outputs
805        .iter()
806        .map(|o| local_env.remove(o).unwrap_or(Value::Void))
807        .collect();
808    Ok(Value::Tuple(vals))
809}
810
811/// Dispatch a user-function call from the VM's [`Opcode::CallUser`] handler.
812///
813/// Fast path: if `name` is a [`Value::Function`] in `env`, calls it directly
814/// via `call_user_function` (no intermediate AST round-trip).
815///
816/// Slow path: for lambdas, matrix indexing (variable shadows function name),
817/// auto-loaded functions, builtins, and other callable values — falls back to
818/// `eval_with_io` by temporarily binding args to env under synthetic key names.
819/// This path is correct for all value types and handles autoload automatically.
820pub(crate) fn dispatch_user_call_for_vm(
821    name: &str,
822    args: &[Value],
823    env: &mut Env,
824    io: &mut IoContext,
825) -> Result<Value, String> {
826    // Fast path: Function directly in env (the common case for hot loops).
827    // Use matches! + get to avoid cloning the Box<FunctionData> — pass &Value directly.
828    // Both `f: &Value` and `env: &Env` (caller_env) are immutable borrows; Rust allows it.
829    if matches!(env.get(name), Some(Value::Function(_))) {
830        let f = env.get(name).unwrap();
831        return call_user_function(name, f, args, env, io);
832    }
833    // Non-function or absent: lambda, matrix indexing, autoload, builtin fallback.
834    // eval_with_io handles all of these correctly.
835    call_via_eval_with_args(name, args, env, io)
836}
837
838/// Evaluate `name(args)` by temporarily binding `args` to synthetic env keys and
839/// calling `eval_with_io`.
840///
841/// Handles all callable types: autoloaded functions, builtins, lambdas, and matrix
842/// indexing when a variable shadows a function name.  Used as the slow-path fallback
843/// from `dispatch_user_call_for_vm` when the fast Function path is not applicable.
844fn call_via_eval_with_args(
845    name: &str,
846    args: &[Value],
847    env: &mut Env,
848    io: &mut IoContext,
849) -> Result<Value, String> {
850    // Bind each arg to a synthetic env key to avoid interfering with user variables.
851    let arg_keys: Vec<String> = (0..args.len()).map(|i| format!("__vm_cu_{i}__")).collect();
852    let mut saved: Vec<(String, Option<Value>)> = Vec::with_capacity(args.len());
853    for (key, val) in arg_keys.iter().zip(args.iter()) {
854        saved.push((key.clone(), env.get(key.as_str()).cloned()));
855        env.insert(key.clone(), val.clone());
856    }
857    // Build the call expression referencing the synthetic arg keys.
858    let call_expr = crate::eval::Expr::Call(
859        name.to_string(),
860        arg_keys
861            .iter()
862            .map(|k| crate::eval::Expr::Var(k.clone()))
863            .collect(),
864    );
865    let result = eval_with_io(&call_expr, env, io);
866    // Restore env: remove synthetic keys or restore pre-existing values.
867    for (key, saved_val) in saved {
868        match saved_val {
869            Some(v) => {
870                env.insert(key, v);
871            }
872            None => {
873                env.remove(&key);
874            }
875        }
876    }
877    result
878}
879
880/// Resolves a script filename to an existing path.
881///
882/// If `name` already has an extension, it is used verbatim.
883/// Otherwise, `.calc` is tried first (native ccalc format), then `.m` (Octave/MATLAB compatibility).
884/// The search is relative to the current working directory.
885pub fn resolve_script_path(name: &str) -> Option<std::path::PathBuf> {
886    // Build candidate base paths.
887    //
888    // MATLAB `private/` semantics: a `private/` sub-directory is visible only
889    // to scripts/functions in its parent directory.  Therefore:
890    //   • SCRIPT_DIR_STACK entries (the calling script's own directory) ARE
891    //     allowed to search their `private/` sub-folder — checked first.
892    //   • CWD and SESSION_PATH entries do NOT get a `private/` look-aside;
893    //     they can only see files directly in those directories.
894    let p = std::path::Path::new(name);
895    let mut bases: Vec<std::path::PathBuf> = Vec::new();
896
897    // Stacked script directories (most-recent first): private/ before the dir.
898    SCRIPT_DIR_STACK.with(|stack| {
899        for dir in stack.borrow().iter().rev() {
900            bases.push(dir.join("private").join(p));
901            bases.push(dir.join(p));
902        }
903    });
904
905    // CWD-relative (no private/ look-aside).
906    bases.push(p.to_path_buf());
907
908    // Session search-path entries (no private/ look-aside).
909    SESSION_PATH.with(|sp| {
910        for dir in sp.borrow().iter() {
911            bases.push(dir.join(p));
912        }
913    });
914
915    for base in &bases {
916        if base.extension().is_some() {
917            if base.exists() {
918                return Some(base.clone());
919            }
920            // Explicit extension given but not found — try next base.
921            continue;
922        }
923        let with_calc = base.with_extension("calc");
924        if with_calc.exists() {
925            return Some(with_calc);
926        }
927        let with_m = base.with_extension("m");
928        if with_m.exists() {
929            return Some(with_m);
930        }
931    }
932    None
933}
934
935/// Returns `true` if `val` is considered truthy by MATLAB `if`/`while` semantics.
936///
937/// - Scalar: nonzero and not NaN.
938/// - Matrix: all elements nonzero and not NaN.
939/// - Complex: either part nonzero.
940/// - Str/StringObj: nonempty.
941/// - Void: always false.
942pub(crate) fn is_truthy(val: &Value) -> bool {
943    match val {
944        Value::Scalar(n) => *n != 0.0 && !n.is_nan(),
945        Value::Matrix(m) => m.iter().all(|&x| x != 0.0 && !x.is_nan()),
946        Value::Complex(re, im) => *re != 0.0 || *im != 0.0,
947        Value::ComplexMatrix(m) => m.iter().all(|c| c.re != 0.0 || c.im != 0.0),
948        Value::Str(s) | Value::StringObj(s) => !s.is_empty(),
949        Value::Void => false,
950        // Functions are truthy (they exist), but comparing them to 0 makes no sense.
951        // Treat as truthy so that `if f` doesn't silently fail.
952        Value::Lambda(_) | Value::Function(_) | Value::Tuple(_) => true,
953        // A cell is truthy if nonempty.
954        Value::Cell(v) => !v.is_empty(),
955        // A struct / struct array is always truthy.
956        Value::Struct(_) | Value::StructArray(_) => true,
957        // Datetime/Duration are truthy when not NaT/zero.
958        Value::DateTime(ts) => !ts.is_nan(),
959        Value::Duration(s) => *s != 0.0,
960        Value::DateTimeArray(v) | Value::DurationArray(v) => !v.is_empty(),
961        // A map is truthy when nonempty.
962        Value::Map(m) => !m.is_empty(),
963    }
964}
965
966/// Prints a value to stdout with MATLAB-style formatting.
967///
968/// `label` is `Some("name")` for assignment output and `None` for expression output.
969/// In expression context scalars/complex print without a label; matrices print `ans =`.
970pub(crate) fn print_value(
971    label: Option<&str>,
972    val: &Value,
973    fmt: &FormatMode,
974    base: Base,
975    compact: bool,
976) {
977    match val {
978        Value::Void => {}
979        Value::Scalar(n) => {
980            if let Some(name) = label {
981                println!("{name} = {}", format_scalar(*n, base, fmt));
982            } else {
983                println!("{}", format_scalar(*n, base, fmt));
984            }
985        }
986        Value::Matrix(_) => {
987            if let Some(full) = format_value_full(val, fmt) {
988                let prefix = label.unwrap_or("ans");
989                println!("{prefix} =");
990                println!("{full}");
991                if !compact {
992                    println!();
993                }
994            }
995        }
996        Value::Complex(re, im) => {
997            if let Some(name) = label {
998                println!("{name} = {}", format_complex(*re, *im, fmt));
999            } else {
1000                println!("{}", format_complex(*re, *im, fmt));
1001            }
1002        }
1003        Value::Str(s) | Value::StringObj(s) => {
1004            if let Some(name) = label {
1005                println!("{name} = {s}");
1006            } else {
1007                println!("{s}");
1008            }
1009        }
1010        Value::Lambda(_) => {
1011            if let Some(name) = label {
1012                println!("{name} = @<lambda>");
1013            } else {
1014                println!("@<lambda>");
1015            }
1016        }
1017        Value::Function(fd) => {
1018            let params_str = fd.params.join(", ");
1019            let out_str = match fd.outputs.len() {
1020                0 => String::new(),
1021                1 => format!("{} = ", fd.outputs[0]),
1022                _ => format!("[{}] = ", fd.outputs.join(", ")),
1023            };
1024            if let Some(name) = label {
1025                println!("{name} = @function {out_str}{name}({params_str})");
1026            } else {
1027                println!("@function {out_str}f({params_str})");
1028            }
1029        }
1030        Value::Tuple(vals) => {
1031            // Tuples are internal and shouldn't normally be displayed at the REPL level.
1032            // This can happen if a multi-output function is called without multi-assign.
1033            for (i, v) in vals.iter().enumerate() {
1034                print_value(label.map(|_| "ans").or(Some("ans")), v, fmt, base, compact);
1035                let _ = i;
1036            }
1037        }
1038        Value::ComplexMatrix(_) => {
1039            if let Some(full) = format_value_full(val, fmt) {
1040                let prefix = label.unwrap_or("ans");
1041                println!("{prefix} =");
1042                println!("{full}");
1043                if !compact {
1044                    println!();
1045                }
1046            }
1047        }
1048        Value::Cell(_) | Value::Struct(_) | Value::StructArray(_) => {
1049            if let Some(full) = format_value_full(val, fmt) {
1050                let prefix = label.unwrap_or("ans");
1051                println!("{prefix} =");
1052                println!("{full}");
1053                if !compact {
1054                    println!();
1055                }
1056            }
1057        }
1058        Value::DateTime(ts) => {
1059            let s = crate::datetime::format_datetime(*ts);
1060            if let Some(name) = label {
1061                println!("{name} = {s}");
1062            } else {
1063                println!("{s}");
1064            }
1065        }
1066        Value::Duration(secs) => {
1067            let s = crate::datetime::format_duration(*secs);
1068            if let Some(name) = label {
1069                println!("{name} = {s}");
1070            } else {
1071                println!("{s}");
1072            }
1073        }
1074        Value::DateTimeArray(_) | Value::DurationArray(_) => {
1075            if let Some(full) = format_value_full(val, fmt) {
1076                let prefix = label.unwrap_or("ans");
1077                println!("{prefix} =");
1078                println!("{full}");
1079                if !compact {
1080                    println!();
1081                }
1082            }
1083        }
1084        Value::Map(_) => {
1085            if let Some(full) = format_value_full(val, fmt) {
1086                let prefix = label.unwrap_or("ans");
1087                println!("{prefix} =");
1088                println!("{full}");
1089                if !compact {
1090                    println!();
1091                }
1092            }
1093        }
1094    }
1095}
1096
1097/// Recursively sets a value at `path` inside a nested struct map.
1098///
1099/// Ownership-by-value approach: consumes the map, updates it, and returns the updated map.
1100/// Intermediate structs are created on demand if a path segment does not yet exist.
1101fn set_nested(
1102    mut map: IndexMap<String, Value>,
1103    path: &[String],
1104    val: Value,
1105) -> Result<IndexMap<String, Value>, String> {
1106    let (first, rest) = path.split_first().expect("set_nested: empty path");
1107    if rest.is_empty() {
1108        map.insert(first.clone(), val);
1109    } else {
1110        let inner = match map.shift_remove(first) {
1111            Some(Value::Struct(m)) => *m,
1112            None => IndexMap::new(),
1113            Some(other) => {
1114                map.insert(first.clone(), other);
1115                return Err(format!("'{first}' is not a struct"));
1116            }
1117        };
1118        let updated = set_nested(inner, rest, val)?;
1119        map.insert(first.clone(), Value::Struct(Box::new(updated)));
1120    }
1121    Ok(map)
1122}
1123
1124/// Pre-register all top-level [`Stmt::FunctionDef`] nodes into `env`.
1125///
1126/// Implements MATLAB/Octave script hoisting semantics: called before execution
1127/// begins so helper functions defined anywhere in a script file are visible to
1128/// code that appears before them textually.  Only the immediate `stmts`
1129/// slice is scanned — function bodies are not descended into.
1130fn hoist_functions(stmts: &[StmtEntry], env: &mut Env) {
1131    for (stmt, _, _) in stmts {
1132        if let Stmt::FunctionDef {
1133            name,
1134            outputs,
1135            params,
1136            body_source,
1137            doc,
1138        } = stmt
1139        {
1140            env.insert(
1141                name.clone(),
1142                Value::Function(Box::new(crate::env::FunctionData {
1143                    outputs: outputs.clone(),
1144                    params: params.clone(),
1145                    body_source: body_source.clone(),
1146                    locals: IndexMap::new(),
1147                    doc: doc.clone(),
1148                })),
1149            );
1150        }
1151    }
1152}
1153
1154/// Execute a top-level script block (REPL input, pipe/script mode, `eval()`).
1155///
1156/// Identical to [`exec_stmts`] but first calls `hoist_functions` so that
1157/// helper functions defined at the bottom of the script are visible to code
1158/// above them — matching MATLAB/Octave script semantics.
1159pub fn exec_script(
1160    stmts: &[StmtEntry],
1161    env: &mut Env,
1162    io: &mut IoContext,
1163    fmt: &FormatMode,
1164    base: Base,
1165    compact: bool,
1166) -> Result<Option<Signal>, String> {
1167    hoist_functions(stmts, env);
1168    exec_stmts(stmts, env, io, fmt, base, compact)
1169}
1170
1171/// Execute a sequence of parsed statements, handling flow control signals.
1172///
1173/// Returns `Ok(None)` on normal completion, `Ok(Some(Signal::Break/Continue))`
1174/// when those flow-control statements escape a loop, or `Err(e)` on runtime error.
1175/// Loop implementations (`For`, `While`) catch `Break`/`Continue` internally;
1176/// a signal that reaches the top-level caller should be reported as an error.
1177pub fn exec_stmts(
1178    stmts: &[StmtEntry],
1179    env: &mut Env,
1180    io: &mut IoContext,
1181    fmt: &FormatMode,
1182    base: Base,
1183    compact: bool,
1184) -> Result<Option<Signal>, String> {
1185    // Fast pre-scan: check compilability with zero heap allocation.
1186    // This avoids the expensive Chunk-building + Expr-cloning that compile()
1187    // does before discovering an unsupported node deep inside a hot loop body.
1188    if crate::vm::compile::is_compilable(stmts) {
1189        match crate::vm::compile::compile(stmts) {
1190            Ok(chunk) => {
1191                return crate::vm::exec::vm_exec(&chunk, env, io, fmt, base, compact);
1192            }
1193            Err(crate::vm::CompileError::Unsupported) => {}
1194        }
1195    }
1196
1197    // Propagate display settings to eval.rs so named function bodies can use them.
1198    set_display_ctx(fmt, base, compact);
1199
1200    for (stmt, silent, stmt_line) in stmts {
1201        match stmt {
1202            Stmt::Assign(name, expr) => {
1203                set_nargout(1);
1204                let val = eval_with_io(expr, env, io).map_err(|e| annotate_line(e, *stmt_line))?;
1205                env.insert(name.clone(), val.clone());
1206                // Mirror to the shared global store when declared global in this scope.
1207                if is_global(name) {
1208                    global_set(name, val.clone());
1209                }
1210                // Write-through for persistent vars: recursive calls can see the update.
1211                if is_persistent(name) {
1212                    persistent_save(&current_func_name(), name, val.clone());
1213                }
1214                if !silent && !matches!(val, Value::Void) {
1215                    print_value(Some(name), &val, fmt, base, compact);
1216                }
1217            }
1218
1219            Stmt::Global(names) => {
1220                for name in names {
1221                    global_declare(name);
1222                    global_init_if_absent(name);
1223                    // If the name was already assigned in local env, promote it to global.
1224                    // Otherwise restore the current global value into local env.
1225                    if let Some(local_val) = env.remove(name) {
1226                        global_set(name, local_val.clone());
1227                        env.insert(name.clone(), local_val);
1228                    } else if let Some(global_val) = global_get(name) {
1229                        env.insert(name.clone(), global_val);
1230                    }
1231                }
1232            }
1233
1234            Stmt::Persistent(names) => {
1235                // Persistent is only meaningful inside a named function.
1236                // At the top level it is accepted and treated as a no-op.
1237                let func = current_func_name();
1238                for name in names {
1239                    persistent_declare(name);
1240                    if let Some(saved) = persistent_load(&func, name) {
1241                        // Subsequent call: restore the saved value.
1242                        env.insert(name.clone(), saved);
1243                    } else {
1244                        // First call: initialize to [] (empty matrix), matching MATLAB.
1245                        // This makes isempty(x) true so guards like
1246                        // `if isempty(x); x = 0; end` work correctly.
1247                        env.insert(
1248                            name.clone(),
1249                            Value::Matrix(Box::new(ndarray::Array2::zeros((0, 0)))),
1250                        );
1251                    }
1252                }
1253            }
1254
1255            Stmt::Expr(expr) => {
1256                // Intercept addpath()/rmpath()/path() — mutate the session search path.
1257                if let Expr::Call(fn_name, args) = expr
1258                    && matches!(fn_name.as_str(), "addpath" | "rmpath" | "path")
1259                {
1260                    match fn_name.as_str() {
1261                        "addpath" => {
1262                            if args.is_empty() || args.len() > 2 {
1263                                return Err(
1264                                    "addpath: expects 1 or 2 arguments: addpath(dir) or addpath(dir, '-end')".to_string()
1265                                );
1266                            }
1267                            let path_val = eval_with_io(&args[0], env, io)?;
1268                            let path_str = match &path_val {
1269                                Value::Str(s) | Value::StringObj(s) => s.clone(),
1270                                _ => {
1271                                    return Err(
1272                                        "addpath: argument must be a string (directory path)"
1273                                            .to_string(),
1274                                    );
1275                                }
1276                            };
1277                            let append = if args.len() == 2 {
1278                                let flag_val = eval_with_io(&args[1], env, io)?;
1279                                match &flag_val {
1280                                    Value::Str(s) | Value::StringObj(s) if s == "-end" => true,
1281                                    Value::Str(_) | Value::StringObj(_) => {
1282                                        return Err(
1283                                            "addpath: second argument must be '-end' (to append) or omitted (to prepend)".to_string()
1284                                        );
1285                                    }
1286                                    _ => {
1287                                        return Err(
1288                                            "addpath: second argument must be a string '-end'"
1289                                                .to_string(),
1290                                        );
1291                                    }
1292                                }
1293                            } else {
1294                                false
1295                            };
1296                            let expanded = expand_tilde(&path_str);
1297                            let pb = std::path::PathBuf::from(&expanded);
1298                            session_path_add(pb, append);
1299                            if !silent {
1300                                for p in session_path_list() {
1301                                    println!("{}", p.display());
1302                                }
1303                            }
1304                        }
1305                        "rmpath" => {
1306                            if args.len() != 1 {
1307                                return Err("rmpath: expects exactly 1 argument".to_string());
1308                            }
1309                            let path_val = eval_with_io(&args[0], env, io)?;
1310                            let path_str = match &path_val {
1311                                Value::Str(s) | Value::StringObj(s) => s.clone(),
1312                                _ => {
1313                                    return Err(
1314                                        "rmpath: argument must be a string (directory path)"
1315                                            .to_string(),
1316                                    );
1317                                }
1318                            };
1319                            let expanded = expand_tilde(&path_str);
1320                            session_path_remove(std::path::Path::new(&expanded));
1321                        }
1322                        "path" => {
1323                            if !args.is_empty() {
1324                                return Err("path: takes no arguments".to_string());
1325                            }
1326                            if !silent {
1327                                let paths = session_path_list();
1328                                if paths.is_empty() {
1329                                    println!("(search path is empty)");
1330                                } else {
1331                                    for p in &paths {
1332                                        println!("{}", p.display());
1333                                    }
1334                                }
1335                            }
1336                        }
1337                        _ => unreachable!(),
1338                    }
1339                    continue;
1340                }
1341
1342                // Intercept run()/source() — execute a script file in the current workspace.
1343                // Variables defined in the script persist in the caller's scope (MATLAB `run` semantics).
1344                if let Expr::Call(fn_name, args) = expr
1345                    && matches!(fn_name.as_str(), "run" | "source")
1346                    && args.len() == 1
1347                {
1348                    let path_val = eval_with_io(&args[0], env, io)?;
1349                    let filename = match &path_val {
1350                        Value::Str(s) | Value::StringObj(s) => s.clone(),
1351                        _ => {
1352                            return Err(format!("{fn_name}: argument must be a string (filename)"));
1353                        }
1354                    };
1355                    let script_path = resolve_script_path(&filename)
1356                        .ok_or_else(|| format!("{fn_name}: script not found: '{filename}'"))?;
1357                    let content = std::fs::read_to_string(&script_path).map_err(|e| {
1358                        format!("{fn_name}: cannot read '{}': {e}", script_path.display())
1359                    })?;
1360                    let depth = RUN_DEPTH.with(|d| d.get());
1361                    if depth >= 64 {
1362                        return Err(format!(
1363                            "{fn_name}: maximum script nesting depth (64) exceeded"
1364                        ));
1365                    }
1366                    RUN_DEPTH.with(|d| d.set(depth + 1));
1367                    // Push the script's directory so nested run()/source() calls resolve
1368                    // helper scripts relative to the calling script's location.
1369                    if let Some(dir) = script_path.parent() {
1370                        SCRIPT_DIR_STACK.with(|s| s.borrow_mut().push(dir.to_path_buf()));
1371                    }
1372                    let run_stmts = parse_stmts(&content).map_err(|e| {
1373                        format!("{fn_name}: parse error in '{}': {e}", script_path.display())
1374                    })?;
1375
1376                    // MATLAB scoping: if EVERY statement is a function definition,
1377                    // treat it as a function file — expose only the primary function and
1378                    // bundle all helpers into its `locals` (invisible to the caller).
1379                    // A mixed script+function file (functions-at-top style) is NOT a
1380                    // function file; its script body must also execute.
1381                    let is_fn_file = !run_stmts.is_empty()
1382                        && run_stmts
1383                            .iter()
1384                            .all(|(s, _, _)| matches!(s, Stmt::FunctionDef { .. }));
1385                    let result = if is_fn_file {
1386                        let primary_name = match &run_stmts[0].0 {
1387                            Stmt::FunctionDef { name, .. } => name.clone(),
1388                            _ => unreachable!(),
1389                        };
1390                        let mut locals: IndexMap<String, Value> = IndexMap::new();
1391                        for (stmt, _, _) in &run_stmts {
1392                            if let Stmt::FunctionDef {
1393                                name,
1394                                outputs,
1395                                params,
1396                                body_source,
1397                                doc,
1398                            } = stmt
1399                                && name != &primary_name
1400                            {
1401                                locals.insert(
1402                                    name.clone(),
1403                                    Value::Function(Box::new(crate::env::FunctionData {
1404                                        outputs: outputs.clone(),
1405                                        params: params.clone(),
1406                                        body_source: body_source.clone(),
1407                                        locals: IndexMap::new(),
1408                                        doc: doc.clone(),
1409                                    })),
1410                                );
1411                            }
1412                        }
1413                        if let Stmt::FunctionDef {
1414                            outputs,
1415                            params,
1416                            body_source,
1417                            doc,
1418                            ..
1419                        } = &run_stmts[0].0
1420                        {
1421                            env.insert(
1422                                primary_name,
1423                                Value::Function(Box::new(crate::env::FunctionData {
1424                                    outputs: outputs.clone(),
1425                                    params: params.clone(),
1426                                    body_source: body_source.clone(),
1427                                    locals,
1428                                    doc: doc.clone(),
1429                                })),
1430                            );
1431                        }
1432                        Ok(None)
1433                    } else {
1434                        // Pre-load all local function defs so that forward references
1435                        // within the script work (MATLAB local-function semantics).
1436                        for (stmt, _, _) in run_stmts.iter() {
1437                            if let Stmt::FunctionDef {
1438                                name,
1439                                outputs,
1440                                params,
1441                                body_source,
1442                                doc,
1443                            } = stmt
1444                            {
1445                                env.insert(
1446                                    name.clone(),
1447                                    Value::Function(Box::new(crate::env::FunctionData {
1448                                        outputs: outputs.clone(),
1449                                        params: params.clone(),
1450                                        body_source: body_source.clone(),
1451                                        locals: IndexMap::new(),
1452                                        doc: doc.clone(),
1453                                    })),
1454                                );
1455                            }
1456                        }
1457                        exec_stmts(&run_stmts, env, io, fmt, base, compact)
1458                    };
1459                    SCRIPT_DIR_STACK.with(|s| s.borrow_mut().pop());
1460                    RUN_DEPTH.with(|d| d.set(depth));
1461                    // Propagate signals (return/break/continue) but do NOT early-return
1462                    // from exec_stmts — remaining statements in the outer script must run.
1463                    match result? {
1464                        None => {}
1465                        Some(sig) => return Ok(Some(sig)),
1466                    }
1467                    continue;
1468                }
1469
1470                // Intercept eval(str) / eval(str, catch_str) — execute code in current workspace.
1471                // Variable assignments inside the string persist in the caller's scope,
1472                // matching MATLAB `eval` semantics. Uses the same RUN_DEPTH limit as run/source.
1473                if let Expr::Call(fn_name, args) = expr
1474                    && fn_name == "eval"
1475                    && (args.len() == 1 || args.len() == 2)
1476                {
1477                    let code_val = eval_with_io(&args[0], env, io)?;
1478                    let code_str = match code_val {
1479                        Value::Str(s) | Value::StringObj(s) => s,
1480                        _ => return Err("eval: argument must be a string".to_string()),
1481                    };
1482                    let depth = RUN_DEPTH.with(|d| d.get());
1483                    if depth >= 64 {
1484                        return Err("eval: maximum nesting depth (64) exceeded".to_string());
1485                    }
1486                    RUN_DEPTH.with(|d| d.set(depth + 1));
1487                    let run_result = (|| -> Result<Option<Signal>, String> {
1488                        let stmts = parse_stmts(&code_str)
1489                            .map_err(|e| format!("eval: parse error: {e}"))?;
1490                        exec_script(&stmts, env, io, fmt, base, compact)
1491                    })();
1492                    RUN_DEPTH.with(|d| d.set(depth));
1493                    match run_result {
1494                        Err(e) if args.len() == 2 => {
1495                            set_last_err(&e);
1496                            let catch_val = eval_with_io(&args[1], env, io)?;
1497                            let catch_str = match catch_val {
1498                                Value::Str(s) | Value::StringObj(s) => s,
1499                                _ => {
1500                                    return Err("eval: catch argument must be a string".to_string());
1501                                }
1502                            };
1503                            let catch_stmts = parse_stmts(&catch_str)
1504                                .map_err(|e| format!("eval: catch parse error: {e}"))?;
1505                            match exec_stmts(&catch_stmts, env, io, fmt, base, compact)? {
1506                                None => {}
1507                                Some(sig) => return Ok(Some(sig)),
1508                            }
1509                        }
1510                        Err(e) => return Err(e),
1511                        Ok(None) => {}
1512                        Ok(Some(sig)) => return Ok(Some(sig)),
1513                    }
1514                    continue;
1515                }
1516
1517                // Intercept clear() / clear('x','y') — workspace variable removal.
1518                if let Expr::Call(fn_name, args) = expr
1519                    && fn_name == "clear"
1520                {
1521                    if args.is_empty() {
1522                        env.clear();
1523                    } else {
1524                        for arg in args {
1525                            let key = match arg {
1526                                Expr::StrLiteral(s) | Expr::StringObjLiteral(s) => s.clone(),
1527                                other => match eval_with_io(other, env, io)? {
1528                                    Value::Str(s) | Value::StringObj(s) => s,
1529                                    _ => continue,
1530                                },
1531                            };
1532                            env.remove(&key);
1533                        }
1534                    }
1535                    continue;
1536                }
1537
1538                // Intercept remove(m, k) — in-place key removal from containers.Map.
1539                if let Expr::Call(fn_name, args) = expr
1540                    && fn_name == "remove"
1541                    && args.len() == 2
1542                    && let Expr::Var(map_name) = &args[0]
1543                {
1544                    let map_name = map_name.clone();
1545                    let key_val = eval_with_io(&args[1], env, io)
1546                        .map_err(|e| annotate_line(e, *stmt_line))?;
1547                    let key = match key_val {
1548                        Value::Str(s) | Value::StringObj(s) => s,
1549                        _ => {
1550                            return Err(annotate_line(
1551                                "remove: key must be a string".to_string(),
1552                                *stmt_line,
1553                            ));
1554                        }
1555                    };
1556                    match env.get_mut(&map_name) {
1557                        Some(Value::Map(map)) => {
1558                            map.shift_remove(&key);
1559                        }
1560                        Some(_) => {
1561                            return Err(annotate_line(
1562                                format!("remove: '{map_name}' is not a containers.Map"),
1563                                *stmt_line,
1564                            ));
1565                        }
1566                        None => {
1567                            return Err(annotate_line(
1568                                format!("remove: undefined variable '{map_name}'"),
1569                                *stmt_line,
1570                            ));
1571                        }
1572                    }
1573                    continue;
1574                }
1575
1576                // Intercept format — update thread-local display context.
1577                if let Expr::Call(fn_name, args) = expr
1578                    && fn_name == "format"
1579                {
1580                    let arg = match args.first() {
1581                        Some(Expr::StrLiteral(s)) => s.as_str(),
1582                        None => "",
1583                        _ => "",
1584                    };
1585                    let new_fmt = match arg {
1586                        "" | "short" => FormatMode::Short,
1587                        "long" => FormatMode::Long,
1588                        "shorte" | "shortE" => FormatMode::ShortE,
1589                        "longe" | "longE" => FormatMode::LongE,
1590                        "shortg" | "shortG" => FormatMode::ShortG,
1591                        "longg" | "longG" => FormatMode::LongG,
1592                        "bank" => FormatMode::Bank,
1593                        "rat" => FormatMode::Rat,
1594                        "hex" => FormatMode::Hex,
1595                        "+" => FormatMode::Plus,
1596                        "compact" | "loose" => get_display_fmt(),
1597                        s => s
1598                            .parse::<usize>()
1599                            .map(FormatMode::Custom)
1600                            .unwrap_or_else(|_| get_display_fmt()),
1601                    };
1602                    let new_compact = match arg {
1603                        "compact" => true,
1604                        "loose" => false,
1605                        _ => get_display_compact(),
1606                    };
1607                    set_display_ctx(&new_fmt, base, new_compact);
1608                    continue;
1609                }
1610
1611                // Intercept save()/load() — workspace persistence (same semantics as REPL).
1612                if let Expr::Call(fn_name, args) = expr
1613                    && matches!(fn_name.as_str(), "save" | "load" | "ws" | "wl")
1614                {
1615                    let is_save = matches!(fn_name.as_str(), "save" | "ws");
1616                    if is_save {
1617                        let (path_opt, var_names) = if args.is_empty() {
1618                            (None, vec![])
1619                        } else {
1620                            let path_val = eval_with_io(&args[0], env, io)?;
1621                            let path_str = match path_val {
1622                                Value::Str(s) | Value::StringObj(s) => s,
1623                                _ => return Err("save: path argument must be a string".to_string()),
1624                            };
1625                            let mut vars: Vec<String> = Vec::new();
1626                            for a in &args[1..] {
1627                                let v = eval_with_io(a, env, io)?;
1628                                match v {
1629                                    Value::Str(s) | Value::StringObj(s) => vars.push(s),
1630                                    _ => {
1631                                        return Err(
1632                                            "save: variable names must be strings".to_string()
1633                                        );
1634                                    }
1635                                }
1636                            }
1637                            (Some(path_str), vars)
1638                        };
1639                        let result = match &path_opt {
1640                            None => {
1641                                let home = std::env::var("HOME")
1642                                    .or_else(|_| std::env::var("USERPROFILE"))
1643                                    .unwrap_or_default();
1644                                let p = std::path::Path::new(&home)
1645                                    .join(".config")
1646                                    .join("ccalc")
1647                                    .join("workspace.toml");
1648                                save_workspace(env, &p)
1649                            }
1650                            Some(p) if var_names.is_empty() => {
1651                                save_workspace(env, std::path::Path::new(p))
1652                            }
1653                            Some(p) => {
1654                                let refs: Vec<&str> =
1655                                    var_names.iter().map(String::as_str).collect();
1656                                save_workspace_vars(env, std::path::Path::new(p), &refs)
1657                            }
1658                        };
1659                        if let Err(e) = result {
1660                            return Err(format!("save: {e}"));
1661                        }
1662                    } else {
1663                        // load
1664                        let loaded = if args.is_empty() {
1665                            let home = std::env::var("HOME")
1666                                .or_else(|_| std::env::var("USERPROFILE"))
1667                                .unwrap_or_default();
1668                            let p = std::path::Path::new(&home)
1669                                .join(".config")
1670                                .join("ccalc")
1671                                .join("workspace.toml");
1672                            load_workspace(&p)
1673                        } else {
1674                            let path_val = eval_with_io(&args[0], env, io)?;
1675                            let path_str = match path_val {
1676                                Value::Str(s) | Value::StringObj(s) => s,
1677                                _ => return Err("load: path argument must be a string".to_string()),
1678                            };
1679                            load_workspace(std::path::Path::new(&path_str))
1680                        };
1681                        match loaded {
1682                            Ok(ws) => env.extend(ws),
1683                            Err(e) => return Err(format!("load: {e}")),
1684                        }
1685                    }
1686                    continue;
1687                }
1688
1689                let expr_label = if let Expr::Var(name) = expr {
1690                    Some(name.as_str())
1691                } else {
1692                    None
1693                };
1694                let val = eval_with_io(expr, env, io).map_err(|e| annotate_line(e, *stmt_line))?;
1695                env.insert("ans".to_string(), val.clone());
1696                if !silent && !matches!(val, Value::Void) {
1697                    print_value(expr_label, &val, fmt, base, compact);
1698                }
1699            }
1700
1701            Stmt::If {
1702                cond,
1703                body,
1704                elseif_branches,
1705                else_body,
1706            } => {
1707                let cond_val =
1708                    eval_with_io(cond, env, io).map_err(|e| annotate_line(e, *stmt_line))?;
1709                let chosen: Option<&[StmtEntry]> = if is_truthy(&cond_val) {
1710                    Some(body)
1711                } else {
1712                    let mut found = None;
1713                    for (ei_cond, ei_body) in elseif_branches {
1714                        if is_truthy(
1715                            &eval_with_io(ei_cond, env, io)
1716                                .map_err(|e| annotate_line(e, *stmt_line))?,
1717                        ) {
1718                            found = Some(ei_body.as_slice());
1719                            break;
1720                        }
1721                    }
1722                    if found.is_none() {
1723                        found = else_body.as_deref();
1724                    }
1725                    found
1726                };
1727                if let Some(body_stmts) = chosen
1728                    && let Some(sig) = exec_stmts(body_stmts, env, io, fmt, base, compact)?
1729                {
1730                    return Ok(Some(sig));
1731                }
1732            }
1733
1734            Stmt::For {
1735                var,
1736                range_expr,
1737                body,
1738            } => {
1739                let range_val =
1740                    eval_with_io(range_expr, env, io).map_err(|e| annotate_line(e, *stmt_line))?;
1741                let iter_cols: Vec<Value> = match range_val {
1742                    Value::Scalar(n) => vec![Value::Scalar(n)],
1743                    Value::Matrix(m) => {
1744                        let nrows = m.nrows();
1745                        let ncols = m.ncols();
1746                        (0..ncols)
1747                            .map(|j| {
1748                                if nrows == 1 {
1749                                    // Row vector: yield each element as a scalar
1750                                    Value::Scalar(m[[0, j]])
1751                                } else {
1752                                    // General matrix: yield each column as an M×1 matrix
1753                                    let mut col = Array2::zeros((nrows, 1));
1754                                    for i in 0..nrows {
1755                                        col[[i, 0]] = m[[i, j]];
1756                                    }
1757                                    Value::Matrix(Box::new(col))
1758                                }
1759                            })
1760                            .collect()
1761                    }
1762                    _ => return Err("'for' range must evaluate to a scalar or matrix".to_string()),
1763                };
1764
1765                'for_loop: for col_val in iter_cols {
1766                    env.insert(var.clone(), col_val);
1767                    match exec_stmts(body, env, io, fmt, base, compact)? {
1768                        None => {}
1769                        Some(Signal::Break) => break 'for_loop,
1770                        Some(Signal::Continue) => continue 'for_loop,
1771                        Some(Signal::Return) => return Ok(Some(Signal::Return)),
1772                    }
1773                }
1774            }
1775
1776            Stmt::While { cond, body } => loop {
1777                if !is_truthy(
1778                    &eval_with_io(cond, env, io).map_err(|e| annotate_line(e, *stmt_line))?,
1779                ) {
1780                    break;
1781                }
1782                match exec_stmts(body, env, io, fmt, base, compact)? {
1783                    None => {}
1784                    Some(Signal::Break) => break,
1785                    Some(Signal::Continue) => continue,
1786                    Some(Signal::Return) => return Ok(Some(Signal::Return)),
1787                }
1788            },
1789
1790            Stmt::Break => return Ok(Some(Signal::Break)),
1791            Stmt::Continue => return Ok(Some(Signal::Continue)),
1792
1793            // ── switch / case / otherwise / end ──────────────────────────────
1794            Stmt::Switch {
1795                expr,
1796                cases,
1797                otherwise_body,
1798            } => {
1799                let switch_val =
1800                    eval_with_io(expr, env, io).map_err(|e| annotate_line(e, *stmt_line))?;
1801                let mut matched = false;
1802                'switch_loop: for (case_exprs, case_body) in cases {
1803                    for case_expr in case_exprs {
1804                        let case_val = eval_with_io(case_expr, env, io)
1805                            .map_err(|e| annotate_line(e, *stmt_line))?;
1806                        // When the case expression is a Cell, check if switch_val
1807                        // matches any element of the cell (Phase 12.5c).
1808                        let is_match = if let Value::Cell(cell_elems) = &case_val {
1809                            cell_elems.iter().any(|elem| match (&switch_val, elem) {
1810                                (Value::Scalar(a), Value::Scalar(b)) => a == b,
1811                                _ => {
1812                                    let sv = match &switch_val {
1813                                        Value::Str(s) | Value::StringObj(s) => Some(s.as_str()),
1814                                        _ => None,
1815                                    };
1816                                    let cv = match elem {
1817                                        Value::Str(s) | Value::StringObj(s) => Some(s.as_str()),
1818                                        _ => None,
1819                                    };
1820                                    matches!((sv, cv), (Some(a), Some(b)) if a == b)
1821                                }
1822                            })
1823                        } else {
1824                            match (&switch_val, &case_val) {
1825                                (Value::Scalar(a), Value::Scalar(b)) => a == b,
1826                                _ => {
1827                                    let sv = match &switch_val {
1828                                        Value::Str(s) | Value::StringObj(s) => Some(s.as_str()),
1829                                        _ => None,
1830                                    };
1831                                    let cv = match &case_val {
1832                                        Value::Str(s) | Value::StringObj(s) => Some(s.as_str()),
1833                                        _ => None,
1834                                    };
1835                                    matches!((sv, cv), (Some(a), Some(b)) if a == b)
1836                                }
1837                            }
1838                        };
1839                        if is_match {
1840                            if let Some(sig) = exec_stmts(case_body, env, io, fmt, base, compact)? {
1841                                return Ok(Some(sig));
1842                            }
1843                            matched = true;
1844                            break 'switch_loop;
1845                        }
1846                    }
1847                }
1848                if !matched
1849                    && let Some(ob) = otherwise_body
1850                    && let Some(sig) = exec_stmts(ob, env, io, fmt, base, compact)?
1851                {
1852                    return Ok(Some(sig));
1853                }
1854            }
1855
1856            // ── do...until ───────────────────────────────────────────────────
1857            Stmt::DoUntil { body, cond } => loop {
1858                match exec_stmts(body, env, io, fmt, base, compact)? {
1859                    Some(Signal::Break) => break,
1860                    Some(Signal::Continue) | None => {}
1861                    Some(Signal::Return) => return Ok(Some(Signal::Return)),
1862                }
1863                if is_truthy(
1864                    &eval_with_io(cond, env, io).map_err(|e| annotate_line(e, *stmt_line))?,
1865                ) {
1866                    break;
1867                }
1868            },
1869
1870            // ── try / catch / end ────────────────────────────────────────────
1871            Stmt::TryCatch {
1872                try_body,
1873                catch_var,
1874                catch_body,
1875            } => match exec_stmts(try_body, env, io, fmt, base, compact) {
1876                Ok(None) => {}
1877                Ok(Some(sig)) => return Ok(Some(sig)),
1878                Err(msg) => {
1879                    let clean = strip_near_line(msg);
1880                    set_last_err(&clean);
1881                    if let Some(var) = catch_var {
1882                        let mut map = IndexMap::new();
1883                        map.insert("message".to_string(), Value::Str(clean));
1884                        env.insert(var.clone(), Value::Struct(Box::new(map)));
1885                    }
1886                    if let Some(sig) = exec_stmts(catch_body, env, io, fmt, base, compact)? {
1887                        return Ok(Some(sig));
1888                    }
1889                }
1890            },
1891
1892            // ── function definition ──────────────────────────────────────────
1893            Stmt::FunctionDef {
1894                name,
1895                outputs,
1896                params,
1897                body_source,
1898                doc,
1899            } => {
1900                env.insert(
1901                    name.clone(),
1902                    Value::Function(Box::new(crate::env::FunctionData {
1903                        outputs: outputs.clone(),
1904                        params: params.clone(),
1905                        body_source: body_source.clone(),
1906                        locals: IndexMap::new(),
1907                        doc: doc.clone(),
1908                    })),
1909                );
1910            }
1911
1912            // ── return ───────────────────────────────────────────────────────
1913            Stmt::Return => return Ok(Some(Signal::Return)),
1914
1915            // ── cell element assignment ──────────────────────────────────────
1916            Stmt::CellSet(cell_name, idx_expr, val_expr) => {
1917                // Inject `end` so that c{end+1} works (end = current cell length).
1918                let cell_len = match env.get(cell_name) {
1919                    Some(Value::Cell(v)) => v.len(),
1920                    _ => 0,
1921                };
1922                let _owned_end;
1923                let env_end: &Env = if crate::eval::contains_end(idx_expr) {
1924                    _owned_end = write_env_with_end(env, cell_len);
1925                    &_owned_end
1926                } else {
1927                    env
1928                };
1929                let idx = eval_with_io(idx_expr, env_end, io)
1930                    .map_err(|e| annotate_line(e, *stmt_line))?;
1931                let rhs =
1932                    eval_with_io(val_expr, env, io).map_err(|e| annotate_line(e, *stmt_line))?;
1933                let i = match idx {
1934                    Value::Scalar(n) => n as isize,
1935                    _ => return Err(format!("{cell_name}{{}}: index must be a scalar integer")),
1936                };
1937                match env.get_mut(cell_name) {
1938                    Some(Value::Cell(v)) => {
1939                        if i < 1 {
1940                            return Err(format!(
1941                                "{cell_name}{{}}: index {i} out of range (1..{})",
1942                                v.len()
1943                            ));
1944                        }
1945                        let idx = (i - 1) as usize;
1946                        // Grow the cell if needed (MATLAB semantics: assigning beyond end grows it)
1947                        if idx >= v.len() {
1948                            v.resize(idx + 1, Value::Scalar(0.0));
1949                        }
1950                        v[idx] = rhs.clone();
1951                    }
1952                    Some(_) => {
1953                        return Err(format!(
1954                            "'{cell_name}' is not a cell array; use () for regular indexing"
1955                        ));
1956                    }
1957                    None => {
1958                        // Auto-create cell if not defined (MATLAB semantics)
1959                        if i < 1 {
1960                            return Err(format!("{cell_name}{{}}: index {i} must be >= 1"));
1961                        }
1962                        let idx = (i - 1) as usize;
1963                        let mut v = vec![Value::Scalar(0.0); idx + 1];
1964                        v[idx] = rhs.clone();
1965                        env.insert(cell_name.clone(), Value::Cell(Box::new(v)));
1966                    }
1967                }
1968                if !silent && let Some(val) = env.get(cell_name) {
1969                    print_value(Some(cell_name), val, fmt, base, compact);
1970                }
1971            }
1972
1973            // ── struct field assignment ──────────────────────────────────────
1974            Stmt::FieldSet(base_name, path, rhs_expr) => {
1975                let rhs =
1976                    eval_with_io(rhs_expr, env, io).map_err(|e| annotate_line(e, *stmt_line))?;
1977                let root = match env.remove(base_name) {
1978                    Some(Value::Struct(m)) => *m,
1979                    None => IndexMap::new(),
1980                    Some(other) => {
1981                        env.insert(base_name.clone(), other);
1982                        return Err(format!("'{base_name}' is not a struct"));
1983                    }
1984                };
1985                let updated = set_nested(root, path, rhs)?;
1986                let struct_val = Value::Struct(Box::new(updated));
1987                if !silent {
1988                    print_value(Some(base_name), &struct_val, fmt, base, compact);
1989                }
1990                env.insert(base_name.clone(), struct_val);
1991            }
1992
1993            // ── dynamic struct field assignment ──────────────────────────────
1994            Stmt::DynFieldSet(base_name, field_expr, rhs_expr) => {
1995                let field_val =
1996                    eval_with_io(field_expr, env, io).map_err(|e| annotate_line(e, *stmt_line))?;
1997                let field = match &field_val {
1998                    Value::Str(s) | Value::StringObj(s) => s.clone(),
1999                    _ => {
2000                        return Err(annotate_line(
2001                            "Dynamic field name must be a string".to_string(),
2002                            *stmt_line,
2003                        ));
2004                    }
2005                };
2006                let rhs =
2007                    eval_with_io(rhs_expr, env, io).map_err(|e| annotate_line(e, *stmt_line))?;
2008                let root = match env.remove(base_name) {
2009                    Some(Value::Struct(m)) => *m,
2010                    None => IndexMap::new(),
2011                    Some(other) => {
2012                        env.insert(base_name.clone(), other);
2013                        return Err(annotate_line(
2014                            format!("'{base_name}' is not a struct"),
2015                            *stmt_line,
2016                        ));
2017                    }
2018                };
2019                let mut updated = root;
2020                updated.insert(field, rhs);
2021                let struct_val = Value::Struct(Box::new(updated));
2022                if !silent {
2023                    print_value(Some(base_name), &struct_val, fmt, base, compact);
2024                }
2025                env.insert(base_name.clone(), struct_val);
2026            }
2027
2028            // ── struct array element field assignment ────────────────────────
2029            Stmt::StructArrayFieldSet(base_name, idx_expr, path, rhs_expr) => {
2030                let rhs =
2031                    eval_with_io(rhs_expr, env, io).map_err(|e| annotate_line(e, *stmt_line))?;
2032                let idx_val =
2033                    eval_with_io(idx_expr, env, io).map_err(|e| annotate_line(e, *stmt_line))?;
2034                let idx = match &idx_val {
2035                    Value::Scalar(n) => {
2036                        let i = *n as isize;
2037                        if i < 1 {
2038                            return Err(format!(
2039                                "Struct array index must be a positive integer, got {n}"
2040                            ));
2041                        }
2042                        i as usize
2043                    }
2044                    _ => return Err("Struct array index must be a scalar integer".to_string()),
2045                };
2046                // Load or create the struct array
2047                let mut arr: Vec<IndexMap<String, Value>> = match env.remove(base_name) {
2048                    Some(Value::StructArray(v)) => *v,
2049                    // A scalar struct with no index yet — promote to 1-element array
2050                    Some(Value::Struct(m)) => vec![*m],
2051                    None => Vec::new(),
2052                    Some(other) => {
2053                        env.insert(base_name.clone(), other);
2054                        return Err(format!("'{base_name}' is not a struct array"));
2055                    }
2056                };
2057                // Grow the array if needed (fill with empty structs)
2058                while arr.len() < idx {
2059                    arr.push(IndexMap::new());
2060                }
2061                // Set the field(s) on element idx (1-based → 0-based)
2062                let elem = arr[idx - 1].clone();
2063                let updated_elem = set_nested(elem, path, rhs)?;
2064                arr[idx - 1] = updated_elem;
2065                let arr_val = Value::StructArray(Box::new(arr));
2066                if !silent {
2067                    print_value(Some(base_name), &arr_val, fmt, base, compact);
2068                }
2069                env.insert(base_name.clone(), arr_val);
2070            }
2071
2072            // ── indexed assignment ────────────────────────────────────────────
2073            Stmt::IndexSet {
2074                name,
2075                indices,
2076                value,
2077            } => {
2078                let rhs = eval_with_io(value, env, io).map_err(|e| annotate_line(e, *stmt_line))?;
2079                // For persistent vars: refresh from the store before applying a partial
2080                // update so that values written by recursive calls are not overwritten.
2081                if is_persistent(name) {
2082                    let func = current_func_name();
2083                    if let Some(fresh) = persistent_load(&func, name) {
2084                        env.insert(name.clone(), fresh);
2085                    }
2086                }
2087                exec_index_set(name, indices, rhs, env, io)?;
2088                // Write-through: persist immediately so recursive callers see the update.
2089                if is_persistent(name)
2090                    && let Some(val) = env.get(name)
2091                {
2092                    persistent_save(&current_func_name(), name, val.clone());
2093                }
2094                if !silent && let Some(val) = env.get(name) {
2095                    print_value(Some(name), val, fmt, base, compact);
2096                }
2097            }
2098
2099            // ── multi-assign ─────────────────────────────────────────────────
2100            Stmt::MultiAssign { targets, expr } => {
2101                set_nargout(targets.len());
2102                let val = eval_with_io(expr, env, io).map_err(|e| annotate_line(e, *stmt_line))?;
2103                let vals: Vec<Value> = match val {
2104                    Value::Tuple(v) => v,
2105                    other => vec![other],
2106                };
2107                for (i, target) in targets.iter().enumerate() {
2108                    if target == "~" {
2109                        continue; // discard output
2110                    }
2111                    let v = vals.get(i).cloned().unwrap_or(Value::Void);
2112                    env.insert(target.clone(), v.clone());
2113                    if !silent && !matches!(v, Value::Void) {
2114                        print_value(Some(target), &v, fmt, base, compact);
2115                    }
2116                }
2117            }
2118        }
2119    }
2120    // After all statements, refresh global vars in env from the global store.
2121    // This ensures that modifications made inside called functions (which write
2122    // to the global store) are visible in the current scope's environment.
2123    global_refresh_into_env(env);
2124    Ok(None)
2125}
2126
2127// ── indexed assignment helper ──────────────────────────────────────────────
2128
2129/// Resolved index positions (0-based) along one matrix dimension.
2130enum WriteIdx {
2131    /// `:` — all positions in the current dimension.
2132    All,
2133    /// Explicit 0-based positions (may exceed current size → grow).
2134    Positions(Vec<usize>),
2135}
2136
2137/// Resolves one index expression to a `WriteIdx`.
2138///
2139/// Unlike the read-path `resolve_dim`, bounds checking is skipped so that
2140/// out-of-range indices can trigger array growth.  Logical-mask detection
2141/// (all-0/1 vector whose length equals `dim_size`) is supported.
2142fn resolve_write_dim(
2143    expr: &crate::eval::Expr,
2144    dim_size: usize,
2145    env: &Env,
2146    io: &mut IoContext,
2147) -> Result<WriteIdx, String> {
2148    if matches!(expr, crate::eval::Expr::Colon) {
2149        return Ok(WriteIdx::All);
2150    }
2151    let val = eval_with_io(expr, env, io)?;
2152    let floats: Vec<f64> = match val {
2153        Value::Scalar(n) => vec![n],
2154        Value::Complex(re, im) => {
2155            if im != 0.0 {
2156                return Err("Index must be real, not complex".to_string());
2157            }
2158            vec![re]
2159        }
2160        Value::Matrix(m) => {
2161            let total = m.nrows() * m.ncols();
2162            if m.nrows() > 1 && m.ncols() > 1 && total != dim_size {
2163                return Err("Index must be a scalar or vector, not a 2-D matrix".to_string());
2164            }
2165            // Collect in column-major order so mask positions align with linear indexing.
2166            if m.nrows() > 1 && m.ncols() > 1 {
2167                let mut v = Vec::with_capacity(total);
2168                for col in 0..m.ncols() {
2169                    for row in 0..m.nrows() {
2170                        v.push(m[[row, col]]);
2171                    }
2172                }
2173                v
2174            } else {
2175                m.iter().copied().collect()
2176            }
2177        }
2178        _ => return Err("Index must be numeric".to_string()),
2179    };
2180    // Logical mask: a 0/1 array whose element count matches dim_size.
2181    if dim_size > 0 && floats.len() == dim_size && floats.iter().all(|&f| f == 0.0 || f == 1.0) {
2182        let positions: Vec<usize> = floats
2183            .iter()
2184            .enumerate()
2185            .filter(|&(_, &f)| f == 1.0)
2186            .map(|(i, _)| i)
2187            .collect();
2188        return Ok(WriteIdx::Positions(positions));
2189    }
2190    // Numeric 1-based indices → 0-based (no upper-bound check — growth is handled by caller).
2191    let positions: Result<Vec<usize>, String> = floats
2192        .iter()
2193        .map(|&n| {
2194            let i = n.round() as i64;
2195            if i < 1 {
2196                return Err(format!("Index {i} must be >= 1"));
2197            }
2198            Ok(i as usize - 1)
2199        })
2200        .collect();
2201    Ok(WriteIdx::Positions(positions?))
2202}
2203
2204/// Returns a clone of `env` with `end` set to `dim_size` as a `Value::Scalar`.
2205fn write_env_with_end(env: &Env, dim_size: usize) -> Env {
2206    let mut e = env.clone();
2207    e.insert("end".to_string(), Value::Scalar(dim_size as f64));
2208    e
2209}
2210
2211/// Writes `rhs` into `name(indices...)` in `env`, growing the matrix if necessary.
2212///
2213/// Supports:
2214/// - 1 index: linear (column-major) indexing; grows row vectors / creates new ones.
2215/// - 2 indices: 2-D row/column indexing; grows the matrix in either dimension.
2216/// - Scalar RHS is broadcast to all selected positions.
2217/// - Logical mask indices (Phase 15d).
2218pub(crate) fn exec_index_set(
2219    name: &str,
2220    indices: &[crate::eval::Expr],
2221    rhs: Value,
2222    env: &mut Env,
2223    io: &mut IoContext,
2224) -> Result<(), String> {
2225    // Map key assignment: m('key') = val
2226    if indices.len() == 1 && matches!(env.get(name), Some(Value::Map(_))) {
2227        let key_val = eval_with_io(&indices[0], env, io)?;
2228        let key = match key_val {
2229            Value::Str(s) | Value::StringObj(s) => s,
2230            _ => return Err("Map key assignment requires a string key".to_string()),
2231        };
2232        match env.get_mut(name) {
2233            Some(Value::Map(map)) => {
2234                map.insert(key, rhs);
2235                return Ok(());
2236            }
2237            _ => unreachable!(),
2238        }
2239    }
2240
2241    // If LHS is already ComplexMatrix or RHS introduces complex values, use the complex path.
2242    let needs_complex = matches!(env.get(name), Some(Value::ComplexMatrix(_)))
2243        || matches!(&rhs, Value::Complex(_, _) | Value::ComplexMatrix(_));
2244
2245    if needs_complex {
2246        return exec_index_set_complex(name, indices, rhs, env, io);
2247    }
2248
2249    match indices.len() {
2250        1 => {
2251            // Borrow env read-only to get matrix dimensions — no clone.
2252            let (total, nrows_hint, ncols_hint) = match env.get(name) {
2253                Some(Value::Matrix(m)) => (m.nrows() * m.ncols(), m.nrows(), m.ncols()),
2254                Some(Value::Scalar(_)) => (1, 1, 1),
2255                None | Some(Value::Void) => (0, 0, 0),
2256                Some(_) => {
2257                    return Err(format!(
2258                        "'{name}' is not a matrix; cannot use () indexed assignment"
2259                    ));
2260                }
2261            };
2262
2263            // Resolve the index expression while env still contains `name`, so that
2264            // self-referential indices like A(A(1)) = v work correctly.
2265            let widx = {
2266                let _owned_end;
2267                let env_end: &Env = if crate::eval::contains_end(&indices[0]) {
2268                    _owned_end = write_env_with_end(env, total);
2269                    &_owned_end
2270                } else {
2271                    env
2272                };
2273                resolve_write_dim(&indices[0], total, env_end, io)?
2274            };
2275
2276            // Determine which 0-based positions to write.
2277            let positions: Vec<usize> = match widx {
2278                WriteIdx::All => (0..total).collect(),
2279                WriteIdx::Positions(p) => p,
2280            };
2281
2282            // Determine the RHS values to write (scalar broadcasts).
2283            let rhs_vals: Vec<f64> = match &rhs {
2284                Value::Scalar(n) => vec![*n; positions.len()],
2285                Value::Matrix(m) => {
2286                    let flat: Vec<f64> = m.iter().copied().collect();
2287                    if flat.len() != positions.len() {
2288                        return Err(format!(
2289                            "Assignment dimension mismatch: {} positions but {} values",
2290                            positions.len(),
2291                            flat.len()
2292                        ));
2293                    }
2294                    flat
2295                }
2296                _ => {
2297                    return Err(
2298                        "Indexed assignment: RHS must be a numeric scalar or matrix".to_string()
2299                    );
2300                }
2301            };
2302
2303            // Find the required flat size after growth.
2304            let required = positions.iter().copied().max().map(|m| m + 1).unwrap_or(0);
2305            let required = required.max(total);
2306
2307            // Determine output shape: keep original orientation when growing a vector.
2308            let (out_rows, out_cols) = if nrows_hint == 0 || ncols_hint == 0 {
2309                // Empty → default to row vector.
2310                (1, required)
2311            } else if nrows_hint == 1 {
2312                (1, required)
2313            } else if ncols_hint == 1 {
2314                (required, 1)
2315            } else if required > total {
2316                return Err("Cannot grow a 2-D matrix with linear indexing".to_string());
2317            } else {
2318                (nrows_hint, ncols_hint)
2319            };
2320
2321            // Take ownership from env — moves the Array2 (pointer + metadata only),
2322            // avoiding the O(n) clone that would make repeated writes O(n²).
2323            let mut mat = match env.remove(name) {
2324                Some(Value::Matrix(m)) => *m,
2325                Some(Value::Scalar(n)) => Array2::from_elem((1, 1), n),
2326                None | Some(Value::Void) => Array2::zeros((0, 0)),
2327                Some(other) => {
2328                    env.insert(name.to_string(), other);
2329                    return Err(format!(
2330                        "'{name}' is not a matrix; cannot use () indexed assignment"
2331                    ));
2332                }
2333            };
2334
2335            // Grow matrix if needed, preserving existing data at correct column-major positions.
2336            if required > total || out_rows != mat.nrows() || out_cols != mat.ncols() {
2337                let mut new_mat = Array2::<f64>::zeros((out_rows, out_cols));
2338                for old_p in 0..total {
2339                    let old_row = old_p % mat.nrows().max(1);
2340                    let old_col = old_p / mat.nrows().max(1);
2341                    let new_row = old_p % out_rows;
2342                    let new_col = old_p / out_rows;
2343                    if old_row < mat.nrows() && old_col < mat.ncols() {
2344                        new_mat[[new_row, new_col]] = mat[[old_row, old_col]];
2345                    }
2346                }
2347                mat = new_mat;
2348            }
2349
2350            // Write values at column-major positions directly.
2351            for (&pos, &val) in positions.iter().zip(rhs_vals.iter()) {
2352                let row = pos % mat.nrows();
2353                let col = pos / mat.nrows();
2354                mat[[row, col]] = val;
2355            }
2356
2357            let result = if mat.nrows() == 1 && mat.ncols() == 1 {
2358                Value::Scalar(mat[[0, 0]])
2359            } else {
2360                Value::Matrix(Box::new(mat))
2361            };
2362            env.insert(name.to_string(), result);
2363        }
2364        2 => {
2365            // Borrow env read-only to get matrix dimensions — no clone.
2366            let (nrows, ncols) = match env.get(name) {
2367                Some(Value::Matrix(m)) => (m.nrows(), m.ncols()),
2368                Some(Value::Scalar(_)) => (1, 1),
2369                None | Some(Value::Void) => (0, 0),
2370                Some(_) => {
2371                    return Err(format!(
2372                        "'{name}' is not a matrix; cannot use () indexed assignment"
2373                    ));
2374                }
2375            };
2376
2377            // Resolve both index expressions while env still contains `name`.
2378            let (ridx, cidx) = {
2379                let _owned_r;
2380                let env_r: &Env = if crate::eval::contains_end(&indices[0]) {
2381                    _owned_r = write_env_with_end(env, nrows);
2382                    &_owned_r
2383                } else {
2384                    env
2385                };
2386                let _owned_c;
2387                let env_c: &Env = if crate::eval::contains_end(&indices[1]) {
2388                    _owned_c = write_env_with_end(env, ncols);
2389                    &_owned_c
2390                } else {
2391                    env
2392                };
2393                (
2394                    resolve_write_dim(&indices[0], nrows, env_r, io)?,
2395                    resolve_write_dim(&indices[1], ncols, env_c, io)?,
2396                )
2397            };
2398
2399            let rows: Vec<usize> = match ridx {
2400                WriteIdx::All => (0..nrows.max(1)).collect(),
2401                WriteIdx::Positions(p) => p,
2402            };
2403            let cols: Vec<usize> = match cidx {
2404                WriteIdx::All => (0..ncols.max(1)).collect(),
2405                WriteIdx::Positions(p) => p,
2406            };
2407
2408            let req_rows = rows
2409                .iter()
2410                .copied()
2411                .max()
2412                .map(|m| m + 1)
2413                .unwrap_or(0)
2414                .max(nrows);
2415            let req_cols = cols
2416                .iter()
2417                .copied()
2418                .max()
2419                .map(|m| m + 1)
2420                .unwrap_or(0)
2421                .max(ncols);
2422
2423            // Collect RHS values.
2424            let n_sel = rows.len() * cols.len();
2425            let rhs_vals: Vec<f64> = match &rhs {
2426                Value::Scalar(n) => vec![*n; n_sel],
2427                Value::Matrix(m) => {
2428                    let flat: Vec<f64> = m.iter().copied().collect();
2429                    if flat.len() != n_sel {
2430                        return Err(format!(
2431                            "Assignment dimension mismatch: {}×{} = {} positions but {} values",
2432                            rows.len(),
2433                            cols.len(),
2434                            n_sel,
2435                            flat.len()
2436                        ));
2437                    }
2438                    flat
2439                }
2440                _ => {
2441                    return Err(
2442                        "Indexed assignment: RHS must be a numeric scalar or matrix".to_string()
2443                    );
2444                }
2445            };
2446
2447            // Take ownership from env — moves the Array2, avoiding the O(n) clone.
2448            let mut mat = match env.remove(name) {
2449                Some(Value::Matrix(m)) => *m,
2450                Some(Value::Scalar(n)) => Array2::from_elem((1, 1), n),
2451                None | Some(Value::Void) => Array2::zeros((0, 0)),
2452                Some(other) => {
2453                    env.insert(name.to_string(), other);
2454                    return Err(format!(
2455                        "'{name}' is not a matrix; cannot use () indexed assignment"
2456                    ));
2457                }
2458            };
2459
2460            // Grow matrix if needed (fill new cells with 0).
2461            if req_rows != nrows || req_cols != ncols {
2462                let mut new_mat = Array2::<f64>::zeros((req_rows, req_cols));
2463                for r in 0..nrows {
2464                    for c in 0..ncols {
2465                        new_mat[[r, c]] = mat[[r, c]];
2466                    }
2467                }
2468                mat = new_mat;
2469            }
2470
2471            // Write values row-major over selected (row, col) pairs.
2472            let mut k = 0;
2473            for &r in &rows {
2474                for &c in &cols {
2475                    mat[[r, c]] = rhs_vals[k];
2476                    k += 1;
2477                }
2478            }
2479
2480            let result = if mat.nrows() == 1 && mat.ncols() == 1 {
2481                Value::Scalar(mat[[0, 0]])
2482            } else {
2483                Value::Matrix(Box::new(mat))
2484            };
2485            env.insert(name.to_string(), result);
2486        }
2487        _ => return Err("Indexed assignment supports at most 2 indices".to_string()),
2488    }
2489    Ok(())
2490}
2491
2492/// Complex-path indexed assignment: LHS is `ComplexMatrix` or RHS is `Complex`/`ComplexMatrix`.
2493///
2494/// A real `Matrix`/`Scalar` LHS is automatically upcast to `ComplexMatrix` when the RHS
2495/// introduces complex values (MATLAB/Octave semantics).
2496fn exec_index_set_complex(
2497    name: &str,
2498    indices: &[crate::eval::Expr],
2499    rhs: Value,
2500    env: &mut Env,
2501    io: &mut IoContext,
2502) -> Result<(), String> {
2503    /// Converts an RHS `Value` into a `Vec<Complex<f64>>` with `n_slots` elements.
2504    fn rhs_to_complex(rhs: &Value, n_slots: usize) -> Result<Vec<Complex<f64>>, String> {
2505        match rhs {
2506            Value::Scalar(n) => Ok(vec![Complex::new(*n, 0.0); n_slots]),
2507            Value::Complex(re, im) => Ok(vec![Complex::new(*re, *im); n_slots]),
2508            Value::Matrix(m) => {
2509                let flat: Vec<Complex<f64>> = m.iter().map(|&x| Complex::new(x, 0.0)).collect();
2510                if flat.len() != n_slots {
2511                    return Err(format!(
2512                        "Assignment dimension mismatch: {} positions but {} values",
2513                        n_slots,
2514                        flat.len()
2515                    ));
2516                }
2517                Ok(flat)
2518            }
2519            Value::ComplexMatrix(m) => {
2520                let flat: Vec<Complex<f64>> = m.iter().copied().collect();
2521                if flat.len() != n_slots {
2522                    return Err(format!(
2523                        "Assignment dimension mismatch: {} positions but {} values",
2524                        n_slots,
2525                        flat.len()
2526                    ));
2527                }
2528                Ok(flat)
2529            }
2530            _ => Err("Indexed assignment: RHS must be a numeric value".to_string()),
2531        }
2532    }
2533
2534    /// Takes LHS from `env`, upcasting real types to `Array2<Complex<f64>>`.
2535    fn take_as_complex(name: &str, env: &mut Env) -> Result<Array2<Complex<f64>>, String> {
2536        match env.remove(name) {
2537            Some(Value::ComplexMatrix(m)) => Ok(*m),
2538            Some(Value::Matrix(m)) => Ok(m.mapv(|x| Complex::new(x, 0.0))),
2539            Some(Value::Scalar(n)) => Ok(Array2::from_elem((1, 1), Complex::new(n, 0.0))),
2540            None | Some(Value::Void) => Ok(Array2::zeros((0, 0))),
2541            Some(other) => {
2542                env.insert(name.to_string(), other);
2543                Err(format!(
2544                    "'{name}' is not a matrix; cannot use () indexed assignment"
2545                ))
2546            }
2547        }
2548    }
2549
2550    match indices.len() {
2551        1 => {
2552            let (total, nrows_hint, ncols_hint) = match env.get(name) {
2553                Some(Value::ComplexMatrix(m)) => (m.nrows() * m.ncols(), m.nrows(), m.ncols()),
2554                Some(Value::Matrix(m)) => (m.nrows() * m.ncols(), m.nrows(), m.ncols()),
2555                Some(Value::Scalar(_)) => (1, 1, 1),
2556                None | Some(Value::Void) => (0, 0, 0),
2557                Some(_) => {
2558                    return Err(format!(
2559                        "'{name}' is not a matrix; cannot use () indexed assignment"
2560                    ));
2561                }
2562            };
2563
2564            let widx = {
2565                let _owned_end;
2566                let env_end: &Env = if crate::eval::contains_end(&indices[0]) {
2567                    _owned_end = write_env_with_end(env, total);
2568                    &_owned_end
2569                } else {
2570                    env
2571                };
2572                resolve_write_dim(&indices[0], total, env_end, io)?
2573            };
2574
2575            let positions: Vec<usize> = match widx {
2576                WriteIdx::All => (0..total).collect(),
2577                WriteIdx::Positions(p) => p,
2578            };
2579
2580            let rhs_vals = rhs_to_complex(&rhs, positions.len())?;
2581
2582            let required = positions.iter().copied().max().map(|m| m + 1).unwrap_or(0);
2583            let required = required.max(total);
2584
2585            let (out_rows, out_cols) = if nrows_hint == 0 || ncols_hint == 0 || nrows_hint == 1 {
2586                (1, required)
2587            } else if ncols_hint == 1 {
2588                (required, 1)
2589            } else if required > total {
2590                return Err("Cannot grow a 2-D matrix with linear indexing".to_string());
2591            } else {
2592                (nrows_hint, ncols_hint)
2593            };
2594
2595            let mut mat = take_as_complex(name, env)?;
2596
2597            if required > total || out_rows != mat.nrows() || out_cols != mat.ncols() {
2598                let mut new_mat = Array2::<Complex<f64>>::zeros((out_rows, out_cols));
2599                for old_p in 0..total {
2600                    let old_row = old_p % mat.nrows().max(1);
2601                    let old_col = old_p / mat.nrows().max(1);
2602                    let new_row = old_p % out_rows;
2603                    let new_col = old_p / out_rows;
2604                    if old_row < mat.nrows() && old_col < mat.ncols() {
2605                        new_mat[[new_row, new_col]] = mat[[old_row, old_col]];
2606                    }
2607                }
2608                mat = new_mat;
2609            }
2610
2611            for (&pos, &val) in positions.iter().zip(rhs_vals.iter()) {
2612                let row = pos % mat.nrows();
2613                let col = pos / mat.nrows();
2614                mat[[row, col]] = val;
2615            }
2616
2617            env.insert(name.to_string(), Value::ComplexMatrix(Box::new(mat)));
2618        }
2619        2 => {
2620            let (nrows, ncols) = match env.get(name) {
2621                Some(Value::ComplexMatrix(m)) => (m.nrows(), m.ncols()),
2622                Some(Value::Matrix(m)) => (m.nrows(), m.ncols()),
2623                Some(Value::Scalar(_)) => (1, 1),
2624                None | Some(Value::Void) => (0, 0),
2625                Some(_) => {
2626                    return Err(format!(
2627                        "'{name}' is not a matrix; cannot use () indexed assignment"
2628                    ));
2629                }
2630            };
2631
2632            let (ridx, cidx) = {
2633                let _owned_r;
2634                let env_r: &Env = if crate::eval::contains_end(&indices[0]) {
2635                    _owned_r = write_env_with_end(env, nrows);
2636                    &_owned_r
2637                } else {
2638                    env
2639                };
2640                let _owned_c;
2641                let env_c: &Env = if crate::eval::contains_end(&indices[1]) {
2642                    _owned_c = write_env_with_end(env, ncols);
2643                    &_owned_c
2644                } else {
2645                    env
2646                };
2647                (
2648                    resolve_write_dim(&indices[0], nrows, env_r, io)?,
2649                    resolve_write_dim(&indices[1], ncols, env_c, io)?,
2650                )
2651            };
2652
2653            let rows: Vec<usize> = match ridx {
2654                WriteIdx::All => (0..nrows.max(1)).collect(),
2655                WriteIdx::Positions(p) => p,
2656            };
2657            let cols: Vec<usize> = match cidx {
2658                WriteIdx::All => (0..ncols.max(1)).collect(),
2659                WriteIdx::Positions(p) => p,
2660            };
2661
2662            let req_rows = rows
2663                .iter()
2664                .copied()
2665                .max()
2666                .map(|m| m + 1)
2667                .unwrap_or(0)
2668                .max(nrows);
2669            let req_cols = cols
2670                .iter()
2671                .copied()
2672                .max()
2673                .map(|m| m + 1)
2674                .unwrap_or(0)
2675                .max(ncols);
2676
2677            let n_sel = rows.len() * cols.len();
2678            let rhs_vals = rhs_to_complex(&rhs, n_sel)?;
2679
2680            let mut mat = take_as_complex(name, env)?;
2681
2682            if req_rows != nrows || req_cols != ncols {
2683                let mut new_mat = Array2::<Complex<f64>>::zeros((req_rows, req_cols));
2684                for r in 0..nrows {
2685                    for c in 0..ncols {
2686                        if r < mat.nrows() && c < mat.ncols() {
2687                            new_mat[[r, c]] = mat[[r, c]];
2688                        }
2689                    }
2690                }
2691                mat = new_mat;
2692            }
2693
2694            let mut k = 0;
2695            for &r in &rows {
2696                for &c in &cols {
2697                    mat[[r, c]] = rhs_vals[k];
2698                    k += 1;
2699                }
2700            }
2701
2702            env.insert(name.to_string(), Value::ComplexMatrix(Box::new(mat)));
2703        }
2704        _ => return Err("Indexed assignment supports at most 2 indices".to_string()),
2705    }
2706    Ok(())
2707}