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