Skip to main content

ccalc_engine/
exec.rs

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