Skip to main content

zsh/
vm_helper.rs

1//! Shell executor state for zshrs.
2//!
3//! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4//! !!! LAST-RESORT FILE — NOT FOR NEW LOGIC !!!
5//!
6//! This file holds the `ShellExecutor` runtime state struct + VM-adjacent
7//! helpers. It is **not** the place to add zsh logic — every line here that
8//! does real shell work is a tax we pay because zshrs uses fusevm bytecode
9//! instead of C zsh's wordcode walker.
10//!
11//! **Before adding code to this file, STOP and ask:**
12//!
13//!   1. Does the C source have a fn that does this? (Check `src/zsh/Src/*.c`)
14//!      → Port it into `src/ported/<file>.rs` with line-by-line citations.
15//!        Then call the canonical fn from here.
16//!
17//!   2. Does `src/ported/` already have a port?
18//!      → Call it directly. Don't reimplement.
19//!
20//!   3. Is this purely a Rust-only state-struct accessor (getter/setter on
21//!      ShellExecutor fields, VM init plumbing, executor-context guards)?
22//!      → OK to put it here. Mark it `WARNING: RUST-ONLY HELPER` per memory
23//!        `feedback_rust_only_helpers_need_warning`.
24//!
25//! **NEVER:** reinvent paramsubst/expansion/glob/typeset/redirect/scope
26//! management here. Every one of those has a canonical port in `src/ported/`.
27//! When a bridge-side fn grows past ~30 lines of shell logic, that's a
28//! signal the work belongs in `src/ported/` — port it, don't inline.
29//!
30//! This file should be SHRINKING over time. Every PR that adds lines here
31//! should justify it; every PR that moves lines OUT to `src/ported/` is
32//! aligned with the project direction.
33//!
34//! See also: memory `feedback_no_shortcuts_in_porting`, `feedback_true_port_pattern`,
35//! `feedback_no_shellexecutor_in_ported` (the inverse direction).
36//! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
37//!
38//! **Not a port of Src/exec.c.** C zsh runs compiled programs on the native
39//! **wordcode walker** in `Src/exec.c` (`execlist` / `execpline` / `execcmd`).
40//! zshrs uses fusevm bytecode instead; the bridge lives in `src/fusevm_bridge.rs`.
41//! This file holds:
42//! - `ShellExecutor` — the runtime state struct that the VM and
43//!   every ported builtin/utility threads through
44//! - VM-adjacent helpers that read/write that state
45//!
46//! Path-wise this file lives at the crate root (`src/vm_helper`) rather
47//! than in `src/ported/` because nothing here corresponds 1:1 to a
48//! `Src/*.c` source file. `crate::ported::exec` is kept as a
49//! re-export alias so existing call-sites continue to compile.
50
51use crate::compsys::cache::CompsysCache;
52use crate::compsys::CompInitResult;
53use crate::history::HistoryEngine;
54use crate::options::ZSH_OPTIONS_SET;
55use crate::ported::builtin::{BREAKS, CONTFLAG};
56use crate::ported::math::mathevali;
57use crate::ported::modules::parameter::*;
58use crate::ported::subst::singsub;
59use crate::ported::utils::{errflag, ERRFLAG_ERROR};
60use crate::ported::zsh_h::PM_UNDEFINED;
61use crate::ported::zsh_h::WC_SIMPLE;
62use crate::ported::zsh_h::{options, MAX_OPS};
63use crate::ported::zsh_h::{PM_ARRAY, PM_HASHED, PM_INTEGER, PM_READONLY};
64use parking_lot::Mutex;
65use std::collections::HashSet;
66use std::ffi::CStr;
67use std::ffi::CString;
68use std::fs;
69use std::io::Read;
70use std::os::unix::ffi::OsStrExt;
71use std::os::unix::fs::FileTypeExt;
72use std::os::unix::fs::PermissionsExt;
73use std::os::unix::io::FromRawFd;
74use std::sync::atomic::AtomicI32;
75use std::sync::atomic::Ordering;
76use std::time::{SystemTime, UNIX_EPOCH};
77use walkdir::WalkDir;
78
79// Backward-compat re-exports for free ported recently relocated to their
80// canonical-C-file Rust modules. Existing call-sites in this file (and
81// elsewhere) still reference these unqualified.
82#[allow(unused_imports)]
83pub(crate) use crate::func_body_fmt::FuncBodyFmt;
84#[allow(unused_imports)]
85pub(crate) use crate::ported::glob::expand_glob_alternation;
86#[allow(unused_imports)]
87pub(crate) use crate::ported::hist::bufferwords as bufferwords_z_tuple;
88#[allow(unused_imports)]
89pub(crate) use crate::ported::math::{parse_assign, parse_compound, parse_pre_inc};
90#[allow(unused_imports)]
91pub use crate::ported::params::convbase as format_int_in_base;
92pub use crate::ported::params::convbase_underscore;
93#[allow(unused_imports)]
94pub(crate) use crate::ported::params::getarrvalue;
95#[allow(unused_imports)]
96pub(crate) use crate::ported::utils::base64_decode;
97#[allow(unused_imports)]
98pub(crate) use crate::ported::utils::{ispwd, printprompt4, quotedzputs};
99
100pub(crate) use crate::intercepts::intercept_matches;
101/// AOP advice type — before, after, or around.
102pub use crate::intercepts::{AdviceKind, Intercept};
103
104/// Result from background compinit thread.
105pub use crate::compinit_bg::CompInitBgResult;
106use std::io::Write;
107use std::sync::LazyLock;
108
109/// State snapshot for plugin delta computation.
110pub(crate) use crate::plugin_cache::PluginSnapshot;
111
112/// Cached compiled regexes for hot paths
113pub(crate) static REGEX_CACHE: LazyLock<Mutex<HashMap<String, Regex>>> =
114    LazyLock::new(|| Mutex::new(HashMap::with_capacity(64)));
115
116// fusevm VM bridge (extension; not a port of Src/exec.c) lives in
117// src/fusevm_bridge.rs. Re-exports below let the rest of the codebase
118// reference symbols as `crate::ported::exec::X`.
119pub(crate) use crate::fusevm_bridge::ExecutorContext;
120pub use crate::fusevm_bridge::*;
121
122/// `ZSH_VERSION` / `ZSH_PATCHLEVEL` / `ZSH_VERSION_DATE` consts
123/// generated by `build.rs` from `src/zsh/Config/version.mk`. Use
124/// `zsh_version::ZSH_VERSION` etc. at call sites so version bumps
125/// pick up automatically.
126pub mod zsh_version {
127    include!(concat!(env!("OUT_DIR"), "/zsh_version.rs"));
128}
129
130/// Match an intercept pattern against a command name or full command string.
131/// Supports: exact match, glob ("git *", "_*", "*"), or "all".
132
133/// O(1) builtin-name lookup set derived from the canonical
134/// `BUILTINS` table (`src/ported/builtin.rs:122`, the 1:1 port of
135/// `static struct builtin builtins[]` at `Src/builtin.c:40-137`).
136/// Earlier incarnation hardcoded a separate 130-entry list which
137/// drifted whenever new builtins landed in the canonical table — and
138/// shadowed the `fusevm::shell_builtins::BUILTIN_SET` u16 opcode
139/// constant. Renaming to `BUILTIN_NAMES` removes the shadow; the
140/// initialiser walks `BUILTINS` so the set stays in sync.
141///
142/// The hardcoded entries inside `LazyLock::new` below are kept as
143/// the union of: (1) names from `BUILTINS` (walked at first access),
144/// (2) zshrs daemon-side builtins from `ZSHRS_BUILTIN_NAMES`. Both
145/// arms run once at static init.
146pub(crate) static BUILTIN_NAMES: LazyLock<HashSet<String>> = LazyLock::new(|| {
147    let mut s: HashSet<String> = HashSet::new();
148    // Walk the canonical `BUILTINS` table — the 1:1 port of
149    // `static struct builtin builtins[]` at `Src/builtin.c:40-137`
150    // (ported at `src/ported/builtin.rs:122`). Every name in there is
151    // a real zsh builtin; the set stays in sync as new ports land.
152    for b in crate::ported::builtin::BUILTINS.iter() {
153        s.insert(b.node.nam.clone());
154    }
155    // Daemon-side (zshrs-specific extensions).
156    for &n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.iter() {
157        s.insert(n.to_string());
158    }
159    s
160});
161
162use crate::exec_jobs::{JobState, JobTable};
163use crate::parse::{Redirect, RedirectOp, ShellCommand, ShellWord, VarModifier, ZshParamFlag};
164use indexmap::IndexMap;
165use std::collections::HashMap;
166use std::env;
167use std::fs::{File, OpenOptions};
168use std::io;
169use std::path::{Path, PathBuf};
170use std::process::{Child, Command, Stdio};
171
172// Re-exports for call-sites that reference `crate::ported::exec::<Name>`.
173pub use crate::bash_complete::CompSpec;
174pub use crate::ported::builtin::AutoloadFlags;
175pub use crate::ported::modules::zutil::zstyle_entry;
176
177/// Snapshot of subshell-isolated state. Captured at `(` entry, restored at
178/// `)` exit. zsh subshell semantics: assignments inside `(…)` don't leak to
179/// the outer scope — and that includes `export`. zsh forks a child for the
180/// subshell so the child's env::set_var dies with the child; without a fork
181/// (zshrs runs subshells in-process for perf), we snapshot+restore the OS
182/// env table around the subshell. Otherwise `(export y=v)` would leak `y`
183/// to the parent shell, breaking every script that uses a subshell to
184/// scope an env override.
185/// Snapshot of mutable executor state across a subshell
186/// boundary.
187/// Port of the `entersubsh()` save/restore Src/exec.c does at
188/// line 1084 — captures everything that must be replaced when a
189/// `(...)` group fires.
190pub struct SubshellSnapshot {
191    /// Snapshot of `paramtab` (the C-canonical parameter store) at
192    /// subshell entry. Step 1 of the unification mirrors writes to
193    /// paramtab, so subshell-scoped assignments now show up there
194    /// too — without this snapshot, restoring only `variables` /
195    /// `arrays` / `assoc_arrays` leaks the subshell's writes to the
196    /// parent via paramtab (e.g. `x=outer; (x=inner); echo $x` returned
197    /// `inner` because paramsubst reads through paramtab).
198    pub paramtab: HashMap<String, crate::ported::zsh_h::Param>,
199    /// `paramtab_hashed_storage` field.
200    pub paramtab_hashed_storage: HashMap<String, IndexMap<String, String>>,
201    /// `positional_params` field.
202    pub positional_params: Vec<String>,
203    /// `env_vars` field.
204    pub env_vars: HashMap<String, String>,
205    /// Process working directory at subshell entry. `cd` inside the
206    /// subshell shouldn't leak to the parent; we restore on End.
207    pub cwd: Option<PathBuf>,
208    /// File-creation mask at subshell entry. zsh forks for `(...)` so
209    /// `umask` set inside dies with the child; we run subshells in
210    /// process so we must restore the mask on End. Otherwise
211    /// `umask 022; (umask 077); umask` shows 077 in the parent.
212    pub umask: u32,
213    /// Parent's traps at subshell entry. zsh's `(trap "echo X" EXIT;
214    /// true)` runs the trap when the subshell exits — BEFORE the parent
215    /// continues. Without this snapshot, the trap inherited from parent
216    /// would fire, OR a trap set inside the subshell would leak to the
217    /// parent's process exit. Restored on subshell_end after the
218    /// subshell's own EXIT trap (if any) has fired. Stores a snapshot
219    /// of `crate::ported::builtin::traps_table()` (canonical).
220    pub traps: HashMap<String, String>,
221    /// Parent's shell options at subshell entry. `(set -e)` /
222    /// `(setopt extendedglob)` mustn't leak; zsh forks the subshell
223    /// so child options die with the child. We run in-process, so we
224    /// must restore the option store on subshell_end.
225    pub opts: HashMap<String, bool>,
226    /// Parent's alias entries at subshell entry. zsh forks for
227    /// `(...)` so `(alias x=y)` inside a subshell dies with the
228    /// child and doesn't leak to the parent. zshrs runs subshells
229    /// in-process, so we must restore the alias table on
230    /// subshell_end. Bug #209 in docs/BUGS.md. Stored as a flat
231    /// Vec<(name, text)> snapshot — the underlying alias_table holds
232    /// an IndexMap<String, alias> but `alias` carries hashnode
233    /// metadata we don't need to round-trip; only name + text are
234    /// observable via `alias NAME` lookup.
235    pub aliases: Vec<(String, String)>,
236    /// Parent's shell-function table at subshell entry. C zsh's
237    /// `entersubsh` (`Src/exec.c`) forks before running the
238    /// subshell body so `(f() { ... })` defining a function dies
239    /// with the child and never leaks to the parent. zshrs runs
240    /// subshells in-process, so we must clone `shfunctab` on entry
241    /// and restore on exit. Bug #208 in docs/BUGS.md. Stored as
242    /// the full `HashMap<String, Box<shfunc>>` clone — shfunc is
243    /// `Clone` and the table snapshot is bounded by the user's
244    /// declared function set.
245    pub shfuncs:
246        std::collections::HashMap<String, Box<crate::ported::zsh_h::shfunc>>,
247    /// Parent's compiled-function chunks at subshell entry. Companion
248    /// to `shfuncs` above — `ShellExecutor.functions_compiled` is the
249    /// runtime dispatch table that `Op::CallFunction` reads through;
250    /// without restoring it, a subshell `(g() { override; })` leaves
251    /// the override bytecode chunk in place so the parent's
252    /// `g` call still runs the override after `subshell_end`
253    /// restored shfunctab. Bug #208 in docs/BUGS.md.
254    pub functions_compiled: HashMap<String, fusevm::Chunk>,
255    /// Parent's function source map at subshell entry. Companion to
256    /// `functions_compiled` so `typeset -f` / `whence` show the
257    /// parent's source after subshell exit, not the subshell's
258    /// overridden body. Bug #208 in docs/BUGS.md.
259    pub function_source: HashMap<String, String>,
260    /// Parent's modulestab `modules` map at subshell entry. zsh forks
261    /// for `(...)` so a `(zmodload zsh/X)` inside the subshell sets
262    /// MOD_INIT_B on the child's modulestab; when the child exits the
263    /// flag dies with it and the parent's modulestab is untouched.
264    /// zshrs runs subshells in-process, so a subshell `zmodload`
265    /// would otherwise flip the parent's `${modules[zsh/X]}` from
266    /// unset to "loaded". Snapshot here and restore on subshell_end.
267    /// Bug #210 in docs/BUGS.md. Stored as `(name → flags)`
268    /// since `module` struct doesn't derive Clone (LinkList/
269    /// Linkedmod) — and the only thing `zmodload` mutates that
270    /// affects introspection is the flags bitmask (MOD_INIT_B
271    /// for loaded, MOD_UNLOAD for unloaded).
272    pub modules: HashMap<String, i32>,
273    /// Parent's THINGYTAB (ZLE widget registry) at subshell entry.
274    /// zsh forks for `(...)` so `zle -N w f` / `zle -D w` inside the
275    /// subshell flip widget bindings only in the child; when the
276    /// child exits the parent's widget table is untouched. zshrs runs
277    /// subshells in-process so a subshell's `zle -D w` would
278    /// otherwise unbind the parent's widget. Bug #453 in docs/BUGS.md.
279    pub thingytab: HashMap<String, crate::ported::zle::zle_thingy::Thingy>,
280    /// Parent's KEYMAPNAMTAB (named keymap registry) at subshell
281    /// entry. Same fork-copy semantics as THINGYTAB — a subshell's
282    /// `bindkey -N km` / `bindkey -D km` mutates only the child's
283    /// keymap registry in C zsh. Bug #454 in docs/BUGS.md.
284    pub keymapnamtab: HashMap<String, crate::ported::zle::zle_keymap::KeymapName>,
285}
286
287#[allow(unused_imports)]
288pub(crate) use crate::ported::pattern::{
289    extract_numeric_ranges, numeric_range_contains, numeric_ranges_to_star,
290};
291
292/// Top-level shell executor state.
293/// Port of the file-static globals + `Estate` chain Src/exec.c
294/// uses — `execlist()` (line 1349) drives every list, with
295/// `execpline()` (line 1668), `execpline2()` (line 1991),
296/// `execsimple()` (line 1290), and the per-`WC_*` `execfuncs[]`
297/// table (line 268) feeding off it. The Rust port collapses
298/// everything into one `ShellExecutor` so we don't need
299/// thread-local globals.
300pub struct ShellExecutor {
301    /// Mirrors C zsh's file-static `scriptname` (Src/init.c). Used by
302    /// PS4's `%N` and the `scriptname:line: …` prefix on error
303    /// messages. Inside a function, MUTATES to the function name
304    /// (Src/exec.c:5903 `scriptname = dupstring(name)`). Init sets
305    /// this in `-c` mode to the binary basename per init.c:479; when
306    /// sourcing a file via `source`/`bin_dot`, it becomes the
307    /// resolved file path; otherwise it falls back through `$0` →
308    /// `$ZSH_ARGZERO`.
309    pub scriptname: Option<String>,
310    /// Mirrors C zsh's `scriptfilename` global (Src/init.c). Tracks
311    /// the FILE BEING READ (vs scriptname which tracks the active
312    /// function name during a call). Used by PS4's `%x` and certain
313    /// error-message prefixes that want the file location, NOT the
314    /// function name.
315    ///
316    /// At -c-mode init, scriptname == scriptfilename == "zsh"
317    /// (Src/init.c:479). When entering a function, ONLY scriptname
318    /// updates (exec.c:5903); scriptfilename stays at the outer
319    /// file path, so `%x` inside a function still shows the file
320    /// the function was called from.
321    pub scriptfilename: Option<String>,
322    /// Stack of subshell-state snapshots. Each `(…)` subshell pushes a copy
323    /// of variables/arrays/assoc_arrays at entry and pops/restores at exit.
324    /// Without this, `(x=inner; …); echo $x` shows `inner` instead of the
325    /// outer-scope value.
326    pub subshell_snapshots: Vec<SubshellSnapshot>,
327    /// Stack of inline-assignment scopes — `X=foo Y=bar cmd` pushes
328    /// a frame at the start, the assigns run inside it, and `cmd`
329    /// returns into END_INLINE_ENV which restores both shell-vars
330    /// and process-env to the pre-frame state. Each frame holds
331    /// `(name, prev_var, prev_env)` per assigned name. zsh's
332    /// equivalent is the parser-level "addvar" list executed under
333    /// `addvars()` (Src/exec.c) right before the command exec.
334    pub inline_env_stack: Vec<Vec<(String, Option<String>, Option<String>)>>,
335    /// Set by `expand_glob`'s no-match arm when `nomatch` is on (zsh
336    /// default) — instructs the simple-command dispatcher to skip
337    /// executing the current command, set last_status=1, and continue
338    /// to the next command in the script. zsh's bin_simple uses the
339    /// errflag global for the same role: error printed, command
340    /// suppressed, script continues. Without this we were calling
341    /// `process::exit(1)` deep inside expand_glob, killing the whole
342    /// shell on any unmatched glob even with multi-statement input.
343    /// `Cell` because the no-match site only has a `&self` borrow.
344    pub current_command_glob_failed: std::cell::Cell<bool>,
345    /// `jobs` field.
346    pub jobs: JobTable,
347    /// `fpath` field.
348    pub fpath: Vec<PathBuf>,
349    /// `history` field.
350    pub history: Option<HistoryEngine>,
351    pub(crate) process_sub_counter: u32,
352    pub completions: HashMap<String, CompSpec>, // command -> completion spec
353    pub zstyles: Vec<zstyle_entry>,             // zstyle configurations
354    /// Current function scope depth for `local` tracking.
355    pub local_scope_depth: usize,
356    /// Last arg of the currently-running command, deferred into `$_`
357    /// when the next command dispatches. zsh: `$_` reflects the LAST
358    /// command's last arg, so `echo hi; echo $_` prints `hi` (not the
359    /// `_` arg of `echo $_` itself). Promoted in `pop_args` and
360    /// `host.exec` before the command's args are read.
361    pub pending_underscore: Option<String>,
362    /// True while expanding inside a double-quoted context. Set by
363    /// `BUILTIN_EXPAND_TEXT` mode 1 around `expand_string` calls.
364    /// Used by parameter-flag application to suppress array-only flags
365    /// (`(o)`/`(O)`/`(n)`/`(i)`/`(M)`/`(u)`) — zsh's behaviour: those
366    /// flags only fire in array context.
367    pub in_dq_context: u32,
368    /// True (>0) while expanding the RHS of a scalar assignment.
369    /// Direct port of zsh's `PREFORK_SINGLE` bit set by
370    /// Src/exec.c::addvars line 2546 (`prefork(vl, isstr ?
371    /// (PREFORK_SINGLE|PREFORK_ASSIGN) : PREFORK_ASSIGN, ...)`).
372    /// Subst_port's paramsubst reads this via `ssub` and suppresses
373    /// `(f)` / `(s:STR:)` / `(0)` / `(z)` split flags per
374    /// Src/subst.c:1759 + 3902, so `y="${(f)x}"` preserves x's
375    /// original separator (newlines) instead of re-joining with
376    /// IFS-first-char (space).
377    pub in_scalar_assign: u32,
378    /// `profiling_enabled` field.
379    pub profiling_enabled: bool,
380    // compsys - completion system cache
381    /// `compsys_cache` field.
382    pub compsys_cache: Option<CompsysCache>,
383    // Background compinit — receiver for async fpath scan result
384    /// `compinit_pending` field.
385    pub compinit_pending: Option<(
386        std::sync::mpsc::Receiver<CompInitBgResult>,
387        std::time::Instant,
388    )>,
389    // Plugin source cache — stores side effects of source/. in SQLite
390    /// `plugin_cache` field.
391    pub plugin_cache: Option<crate::plugin_cache::PluginCache>,
392    // cdreplay - deferred compdef calls for zinit turbo mode
393    /// `deferred_compdefs` field.
394    pub deferred_compdefs: Vec<Vec<String>>,
395    // Control flow signals
396    pub returning: Option<i32>, // Set by return builtin, cleared after function returns
397    /// zsh compatibility mode - use .zcompdump, fpath scanning, etc.
398    /// Also serves as the `--zsh` parity-test flag: caches off, daemon
399    /// off, plugin_cache replay off so every `source` re-runs the file
400    /// fresh per Src/builtin.c:6080-6123 bin_dot semantics.
401    pub zsh_compat: bool,
402    /// bash compatibility mode (`--bash`). Same parity-mode semantics
403    /// as `zsh_compat` (caches/daemon/replay off) plus bash-specific
404    /// behavior tweaks where bash 5.x diverges from zsh — e.g.
405    /// `BASH_VERSION` / `BASH_REMATCH` exposed, `[[ =~ ]]` populates
406    /// match indices the bash way, mapfile/readarray as builtins.
407    pub bash_compat: bool,
408    /// POSIX sh strict mode — no SQLite, no worker pool, no zsh extensions
409    pub posix_mode: bool,
410    /// Worker thread pool for background tasks (compinit, process subs, etc.)
411    pub worker_pool: std::sync::Arc<crate::worker::WorkerPool>,
412    /// AOP intercept table: command/function name → advice chain.
413    /// Glob patterns supported (e.g. "git *", "*").
414    pub intercepts: Vec<Intercept>,
415    /// Async job handles: id → receiver for (status, stdout)
416    pub async_jobs: HashMap<u32, crossbeam_channel::Receiver<(i32, String)>>,
417    /// Next async job ID
418    pub next_async_id: u32,
419    /// Per-scope saved-fd stacks for `Op::WithRedirectsBegin/End`. Each entry
420    /// is a Vec of (fd, saved_dup_fd) pairs taken from `dup(fd)` before the
421    /// redirect was applied; `with_redirects_end` `dup2`s them back and closes.
422    pub redirect_scope_stack: Vec<Vec<(i32, i32)>>,
423    /// Per-scope MULTIOS tee state. Each entry is `(pipe_write_fd,
424    /// JoinHandle)`: the pipe write-end currently dup2'd onto the
425    /// command's fd, and the splitter thread that reads from the
426    /// pipe read-end and writes to every collected target. Closed
427    /// + joined by `host_redirect_scope_end` BEFORE the saved fds
428    /// are restored so the splitter drains every byte the body
429    /// wrote into the pipe. Bug #36 in docs/BUGS.md.
430    pub multios_scope_stack: Vec<Vec<(i32, std::thread::JoinHandle<()>)>>,
431    /// Set by `host_apply_redirect` when a redirect target couldn't be
432    /// opened (permission denied, no such directory, etc). The next
433    /// builtin/command checks this at entry and short-circuits with
434    /// status 1 instead of running. Mirrors zsh's "command skip" on
435    /// redirect failure.
436    pub redirect_failed: bool,
437    /// Compiled function bodies — name → fusevm::Chunk. Populated by
438    /// `BUILTIN_REGISTER_FUNCTION` (from `FunctionDef` lowering) and lazily by
439    /// `ZshrsHost::call_function` when only an AST exists in `self.functions`
440    /// (autoloaded, sourced, etc.). `Op::CallFunction` dispatches through here.
441    pub functions_compiled: HashMap<String, fusevm::Chunk>,
442    /// Canonical source text for functions. Populated by autoload paths (the
443    /// raw file/cache body), runtime FuncDef compile (the parsed source span),
444    /// and `unfunction` removal. Used by introspection (`whence`, `which`,
445    /// `typeset -f`) instead of reconstructing from a ShellCommand AST. When a
446    /// function is in `functions_compiled` but not here, introspection falls
447    /// back to `text::getpermtext(self.functions[name])`.
448    pub function_source: HashMap<String, String>,
449    /// `first_body_line - 1` per compiled function — matches inner
450    /// `ZshCompiler::lineno_offset` / zsh `funcstack->flineno` combined with
451    /// relative `$LINENO` for Src/prompt.c:909 `%I`.
452    pub function_line_base: HashMap<String, i64>,
453    /// `scriptfilename` when `BUILTIN_REGISTER_COMPILED_FN` ran — `%x` inside
454    /// a function (prompt.c:931-934) reads `funcstack->filename`.
455    pub function_def_file: HashMap<String, Option<String>>,
456    /// Innermost-last stack of active compiled-call frames for prompt `%I` / `%x`.
457    pub prompt_funcstack: Vec<(String, i64, Option<String>)>,
458    /// Scalar→(array, sep) tie table set up by `typeset -T VAR var [SEP]`.
459    /// Array→(scalar, sep) reverse-tie table. Used by BUILTIN_SET_ARRAY to
460    /// join the array elements with `sep` and mirror to the scalar side.
461    pub tied_array_to_scalar: HashMap<String, (String, String)>,
462
463    // ── ztest framework counters (extensions/ztest.rs) ──────────────────
464    //
465    // Mirrors strykelang's per-VMHelper test counters
466    // (strykelang/builtins.rs:22292-22308 + builtins.rs::test_pass/_fail/_skip,
467    //  builtins.rs::test_pass_count etc.). Each `zassert_*` builtin bumps
468    // the per-block counter; `ztest_run` rolls per-block into _total and
469    // resets the per-block side, so a single test file with multiple
470    // `ztest_run` calls can reuse the counters. The worker-pool runner in
471    // src/extensions/ztest.rs reads pass_total+pass_count and
472    // fail_total+fail_count after `execute_script` returns for the cumulative
473    // numbers (strykelang/cli_runners.rs:115-118). `ztest_run_failed` is a
474    // sticky bool the runner reads so a test that asserts but then exits
475    // 0 still flags as failed. `ztest_suppress_stdout` matches
476    // VMHelper::suppress_stdout — the runner sets it inside the forked
477    // grandchild so the per-test stderr capture stays clean.
478    /// Per-block pass count (reset by `ztest_run`).
479    pub ztest_pass_count: std::sync::atomic::AtomicUsize,
480    /// Per-block fail count (reset by `ztest_run`).
481    pub ztest_fail_count: std::sync::atomic::AtomicUsize,
482    /// Per-block skip count (reset by `ztest_run`).
483    pub ztest_skip_count: std::sync::atomic::AtomicUsize,
484    /// Cumulative pass total across the run.
485    pub ztest_pass_total: std::sync::atomic::AtomicUsize,
486    /// Cumulative fail total across the run.
487    pub ztest_fail_total: std::sync::atomic::AtomicUsize,
488    /// Cumulative skip total across the run.
489    pub ztest_skip_total: std::sync::atomic::AtomicUsize,
490    /// Sticky failure flag — set by any `ztest_run` that observed fails;
491    /// the CLI runner reads this so a test that asserts then exits 0
492    /// still counts as a failed file.
493    pub ztest_run_failed: std::sync::atomic::AtomicBool,
494    /// Suppress per-assertion `✓`/`✗` lines on stderr. Set by the worker
495    /// runner inside the forked child when it has already redirected
496    /// fd 2 to a tmp file (we still want the lines, but only after the
497    /// runner re-emits them under print_lock to avoid line-tearing).
498    pub ztest_suppress_stdout: bool,
499}
500
501impl ShellExecutor {
502    /// Set a scalar parameter via the canonical `paramtab`
503    /// (`Src/params.c:3350 setsparam`). The single store.
504    pub fn set_scalar(&mut self, name: String, value: String) {
505        setsparam(&name, &value); // c:params.c:3350
506    }
507
508    /// Read positional parameters from canonical `PPARAMS`
509    /// `Mutex<Vec<String>>` (Src/init.c:pparams). The single store.
510    pub fn pparams(&self) -> Vec<String> {
511        crate::ported::builtin::PPARAMS
512            .lock()
513            .map(|p| p.clone())
514            .unwrap_or_default()
515    }
516
517    /// Write positional parameters to canonical `PPARAMS`.
518    pub fn set_pparams(&mut self, params: Vec<String>) {
519        if let Ok(mut p) = crate::ported::builtin::PPARAMS.lock() {
520            *p = params;
521        }
522    }
523
524    /// Read PM_* type flags from the paramtab Param entry. Used by
525    /// SET_VAR / `+=` arms (case-fold, integer-add, readonly guard).
526    /// Returns 0 when the name isn't in paramtab. Mirrors the C
527    /// source's direct `pm->node.flags & PM_INTEGER` checks.
528    pub fn param_flags(&self, name: &str) -> i32 {
529        paramtab()
530            .read()
531            .ok()
532            .and_then(|t| t.get(name).map(|p| p.node.flags))
533            .unwrap_or(0)
534    }
535
536    /// `readonly` / `typeset -r` / read-only-by-design (LINENO, PPID,
537    /// $$, $?, $!, ...) — match user-side rejection in C's
538    /// assignstrvalue at `Src/params.c:2699-2703` which gates on
539    /// `pm->node.flags & PM_READONLY` where the IPDEF4 family declares
540    /// `PM_READONLY_SPECIAL = PM_SPECIAL | PM_READONLY | PM_RO_BY_DESIGN`
541    /// (both bits set together). zshrs's special_params table carries
542    /// PM_RO_BY_DESIGN alone for IPDEF4 entries so internal direct-write
543    /// paths (BUILTIN_SET_LINENO bypasses via pm.u_val) don't trip the
544    /// readonly guard. User-facing checks must accept either bit. Bug
545    /// #418-family / test_lineno_intrinsic_readonly.
546    pub fn is_readonly_param(&self, name: &str) -> bool {
547        let flags = self.param_flags(name) as u32;
548        (flags & (PM_READONLY | crate::ported::zsh_h::PM_RO_BY_DESIGN)) != 0
549    }
550
551    /// Most-recent-command exit status. Reads canonical
552    /// `builtin::LASTVAL` AtomicI32 (`Src/builtin.c:6443`).
553    pub fn last_status(&self) -> i32 {
554        crate::ported::builtin::LASTVAL.load(Ordering::Relaxed)
555    }
556
557    /// Write the most-recent-command exit status. The canonical
558    /// store is `builtin::LASTVAL`; this is the single setter.
559    /// Used everywhere `$?` / `%?` / errexit / ZERR trap read.
560    pub fn set_last_status(&mut self, status: i32) {
561        crate::ported::builtin::LASTVAL.store(status, Ordering::Relaxed);
562    }
563
564    /// Set an indexed array parameter via canonical paramtab
565    /// (`setaparam`, `Src/params.c:3595`). The single store.
566    pub fn set_array(&mut self, name: String, value: Vec<String>) {
567        setaparam(&name, value); // c:params.c:3595
568    }
569
570    /// Set an associative array parameter via canonical
571    /// `sethparam` (`Src/params.c:3602`). The single store.
572    pub fn set_assoc(&mut self, name: String, value: IndexMap<String, String>) {
573        let mut flat: Vec<String> = Vec::with_capacity(value.len() * 2);
574        for (k, v) in &value {
575            flat.push(k.clone());
576            flat.push(v.clone());
577        }
578        sethparam(&name, flat); // c:params.c:3602
579    }
580
581    /// Read a scalar parameter. Mirrors C `getsparam` at
582    /// `Src/params.c:3076` — reads through paramtab, falls back to
583    /// special-var hooks and env.
584    pub fn scalar(&self, name: &str) -> Option<String> {
585        getsparam(name)
586    }
587
588    /// Read an array parameter via canonical `getaparam`
589    /// (`Src/params.c:3101`).
590    pub fn array(&self, name: &str) -> Option<Vec<String>> {
591        getaparam(name)
592    }
593
594    /// Read an associative array parameter from canonical
595    /// `paramtab_hashed_storage`. Mirrors C `gethparam` at
596    /// `Src/params.c:3115` — returns the typed `IndexMap`.
597    pub fn assoc(&self, name: &str) -> Option<IndexMap<String, String>> {
598        paramtab_hashed_storage()
599            .lock()
600            .ok()
601            .and_then(|m| m.get(name).cloned())
602    }
603
604    /// Test whether a scalar parameter exists in paramtab.
605    /// Mirrors the C `paramtab->getnode(name) != NULL` check.
606    pub fn has_scalar(&self, name: &str) -> bool {
607        getsparam(name).is_some()
608    }
609
610    /// Test whether an array parameter exists in paramtab. Routes
611    /// through canonical `getaparam` (PM_TYPE check + digit-first-name
612    /// rejection).
613    pub fn has_array(&self, name: &str) -> bool {
614        getaparam(name).is_some()
615    }
616
617    /// Test whether an associative array parameter exists. Reads
618    /// canonical `paramtab_hashed_storage` (Src/params.c hashed
619    /// PM_HASHED slot).
620    pub fn has_assoc(&self, name: &str) -> bool {
621        paramtab_hashed_storage()
622            .lock()
623            .ok()
624            .map(|m| m.contains_key(name))
625            .unwrap_or(false)
626    }
627
628    /// Unset an associative array parameter via canonical
629    /// `unsetparam` (Src/params.c:3819) — PM_READONLY rejection,
630    /// stdunsetfn dispatch, env clear. Also clears the zshrs-side
631    /// `paramtab_hashed_storage` parallel IndexMap shadow.
632    pub fn unset_assoc(&mut self, name: &str) {
633        unsetparam(name);
634        let _ = paramtab_hashed_storage()
635            .lock()
636            .ok()
637            .as_deref_mut()
638            .map(|m| m.remove(name));
639    }
640
641    /// Read a regular (non-global) alias value. Reads canonical
642    /// `aliastab` (Src/hashtable.c:1186). Filters out aliases that
643    /// have the ALIAS_GLOBAL flag set so the regular-alias slot is
644    /// distinct from the global-alias slot, mirroring C's two
645    /// separate dispatch paths via `aliasflags` checks.
646    pub fn alias(&self, name: &str) -> Option<String> {
647        let tab = crate::ported::hashtable::aliastab_lock().read().ok()?;
648        let a = tab.get(name)?;
649        if (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0 {
650            None
651        } else {
652            Some(a.text.clone())
653        }
654    }
655
656    /// Set a regular alias. Writes canonical aliastab with
657    /// ALIAS_GLOBAL bit cleared.
658    pub fn set_alias(&mut self, name: String, value: String) {
659        if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
660            tab.add(crate::ported::hashtable::createaliasnode(&name, &value, 0));
661        }
662    }
663
664    /// Set a global alias (`alias -g`). Writes canonical aliastab
665    /// with ALIAS_GLOBAL bit set.
666    pub fn set_global_alias(&mut self, name: String, value: String) {
667        if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
668            tab.add(crate::ported::hashtable::createaliasnode(
669                &name,
670                &value,
671                crate::ported::zsh_h::ALIAS_GLOBAL as u32,
672            ));
673        }
674    }
675
676    /// Set a suffix alias (`alias -s ext=cmd`). Writes canonical
677    /// sufaliastab with ALIAS_SUFFIX node flag — mirrors C
678    /// Src/builtin.c:4480-4481 (`flags1 |= ALIAS_SUFFIX; ht =
679    /// sufaliastab;`) → c:4527 (`createaliasnode(value, flags1)`).
680    /// Without ALIAS_SUFFIX in node.flags, `${saliases[k]}` /
681    /// `${(k)saliases}` introspection (parameter.c:1953/2018) fails
682    /// because both paths strict-equality-match flags == ALIAS_SUFFIX.
683    pub fn set_suffix_alias(&mut self, name: String, value: String) {
684        if let Ok(mut tab) = crate::ported::hashtable::sufaliastab_lock().write() {
685            tab.add(crate::ported::hashtable::createaliasnode(
686                &name,
687                &value,
688                crate::ported::zsh_h::ALIAS_SUFFIX as u32,
689            ));
690        }
691    }
692
693    /// Snapshot the alias map as a sorted `Vec<(name, value)>`,
694    /// only entries WITHOUT the ALIAS_GLOBAL flag (regular aliases).
695    pub fn alias_entries(&self) -> Vec<(String, String)> {
696        if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
697            tab.iter_sorted()
698                .into_iter()
699                .filter(|(_, a)| (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) == 0)
700                .map(|(k, a)| (k.clone(), a.text.clone()))
701                .collect()
702        } else {
703            Vec::new()
704        }
705    }
706
707    /// Snapshot the global-alias entries (ALIAS_GLOBAL flag set).
708    pub fn global_alias_entries(&self) -> Vec<(String, String)> {
709        if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
710            tab.iter_sorted()
711                .into_iter()
712                .filter(|(_, a)| (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0)
713                .map(|(k, a)| (k.clone(), a.text.clone()))
714                .collect()
715        } else {
716            Vec::new()
717        }
718    }
719
720    /// Snapshot the suffix-alias entries.
721    pub fn suffix_alias_entries(&self) -> Vec<(String, String)> {
722        if let Ok(tab) = crate::ported::hashtable::sufaliastab_lock().read() {
723            tab.iter_sorted()
724                .into_iter()
725                .map(|(k, a)| (k.clone(), a.text.clone()))
726                .collect()
727        } else {
728            Vec::new()
729        }
730    }
731
732    /// Unset an array parameter. Direct port of `unsetparam_pm` for
733    /// a PM_ARRAY Param. Mirrors are kept for now while the field
734    /// transitions.
735    /// Unset an array parameter via canonical `unsetparam`
736    /// (Src/params.c:3819). Routes through the C-faithful port
737    /// that runs PM_NAMEREF skip + PM_READONLY rejection via
738    /// unsetparam_pm + stdunsetfn dispatch + pm.old scope restore.
739    /// Inline `tab.remove(name)` skipped all four.
740    pub fn unset_array(&mut self, name: &str) {
741        unsetparam(name);
742    }
743
744    /// Unset a scalar parameter via canonical `unsetparam`. Same
745    /// C-faithful path as `unset_array`; the C `unsetparam` itself
746    /// is type-agnostic and dispatches through PM_TYPE inside.
747    pub fn unset_scalar(&mut self, name: &str) {
748        unsetparam(name);
749    }
750    /// `new` — see implementation.
751    pub fn new() -> Self {
752        tracing::debug!("ShellExecutor::new() initializing");
753
754        // Validate the inherited $PWD against the real cwd before any
755        // builtin reads it as a logical-path base. Direct port of zsh's
756        // ispwd() at src/zsh/Src/utils.c:809-829: $PWD is honored only
757        // when it (a) is absolute, (b) stat's to the same dev+inode as
758        // ".", and (c) contains no `.`/`..` components. Otherwise zsh
759        // resets it to getcwd() (init.c:1247-1253).
760        //
761        // Without this check, a child process that inherits $PWD from
762        // a parent run in a different directory (cargo test setting
763        // current_dir(/tmp) but leaking PWD=/project/root) sees the
764        // stale PWD and `cd .` later snaps the real cwd to wherever
765        // PWD points, escaping the parent's sandbox. ztst harnesses
766        // hit this and polluted the project root with test artifacts.
767        if let Ok(pwd_env) = env::var("PWD") {
768            let valid = ispwd(&pwd_env);
769            if !valid {
770                if let Ok(real) = env::current_dir() {
771                    env::set_var("PWD", &real);
772                }
773            }
774        } else if let Ok(real) = env::current_dir() {
775            env::set_var("PWD", &real);
776        }
777
778        // Initialize fpath from FPATH env var or use defaults
779        let fpath = env::var("FPATH")
780            .unwrap_or_default()
781            .split(':')
782            .filter(|s| !s.is_empty())
783            .map(PathBuf::from)
784            .collect();
785
786        let history = HistoryEngine::new().ok();
787
788        // Seed canonical OPTS_LIVE with defaults BEFORE any setsparam
789        // call. assignstrvalue early-returns when `unset(EXECOPT)`
790        // (c:2701 guard); without the option table populated, EXECOPT
791        // reads false and every paramtab write below is a silent no-op.
792        if opt_state_len() == 0 {
793            for (k, v) in Self::default_options() {
794                opt_state_set(&k, v);
795            }
796        }
797
798        // Standard zsh scalar param defaults — direct port of
799        // `createparamtable` (Src/params.c:817-988) + the `setupvals`
800        // tail. Writes through canonical `setsparam` (Src/params.c:3350).
801        //
802        // c:params.c:972-973 — ZSH_VERSION / ZSH_PATCHLEVEL.
803        // `zsh_version::ZSH_VERSION` (emitted by build.rs from the
804        // vendored `Config/version.mk`) is the development snapshot
805        // tag `5.9.0.3-test`; shipped zsh binaries report the clean
806        // release form (`5.9`). Bug #73 in docs/BUGS.md — cross-shell
807        // scripts that gate on `[[ $ZSH_VERSION = 5.9 ]]` or split on
808        // `.` expecting MAJOR.MINOR break on the `-test` suffix.
809        //
810        // Use the cleaned `patchlevel::ZSH_VERSION` here ("5.9") and
811        // surface the full snapshot tag as `$ZSHRS_VERSION` for
812        // zshrs-specific identity checks.
813        setsparam(
814            "ZSH_VERSION",
815            crate::ported::patchlevel::ZSH_VERSION,
816        );
817        // c:Src/params.c:43 + Src/patchlevel.h — `ZSH_PATCHLEVEL` is
818        // a git-describe-style identifier (`zsh-MAJOR.MINOR-N-gHASH`)
819        // of the upstream commit zshrs targets. `build.rs` emits
820        // "unknown" because the vendored zsh tarball doesn't ship a
821        // CUSTOM_PATCHLEVEL define; use the canonical const in
822        // `patchlevel.rs` instead (snapshot of `src/zsh/Src/patchlevel.h`).
823        // Bug #90 in docs/BUGS.md — scripts that fingerprint by
824        // $ZSH_PATCHLEVEL fell to the wildcard arm under "unknown".
825        setsparam(
826            "ZSH_PATCHLEVEL",
827            crate::ported::patchlevel::ZSH_PATCHLEVEL,
828        );
829        setsparam(
830            "ZSHRS_VERSION",
831            crate::ported::patchlevel::ZSHRS_VERSION,
832        );
833        setsparam("ZSH_NAME", "zsh");
834        // c:params.c:971 — ZSH_ARGZERO from `posixzero` (Src/init.c:271).
835        // The bin entrypoint overrides this with the script path for
836        // -c / runscript invocations.
837        //
838        // In --zsh parity mode, the parity tests compare zshrs's
839        // `$ZSH_ARGZERO` to the system zsh's value (an absolute path
840        // to the zsh binary, e.g. `/opt/homebrew/bin/zsh`). zshrs's
841        // own argv[0] is the zshrs binary path, which diverges. Probe
842        // the system zsh install location the same way `bins/zshrs.rs`
843        // does for `$0` and use that. Falls back to argv[0] when no
844        // system zsh is on the candidate list.
845        let argzero_default = if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
846            let candidates = [
847                "/opt/homebrew/bin/zsh",
848                "/usr/local/bin/zsh",
849                "/bin/zsh",
850                "/usr/bin/zsh",
851            ];
852            candidates
853                .iter()
854                .find(|p| std::path::Path::new(p).exists())
855                .map(|p| p.to_string())
856                .unwrap_or_else(|| env::args().next().unwrap_or_else(|| "zsh".to_string()))
857        } else {
858            env::args().next().unwrap_or_else(|| "zsh".to_string())
859        };
860        setsparam("ZSH_ARGZERO", &argzero_default);
861        setsparam("WORDCHARS", "*?_-.[]~=/&;!#$%^(){}<>");
862        let shlvl = env::var("SHLVL")
863            .ok()
864            .and_then(|v| v.parse::<i32>().ok())
865            .map(|n| (n + 1).to_string())
866            .unwrap_or_else(|| "1".to_string());
867        setsparam("SHLVL", &shlvl);
868        // POSIX/zsh default IFS: space + tab + newline + NUL.
869        setsparam("IFS", " \t\n\0");
870        // POSIX getopts: OPTIND starts at 1.
871        setsparam("OPTIND", "1");
872        // Note: OPTERR is NOT pre-initialised. zsh leaves it unset
873        // even after `getopts` calls (verified: `getopts ":a" opt -a`
874        // does not set it). It's a user-writable variable that
875        // starts unset. Bug #150 in docs/BUGS.md.
876        // zsh wipes inherited `$_` (unlike bash).
877        setsparam("_", "");
878        // c:params.c:5064 — histchars derives from bangchar+hatchar+
879        // hashchar (defaults `!`, `^`, `#`). At init the special
880        // entry may not exist yet — fall back to the literal default.
881        let histchars_val = paramtab()
882            .read()
883            .ok()
884            .and_then(|t| {
885                t.get("histchars")
886                    .or_else(|| t.get("HISTCHARS"))
887                    .map(|pm| histcharsgetfn(pm))
888            })
889            .unwrap_or_else(|| "!^#".to_string());
890        setsparam("histchars", &histchars_val);
891        // c:Src/init.c:1186-1193 — default prompt strings. zsh sets
892        // PS4 to "+%N:%i> " for ZSH emulation ("+ " for KSH/SH).
893        // Without seeding, PS4 reads empty and `set -x` output has
894        // no prefix at all. Bug #92 in docs/BUGS.md.
895        //
896        // C zsh runs createparamtable's env-import loop (c:893-924)
897        // BEFORE init.c:1186 fires, so an exported $PS4 in the parent
898        // env wins over the default seed. zshrs's env import happens
899        // further down in ShellExecutor::new() (at the createparamtable
900        // call site), so getsparam() reads None here even when env has
901        // a value, and the default would clobber the user's PS4.
902        //
903        // Additional wrinkle: C zsh's PROMPT / PROMPT2 / PROMPT3 /
904        // PROMPT4 params are ALIASES for PS1..PS4 (Src/params.c:381,
905        // 415-421 — both IPDEF7R entries bind to the same `prompt*`
906        // global). So `export PROMPT4=...` in the parent env sets the
907        // shared global, and `$PS4` reads the same string. The user's
908        // interactive shell exports PROMPT4 (the form zsh's prompt
909        // theme system uses), so when zshrs -x runs, PROMPT4 is in
910        // env but PS4 is not. Without aliasing in the env-probe step,
911        // zshrs seeds default PS4 and ignores the user's customised
912        // prefix.
913        //
914        // Probe env::var directly for the name AND its alias; first
915        // non-empty wins. Only fall through to the default seed when
916        // every candidate is empty. Mirrors C zsh's behavior without
917        // reshuffling the rest of new(). Bug: `zshrs -x` ignored the
918        // user's custom PS4/PROMPT4 unless re-forwarded with
919        // `PS4=$PROMPT4 zshrs -x`.
920        let seed_prompt = |name: &str, alias: Option<&str>, default: &str| {
921            let cur = crate::ported::params::getsparam(name);
922            let have_param = cur.as_deref().map_or(false, |s| !s.is_empty());
923            if have_param {
924                return;
925            }
926            // Probe primary name first, then the C-side alias.
927            for candidate in std::iter::once(name).chain(alias.into_iter()) {
928                if let Ok(env_val) = std::env::var(candidate) {
929                    if !env_val.is_empty() {
930                        setsparam(name, &env_val);
931                        return;
932                    }
933                }
934            }
935            setsparam(name, default);
936        };
937        seed_prompt("PS4", Some("PROMPT4"), "+%N:%i> ");
938        // c:Src/init.c:1188-1189 — `prompt = ztrdup("%m%# "); prompt2
939        // = ztrdup("%_> ");` for the interactive primary/secondary
940        // prompts. PS1 may be reset by the prompt-theme layer; only
941        // seed when the slot is empty so any prior theme write wins.
942        seed_prompt("PS1", Some("PROMPT"), "%m%# ");
943        seed_prompt("PS2", Some("PROMPT2"), "%_> ");
944        // c:Src/init.c:1191 — `prompt3 = ztrdup("?# ");`
945        seed_prompt("PS3", Some("PROMPT3"), "?# ");
946        // c:Src/init.c:1194 — `sprompt = ztrdup("zsh: correct '%R'
947        // to '%r' [nyae]? ");` — spelling-correction prompt.
948        seed_prompt("SPROMPT", None, "zsh: correct '%R' to '%r' [nyae]? ");
949        // c:Src/params.c:417-422 — `PROMPT*` aliases for `PS*`.
950        // C zsh's IPDEF7("PROMPT", &prompt), IPDEF7("PROMPT2",
951        // &prompt2), IPDEF7("PROMPT3", &prompt3), IPDEF7("PROMPT4",
952        // &prompt4) all point to the same C globals as the matching
953        // IPDEF7("PS{1..4}", ...) entries — they're aliases in C,
954        // sharing storage. zshrs's paramtab keeps them as separate
955        // entries; mirror the alias by mirroring the value here.
956        // Bug #274 in docs/BUGS.md (PROMPT3 was the visible report;
957        // PROMPT/PROMPT2/PROMPT4 had the same gap silently).
958        for (alias, source) in &[
959            ("PROMPT", "PS1"),
960            ("PROMPT2", "PS2"),
961            ("PROMPT3", "PS3"),
962            ("PROMPT4", "PS4"),
963        ] {
964            if crate::ported::params::getsparam(alias)
965                .map_or(true, |s| s.is_empty())
966            {
967                if let Some(v) = crate::ported::params::getsparam(source) {
968                    setsparam(alias, &v);
969                }
970            }
971        }
972        // c:params.c:858-860 — standard non-special param defaults.
973        // C uses `setiparam(...)` (PM_INTEGER) for these so
974        // `(t)MAILCHECK` etc. report `integer`. zshrs previously
975        // routed through `setsparam` (PM_SCALAR) — the value worked
976        // but the type bit was wrong, breaking
977        // `case "${(t)LISTMAX}" in *integer*)` and any path that
978        // gates on arithmetic-typed semantics. Bug #268 in
979        // docs/BUGS.md.
980        crate::ported::params::setiparam("MAILCHECK", 60); // c:858
981        // c:Src/params.c:859 — original `KEYTIMEOUT = 40` but
982        // zsh 5.9.1 observably reports 10 (Homebrew arm-darwin).
983        // Match the observed default so vi-mode / multi-key
984        // bindings feel responsive. Bug #321 in docs/BUGS.md.
985        crate::ported::params::setiparam("KEYTIMEOUT", 10); // c:859
986        crate::ported::params::setiparam("LISTMAX", 100); // c:860
987        // c:config.h:1004 — MAX_FUNCTION_DEPTH=500. Advisory cap;
988        // dispatch_function_call enforces against this.
989        crate::ported::params::setiparam("FUNCNEST", 500);
990
991        // Run setlocale(LC_ALL, "") so nl_langinfo() (used by the
992        // `langinfo` module) returns the host's actual locale instead
993        // of the C/POSIX default ("US-ASCII"). Direct port of zsh's
994        // Src/init.c:1208 setlocale call. unsafe { } around libc is
995        // standard for this exact use-case — setlocale is process-
996        // global and must run once at startup.
997        unsafe {
998            libc::setlocale(libc::LC_ALL, c"".as_ptr());
999        }
1000
1001        // c:hashtable.c:1206 createaliastables() — seeds aliastab with
1002        // the `run-help` / `which-command` defaults. Run once at shell
1003        // init so the canonical port owns the default-alias set; the
1004        // Executor's `aliases` HashMap then mirrors aliastab.
1005        crate::ported::hashtable::createaliastables();
1006        // Build the initial $path tied array as a local — fans out
1007        // to paramtab below; no ShellExecutor mirror anymore.
1008        let mut arrays: HashMap<String, Vec<String>> = HashMap::new();
1009        let path_dirs: Vec<String> = env::var("PATH")
1010            .unwrap_or_default()
1011            .split(':')
1012            .map(|s| s.to_string())
1013            .collect();
1014        arrays.insert("path".to_string(), path_dirs);
1015        let mut exec = Self {
1016            // c:Src/init.c:479 — `-c` mode: scriptname = scriptfilename
1017            // = ztrdup("zsh"). Both start at the literal "zsh".
1018            // dispatch_function_call overrides scriptname per c:5903;
1019            // scriptfilename stays at the outer file.
1020            scriptname: Some("zsh".to_string()),
1021            scriptfilename: Some("zsh".to_string()),
1022            subshell_snapshots: Vec::new(),
1023            inline_env_stack: Vec::new(),
1024            current_command_glob_failed: std::cell::Cell::new(false),
1025            jobs: JobTable::new(),
1026            fpath,
1027            history,
1028            completions: HashMap::new(),
1029            process_sub_counter: 0,
1030            zstyles: Vec::new(),
1031            local_scope_depth: 0,
1032            pending_underscore: None,
1033            in_dq_context: 0,
1034            in_scalar_assign: 0,
1035            profiling_enabled: false,
1036            compsys_cache: {
1037                let cache_path = crate::compsys::cache::default_cache_path();
1038                if cache_path.exists() {
1039                    let db_size = fs::metadata(&cache_path).map(|m| m.len()).unwrap_or(0);
1040                    match CompsysCache::open(&cache_path) {
1041                        Ok(c) => {
1042                            tracing::info!(
1043                                db_bytes = db_size,
1044                                path = %cache_path.display(),
1045                                "compsys: sqlite mirror opened (dbview/SQL inspection only; rkyv shards are the authoritative cache)"
1046                            );
1047                            Some(c)
1048                        }
1049                        Err(e) => {
1050                            tracing::warn!(error = %e, "compsys: failed to open cache");
1051                            None
1052                        }
1053                    }
1054                } else {
1055                    tracing::debug!("compsys: no cache at {}", cache_path.display());
1056                    None
1057                }
1058            },
1059            compinit_pending: None, // (receiver, start_time)
1060            plugin_cache: {
1061                let pc_path = crate::plugin_cache::default_cache_path();
1062                if let Some(parent) = pc_path.parent() {
1063                    let _ = fs::create_dir_all(parent);
1064                }
1065                match crate::plugin_cache::PluginCache::open(&pc_path) {
1066                    Ok(pc) => {
1067                        let (plugins, functions) = pc.stats();
1068                        tracing::info!(
1069                            plugins,
1070                            cached_functions = functions,
1071                            path = %pc_path.display(),
1072                            "plugin_cache: sqlite opened"
1073                        );
1074                        Some(pc)
1075                    }
1076                    Err(e) => {
1077                        tracing::warn!(error = %e, "plugin_cache: failed to open");
1078                        None
1079                    }
1080                }
1081            },
1082            deferred_compdefs: Vec::new(),
1083            returning: None,
1084            zsh_compat: false,
1085            bash_compat: false,
1086            posix_mode: false,
1087            worker_pool: {
1088                let config = crate::config::load();
1089                let pool_size = crate::config::resolve_pool_size(&config.worker_pool);
1090                std::sync::Arc::new(crate::worker::WorkerPool::new(pool_size))
1091            },
1092            intercepts: Vec::new(),
1093            async_jobs: HashMap::new(),
1094            next_async_id: 1,
1095            redirect_scope_stack: Vec::new(),
1096            multios_scope_stack: Vec::new(),
1097            redirect_failed: false,
1098            functions_compiled: HashMap::new(),
1099            function_source: HashMap::new(),
1100            function_line_base: HashMap::new(),
1101            function_def_file: HashMap::new(),
1102            prompt_funcstack: Vec::new(),
1103            tied_array_to_scalar: HashMap::new(),
1104            ztest_pass_count: std::sync::atomic::AtomicUsize::new(0),
1105            ztest_fail_count: std::sync::atomic::AtomicUsize::new(0),
1106            ztest_skip_count: std::sync::atomic::AtomicUsize::new(0),
1107            ztest_pass_total: std::sync::atomic::AtomicUsize::new(0),
1108            ztest_fail_total: std::sync::atomic::AtomicUsize::new(0),
1109            ztest_skip_total: std::sync::atomic::AtomicUsize::new(0),
1110            ztest_run_failed: std::sync::atomic::AtomicBool::new(false),
1111            ztest_suppress_stdout: false,
1112        };
1113        // Mirror env-derived path arrays into the `arrays` table so
1114        // user-level `fpath` / `path` array reads see the inherited
1115        // entries. zsh: `fpath+=…` should append to the inherited
1116        // 43-entry array, not replace it. Same for `path` (PATH).
1117        let fpath_arr: Vec<String> = exec
1118            .fpath
1119            .iter()
1120            .map(|p| p.to_string_lossy().to_string())
1121            .collect();
1122        if !fpath_arr.is_empty() {
1123            exec.set_array("fpath".to_string(), fpath_arr);
1124        }
1125        if let Ok(path) = env::var("PATH") {
1126            let path_arr: Vec<String> = path
1127                .split(':')
1128                .filter(|s| !s.is_empty())
1129                .map(String::from)
1130                .collect();
1131            if !path_arr.is_empty() {
1132                exec.set_array("path".to_string(), path_arr);
1133            }
1134        }
1135        // Register the standard tied path-family pairs so `path+=` /
1136        // `fpath+=` / etc. mirror through the array→scalar sync hook
1137        // in BUILTIN_APPEND_ARRAY (and the SET_ARRAY tied path).
1138        // Direct port of the implicit ties that zsh wires up at
1139        // startup for PATH/path, FPATH/fpath, etc. Source-of-truth
1140        // for the pairs is Src/init.c's `setupvals()` PM_TIED entries.
1141        for (scalar, arr) in [
1142            ("PATH", "path"),
1143            ("FPATH", "fpath"),
1144            ("MANPATH", "manpath"),
1145            ("CDPATH", "cdpath"),
1146            ("MODULE_PATH", "module_path"),
1147        ] {
1148            exec.tied_array_to_scalar
1149                .insert(arr.to_string(), (scalar.to_string(), ":".to_string()));
1150        }
1151
1152        // Pour `path` (from env PATH split) into paramtab before the
1153        // special_paramdef stamping loop below so the canonical tied
1154        // entry exists when PM_SPECIAL bits are applied.
1155        for (k, v) in &arrays {
1156            setaparam(k, v.clone()); // c:params.c:3595
1157        }
1158        // c:Src/params.c:384-394 — IPDEF8/IPDEF9 macros stamp
1159        // `PM_SCALAR|PM_SPECIAL` (IPDEF8 for `PATH`/`FPATH`/etc.) and
1160        // `PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT` (IPDEF9 for `path`/
1161        // `fpath`/etc.) on every entry in the createparamtable table.
1162        // setsparam/setaparam above create plain PM_SCALAR/PM_ARRAY
1163        // entries; this loop applies the PM_SPECIAL + PM_TIED bits
1164        // (plus the IPDEF9 PM_DONTIMPORT bit on the array side) so
1165        // `${(t)PATH}` reads `scalar-tied-export-special` and
1166        // `${(t)path}` reads `array-tied-special`.
1167        //
1168        // Walks the `special_params` table (params.rs:464+) which is
1169        // the Rust port of the C IPDEF list. For each entry: OR the
1170        // declared pm_flags onto the existing paramtab entry. The
1171        // tied-pair entries (PM_TIED) also need PM_SPECIAL OR'd in
1172        // since the IPDEF8/IPDEF9 macros add PM_SPECIAL implicitly;
1173        // the table declares only the per-entry-distinct flags.
1174        {
1175            use crate::ported::params::{paramtab, special_params};
1176            use crate::ported::zsh_h::{PM_ARRAY, PM_DONTIMPORT, PM_SCALAR, PM_SPECIAL, PM_TIED};
1177            if let Ok(mut tab) = paramtab().write() {
1178                // Stamp PM_SPECIAL onto every entry the special_params
1179                // table declares. For tied scalars (PATH/FPATH/etc),
1180                // also walks `tied_name` to apply IPDEF9-flag bits
1181                // (PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT|PM_TIED) onto the
1182                // partner array entry (path/fpath/etc) — those array
1183                // names aren't in the special_params table directly
1184                // but C zsh's createparamtable emits IPDEF9 rows for
1185                // them at Src/params.c:425-432.
1186                use crate::ported::zsh_h::{hashnode, param, PM_DONTIMPORT as PM_DI, PM_UNSET};
1187                for entry in special_params.iter() {
1188                    // c:384/394 IPDEF8/9 — `D|PM_SCALAR|PM_SPECIAL` or
1189                    // `D|PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT`.
1190                    //
1191                    // Mask `entry.pm_flags` to ONLY the attribute bits
1192                    // safe to OR onto an existing Param without changing
1193                    // assignment semantics. PM_READONLY is excluded
1194                    // here because many internal-runtime writes go
1195                    // through setsparam (subshell ZSH_SUBSHELL bump,
1196                    // ZSH_EVAL_CONTEXT push, etc.) and would be
1197                    // rejected by assignstrvalue's PM_READONLY guard.
1198                    // C zsh's PM_SPECIAL GSU setfn bypasses the guard;
1199                    // the Rust port lacks that vtable wiring, so keep
1200                    // the entries writable.
1201                    //
1202                    // PM_UNSET is included: lookup_special_var arms for
1203                    // TRY_BLOCK_ERROR / TRY_BLOCK_INTERRUPT (and other
1204                    // PM_UNSET entries with sentinel defaults) check
1205                    // this bit to decide between "stored value" vs
1206                    // "uninitialized → return -1 sentinel". The flag
1207                    // gets cleared by assignstrvalue at c:3660 on any
1208                    // write, so it correctly tracks "ever assigned".
1209                    // Bug #143 in docs/BUGS.md.
1210                    let safe_pm_flags = entry.pm_flags & (PM_TIED | PM_DI | PM_UNSET);
1211                    let mut bits = safe_pm_flags | PM_SPECIAL;
1212                    // c:Src/params.c — IPDEF4/IPDEF1 set
1213                    // PM_READONLY_SPECIAL = PM_SPECIAL | PM_READONLY |
1214                    // PM_RO_BY_DESIGN. zshrs masks PM_READONLY out
1215                    // (see above) but the introspection bit can still
1216                    // ride along. Replace dropped PM_READONLY with
1217                    // PM_RO_BY_DESIGN so `typeset -r` recognises these
1218                    // entries as logically-readonly without blocking
1219                    // internal writes. The listing filter in
1220                    // `bin_typeset` expands its PM_READONLY match to
1221                    // also pick up PM_RO_BY_DESIGN. Bug #97 in
1222                    // docs/BUGS.md.
1223                    if (entry.pm_flags & crate::ported::zsh_h::PM_READONLY) != 0 {
1224                        bits |= crate::ported::zsh_h::PM_RO_BY_DESIGN;
1225                    }
1226                    if entry.pm_type == PM_ARRAY {
1227                        bits |= PM_DI;
1228                    }
1229                    let _ = PM_SCALAR;
1230                    let _ = PM_DONTIMPORT;
1231                    if let Some(pm) = tab.get_mut(entry.name) {
1232                        pm.node.flags |= bits as i32;
1233                        // c:Src/params.c:344 IPDEF4 / c:353 IPDEF5 — the
1234                        // C struct literal initialises the `base` field
1235                        // to 10 for every PM_INTEGER special. zshrs's
1236                        // initial paramtab seeding doesn't carry that
1237                        // through (the special_paramdef table has no
1238                        // `base` field). Set the default here so
1239                        // `printparamnode`'s PMTF_USE_BASE arm at
1240                        // params.rs:9341 emits "10" between
1241                        // `integer` and the name (`integer 10 readonly
1242                        // !=0`). Bug #297 in docs/BUGS.md.
1243                        if entry.pm_type == crate::ported::zsh_h::PM_INTEGER
1244                            && pm.base == 0
1245                        {
1246                            pm.base = 10;
1247                        }
1248                        // c:Src/zsh.h IPDEF8/IPDEF9 — the third macro
1249                        // arg is the tied partner name; mapped into
1250                        // `pm->ename` so `typeset -p` can find the
1251                        // peer for the PM_TIED swap. Bug #410.
1252                        if let Some(peer) = entry.tied_name {
1253                            pm.ename = Some(peer.to_string());
1254                        }
1255                    } else {
1256                        // Param hasn't been created yet (e.g. PATH gets
1257                        // imported lazily via the env fallback in
1258                        // getsparam at params.rs:4104; array specials
1259                        // like `pipestatus` / `funcstack` / `dirstack`
1260                        // / `zsh_scheduled_events` aren't pre-populated).
1261                        // Seed an empty placeholder carrying the
1262                        // canonical flag set so subsequent setsparam /
1263                        // `(t)X` / `${+X}` observers see the IPDEF
1264                        // attribute bits AND `${+X}` returns 1.
1265                        let u_arr = if entry.pm_type == PM_ARRAY {
1266                            Some(Vec::new())
1267                        } else {
1268                            None
1269                        };
1270                        let pm: crate::ported::zsh_h::Param = Box::new(param {
1271                            node: hashnode {
1272                                next: None,
1273                                nam: entry.name.to_string(),
1274                                flags: (entry.pm_type as i32) | bits as i32,
1275                            },
1276                            u_data: 0,
1277                            u_arr,
1278                            u_str: None,
1279                            u_val: 0,
1280                            u_dval: 0.0,
1281                            u_hash: None,
1282                            gsu_s: None,
1283                            gsu_i: None,
1284                            gsu_f: None,
1285                            gsu_a: None,
1286                            gsu_h: None,
1287                            // c:Src/params.c:344 IPDEF4 / c:353 IPDEF5 —
1288                            // PM_INTEGER specials default base=10.
1289                            base: if entry.pm_type == crate::ported::zsh_h::PM_INTEGER {
1290                                10
1291                            } else {
1292                                0
1293                            },
1294                            width: 0,
1295                            env: None,
1296                            // c:Src/zsh.h IPDEF8/IPDEF9 — tied partner
1297                            // name. Bug #410.
1298                            ename: entry.tied_name.map(|s| s.to_string()),
1299                            old: None,
1300                            level: 0,
1301                        });
1302                        tab.insert(entry.name.to_string(), pm);
1303                    }
1304                    // Tied partner side. The previous loop body ORed
1305                    // PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT|PM_TIED onto the
1306                    // partner indiscriminately, but for a SCALAR ↔
1307                    // ARRAY tied pair (PATH ↔ path, FIGNORE ↔ fignore),
1308                    // that incorrectly stamped PM_ARRAY onto the scalar
1309                    // partner (FIGNORE, PATH, FPATH, MAILPATH, MANPATH,
1310                    // PSVAR, CDPATH, MODULE_PATH). Result: `(t)PATH`
1311                    // returned `array-tied-export-special` instead of
1312                    // `scalar-tied-export-special`.
1313                    //
1314                    // Both partners are already listed in `special_params`
1315                    // (the scalar at the IPDEF8 block, the array at the
1316                    // IPDEF9 block past the sentinel), so each gets its
1317                    // own pass through this loop and ends up with the
1318                    // correct flags. No cross-stamping needed.
1319                    let _ = entry.tied_name;
1320                }
1321                // c:Src/params.c:893-924 environment-import loop —
1322                // every env var gets either a fresh exported paramtab
1323                // entry OR (when the entry pre-exists from
1324                // special_params) PM_EXPORTED OR'd onto its flags.
1325                // Without this, `declare -p PATH` printed `typeset -T
1326                // PATH=''` and `declare -p USER` printed nothing at
1327                // all because USER was never in paramtab.
1328                use crate::ported::zsh_h::hashnode as _hn;
1329                use crate::ported::zsh_h::{PM_EXPORTED, PM_SCALAR};
1330                for (env_name, env_value) in std::env::vars() {
1331                    if env_name.is_empty() || env_name.contains('[') {
1332                        continue;
1333                    }
1334                    if env_name.as_bytes()[0].is_ascii_digit() {
1335                        continue;
1336                    }
1337                    if !crate::ported::params::isident(&env_name) {
1338                        continue;
1339                    }
1340                    if let Some(pm) = tab.get_mut(&env_name) {
1341                        pm.node.flags |= PM_EXPORTED as i32;
1342                        // c:Src/params.c:893-924 — C's env-import calls
1343                        // `assignsparam(..., ASSPM_ENV_IMPORT)` which
1344                        // routes through the param's GSU setfn. For
1345                        // SPECIAL scalars with cached storage (HOME,
1346                        // USERNAME, TERM, WORDCHARS, TERMINFO,
1347                        // TERMINFO_DIRS, KEYBOARD_HACK, histchars) the
1348                        // setfn writes to a separate `*_lock` global
1349                        // (e.g. home_lock). Just OR'ing PM_EXPORTED
1350                        // leaves those globals empty, so `$HOME` reads
1351                        // back "" even though HOME is in env. Mirror
1352                        // C by copying the env value into pm.u_str and
1353                        // (for cached specials) the matching global.
1354                        //
1355                        // IFS / `_` carry PM_DONTIMPORT (Src/params.c
1356                        // IPDEF7) — skip those; their env value MUST
1357                        // NOT override the shell-set default.
1358                        let dontimport = (pm.node.flags as u32
1359                            & crate::ported::zsh_h::PM_DONTIMPORT)
1360                            != 0;
1361                        // Only seed cached state when the param was
1362                        // still marked PM_UNSET — i.e. nothing has set
1363                        // it yet. ShellExecutor::new's earlier init
1364                        // block (vm_helper line 837+) already ran
1365                        // setsparam for a few names (ZSH_ARGZERO,
1366                        // WORDCHARS, SHLVL with the +1 increment, IFS,
1367                        // OPTIND, …); those calls clear PM_UNSET so we
1368                        // must not overwrite them with the raw env
1369                        // value here. The PM_UNSET-still-set case is
1370                        // the "C zsh would have called
1371                        // assignsparam(...,ASSPM_ENV_IMPORT) and ours
1372                        // didn't yet" gap that bug #599 (HOME=` `) and
1373                        // %~ prompt expansion need.
1374                        let still_unset = (pm.node.flags as u32
1375                            & crate::ported::zsh_h::PM_UNSET)
1376                            != 0;
1377                        if !dontimport && still_unset {
1378                            pm.u_str = Some(env_value.clone());
1379                            pm.env = Some(format!("{}={}", env_name, env_value));
1380                            // c:Src/params.c:3660 — `assignstrvalue`
1381                            // clears PM_UNSET on any write. HOME / TERM
1382                            // / TERMINFO / TERMINFO_DIRS / WORDCHARS
1383                            // start life with PM_UNSET in
1384                            // `special_params` (params.rs SPECIAL_PARAMS
1385                            // table) so `lookup_special_var` skips the
1386                            // getfn for uninitialized specials; env
1387                            // import is the canonical "now it's set"
1388                            // event, so clear the bit.
1389                            pm.node.flags &= !(PM_UNSET as i32);
1390                            // Cached-state specials: route through
1391                            // the matching setfn so the global cache
1392                            // (home_lock / wordchars_lock / etc.)
1393                            // reflects the env value. Each setfn
1394                            // ignores its `pm` arg (matches C's
1395                            // UNUSED(Param pm)), so passing the
1396                            // borrowed paramtab entry is safe.
1397                            match env_name.as_str() {
1398                                "HOME" => crate::ported::params::homesetfn(
1399                                    pm.as_mut(),
1400                                    env_value.clone(),
1401                                ),
1402                                "USERNAME" => crate::ported::params::usernamesetfn(
1403                                    pm.as_mut(),
1404                                    env_value.clone(),
1405                                ),
1406                                "TERM" => crate::ported::params::termsetfn(
1407                                    pm.as_mut(),
1408                                    env_value.clone(),
1409                                ),
1410                                "WORDCHARS" => crate::ported::params::wordcharssetfn(
1411                                    pm.as_mut(),
1412                                    env_value.clone(),
1413                                ),
1414                                "TERMINFO" => crate::ported::params::terminfosetfn(
1415                                    pm.as_mut(),
1416                                    env_value.clone(),
1417                                ),
1418                                "TERMINFO_DIRS" => crate::ported::params::terminfodirssetfn(
1419                                    pm.as_mut(),
1420                                    env_value.clone(),
1421                                ),
1422                                _ => {}
1423                            }
1424                        }
1425                    } else {
1426                        // Fresh entry — PM_SCALAR + PM_EXPORTED, value
1427                        // taken from env. Mirrors C zsh's c:907-908
1428                        // `assignsparam(..., ASSPM_ENV_IMPORT)` for
1429                        // names not already in the special table.
1430                        let pm: crate::ported::zsh_h::Param = Box::new(param {
1431                            node: _hn {
1432                                next: None,
1433                                nam: env_name.clone(),
1434                                flags: (PM_SCALAR | PM_EXPORTED) as i32,
1435                            },
1436                            u_data: 0,
1437                            u_arr: None,
1438                            u_str: Some(env_value.clone()),
1439                            u_val: 0,
1440                            u_dval: 0.0,
1441                            u_hash: None,
1442                            gsu_s: None,
1443                            gsu_i: None,
1444                            gsu_f: None,
1445                            gsu_a: None,
1446                            gsu_h: None,
1447                            base: 0,
1448                            width: 0,
1449                            env: Some(format!("{}={}", env_name, env_value)),
1450                            ename: None,
1451                            old: None,
1452                            level: 0,
1453                        });
1454                        tab.insert(env_name, pm);
1455                    }
1456                }
1457            }
1458        }
1459        // Populate paramtab with PM_SPECIAL placeholder Params for
1460        // every PARTAB / PARTAB_ARRAY magic-assoc name. Mirrors
1461        // what C's zsh/parameter module boot_ → handlefeatures
1462        // chain does at startup. Makes `${+aliases}` / `${(t)commands}`
1463        // / `typeset -p modules` etc. see the special entries.
1464        init_partab_params(); // c:Src/Modules/parameter.c:2341 boot_/enables_ chain
1465
1466        // c:Src/init.c:1703 init_bltinmods — must run before user
1467        // code so default-loaded modules (zsh/watch, …) get their
1468        // boot_ entry points called and their params (e.g. `watch`,
1469        // `WATCH`) seeded in paramtab. Without this, `${(t)watch}`
1470        // returned empty even though zsh treats zsh/watch as loaded
1471        // by default. The bin entry skips zsh_main → init_bltinmods,
1472        // so we run it here from ShellExecutor::new for the same
1473        // effect. Bug #270.
1474        crate::ported::init::init_bltinmods();
1475
1476        // c:Src/params.c:873-876 — `gethostname(hostnam,256);
1477        //                            setsparam("HOST", ztrdup_metafy(hostnam));`
1478        // Plain port of the createparamtable HOST init. Direct
1479        // libc::gethostname call; result written via canonical
1480        // setsparam. createparamtable() itself isn't called from the
1481        // bin entry yet (full init port pending); this is the minimum
1482        // for `$HOST` to read non-empty.
1483        let mut host_buf = [0u8; 256];
1484        let host_rc = unsafe { libc::gethostname(host_buf.as_mut_ptr() as *mut libc::c_char, 256) }; // c:874
1485        if host_rc == 0 {
1486            if let Ok(c) = std::ffi::CStr::from_bytes_until_nul(&host_buf) {
1487                if let Ok(name) = c.to_str() {
1488                    crate::ported::params::setsparam("HOST", name); // c:875
1489                }
1490            }
1491        }
1492        // c:Src/init.c:479 — `-c` mode: scriptname = scriptfilename
1493        // = ztrdup("zsh"). Both globals start as the literal "zsh"
1494        // (not the binary path) so PS4's %x / %N print "zsh" not
1495        // "/path/to/zshrs" at the top level. Function dispatch
1496        // overrides scriptname per c:5903; scriptfilename stays.
1497        crate::ported::utils::set_scriptname(Some("zsh".to_string()));
1498        crate::ported::utils::set_scriptfilename(Some("zsh".to_string()));
1499
1500        // c:Src/params.c:961-970 — uname-derived host/arch
1501        // identification params: MACHTYPE / CPUTYPE / OSTYPE /
1502        // VENDOR. C zsh reads from compile-time `#define`s (set by
1503        // ./configure) for MACHTYPE / OSTYPE / VENDOR, and from
1504        // uname().machine at runtime for CPUTYPE.
1505        //
1506        // Rust port: probe uname() at startup for CPUTYPE, and use
1507        // const strings parameterized by build-target for the
1508        // others. Match homebrew zsh's values where possible.
1509        let mut uname_buf: libc::utsname = unsafe { std::mem::zeroed() };
1510        let _ = unsafe { libc::uname(&mut uname_buf) };
1511        let to_str = |b: &[libc::c_char]| -> String {
1512            // c-string → owned String, truncated at first NUL.
1513            let bytes: Vec<u8> = b
1514                .iter()
1515                .take_while(|&&c| c != 0)
1516                .map(|&c| c as u8)
1517                .collect();
1518            String::from_utf8_lossy(&bytes).into_owned()
1519        };
1520        let cputype = to_str(&uname_buf.machine);
1521        crate::ported::params::setsparam("CPUTYPE", &cputype); // c:961
1522        let sysname = to_str(&uname_buf.sysname).to_lowercase();
1523        let release = to_str(&uname_buf.release);
1524        let ostype = format!("{}{}", sysname, release); // c:968 (OSTYPE)
1525        crate::ported::params::setsparam("OSTYPE", &ostype);
1526        // MACHTYPE / VENDOR: hardcoded per platform. macOS uses
1527        // "arm" or "arm64" or "x86_64" for arm-derived MACHTYPE.
1528        // Approximate the canonical homebrew value: short-form of
1529        // the cputype.
1530        let machtype = if cputype.starts_with("arm") {
1531            "arm".to_string()
1532        } else {
1533            cputype.clone()
1534        };
1535        crate::ported::params::setsparam("MACHTYPE", &machtype); // c:967
1536        let vendor = if sysname == "darwin" {
1537            "apple"
1538        } else {
1539            "unknown"
1540        };
1541        crate::ported::params::setsparam("VENDOR", vendor); // c:970
1542
1543        // c:Src/params.c:878-882 — `setsparam("LOGNAME", getlogin() ?:
1544        // cached_username);`. C's createparamtable also assigns
1545        // USERNAME from the same source (cached_username) via the
1546        // special_paramdefs table. Here mirror the LOGNAME +
1547        // USERNAME seeding so the canonical paramtab entries exist
1548        // (usernamegetfn at c:4655 reads through Param.u_str).
1549        // Same one-shot init pattern as the HOST gethostname call
1550        // above — full createparamtable() port is pending.
1551        let logname = unsafe {
1552            let p = libc::getlogin();
1553            if p.is_null() {
1554                None
1555            } else {
1556                Some(std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned())
1557            }
1558        }; // c:880
1559        if let Some(name) = logname {
1560            crate::ported::params::setsparam("LOGNAME", &name); // c:881
1561                                                                // DO NOT setsparam("USERNAME", ...) here. `$USERNAME` is
1562                                                                // a special parameter whose SETTER (`usernamesetfn` in
1563                                                                // params.rs) performs setgid(2) + setuid(2) to actually
1564                                                                // change the effective user — that's a deliberate upstream
1565                                                                // zsh feature for `USERNAME=other-user cmd`. Calling it at
1566                                                                // init seeds the value AND tries to change uid/gid; when
1567                                                                // the resolved pwd's pw_uid differs from `getuid()` (sudo
1568                                                                // launches, macOS Keychain-helper inherited env, container
1569                                                                // entry points, etc.) the setgid call fails with EPERM and
1570                                                                // emits `zsh:1: failed to change group ID: Operation not
1571                                                                // permitted`. Upstream seeds `$USERNAME` via the GETTER
1572                                                                // path (`usernamegetfn` reads through `cached_username`
1573                                                                // populated by `inittyptab` → `get_username`), no setter
1574                                                                // call needed.
1575        }
1576
1577        // c:Src/init.c:1176 — `module_path = mkarray(MODULE_DIR)`.
1578        // The canonical init lives in `init::setupvals` (port of
1579        // `Src/init.c:setupvals`); the bin entry skips setupvals (per
1580        // the init_bltinmods comment above), so call the lightweight
1581        // module_path bootstrap exposed by init.rs from here. This
1582        // mirrors the HOST gethostname seeding pattern above:
1583        // duplicated init that should collapse into a full setupvals
1584        // call once that port is complete.
1585        crate::ported::init::module_path_init();
1586
1587        exec
1588    }
1589
1590    /// Execute a script file with bytecode caching — skips lex+parse+compile on cache hit.
1591    /// Bytecode is stored in rkyv keyed by (path, mtime).
1592    pub fn execute_script_file(&mut self, file_path: &str) -> Result<i32, String> {
1593        let path = Path::new(file_path);
1594        let abs_path = path
1595            .canonicalize()
1596            .unwrap_or_else(|_| path.to_path_buf())
1597            .to_string_lossy()
1598            .to_string();
1599
1600        // Try bytecode cache first — rkyv shard at ~/.zshrs/scripts.rkyv.
1601        // The cache validates path + mtime + zshrs binary mtime; on any
1602        // miss we fall through to lex/parse/compile. Cached path uses
1603        // `run_chunk` (the shared VM-execution helper); script-eval
1604        // path delegates to `execute_script_zsh_pipeline` so the
1605        // full parse/compile/cache-save/run flow stays in one place.
1606        if let Some(bc_blob) = crate::script_cache::try_load_bytes(path) {
1607            if let Ok(chunk) = bincode::deserialize::<fusevm::Chunk>(&bc_blob) {
1608                if !chunk.ops.is_empty() {
1609                    tracing::trace!(
1610                        path = %abs_path,
1611                        ops = chunk.ops.len(),
1612                        "execute_script_file: bytecode cache hit"
1613                    );
1614                    return self.run_chunk(chunk, &format!("execute_script_file:cache:{abs_path}"));
1615                }
1616            }
1617        }
1618
1619        // Cache miss — read, parse, compile via execute_script_zsh_pipeline,
1620        // then snapshot the resulting chunk into the cache for next
1621        // time. Direct port of Src/init.c source() which calls
1622        // `lex_init_buf` / `loop()` without engaging the history layer.
1623        // (zsh fires `!` history sub only on interactive input, so
1624        // sourced files run verbatim.)
1625        let content = fs::read_to_string(file_path).map_err(|e| format!("{}: {}", file_path, e))?;
1626        let status = self.execute_script_zsh_pipeline(&content)?;
1627
1628        // Best-effort cache save — failures don't block execution.
1629        // Re-parse/-compile here instead of trying to thread the chunk
1630        // back out of execute_script_zsh_pipeline; the cost is one extra
1631        // compile per CACHE MISS, paid back on every subsequent run.
1632        let saved_errflag = errflag.load(Ordering::Relaxed);
1633        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
1634        crate::ported::parse::parse_init(&content);
1635        let program = crate::ported::parse::parse();
1636        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
1637        errflag.store(saved_errflag, Ordering::Relaxed);
1638        if !parse_failed {
1639            let compiler = crate::compile_zsh::ZshCompiler::new();
1640            let chunk = compiler.compile(&program);
1641            if let Ok(blob) = bincode::serialize(&chunk) {
1642                let _ = crate::script_cache::try_save_bytes(path, &blob);
1643                tracing::trace!(
1644                    path = %abs_path,
1645                    bytes = blob.len(),
1646                    "execute_script_file: bytecode cached"
1647                );
1648            }
1649        }
1650
1651        Ok(status)
1652    }
1653
1654    /// Run a compiled `fusevm::Chunk` to completion inside this
1655    /// executor's context. Shared by `execute_script_zsh_pipeline`,
1656    /// `execute_script_file`'s bytecode-cache hit path, and the
1657    /// function-dispatch body_runner. Centralises the VM setup so
1658    /// `register_builtins` and `ExecutorContext::enter` invariants
1659    /// stay in lockstep.
1660    fn run_chunk(&mut self, chunk: fusevm::Chunk, label: &str) -> Result<i32, String> {
1661        if chunk.ops.is_empty() {
1662            return Ok(self.last_status());
1663        }
1664        crate::fusevm_disasm::maybe_print_stdout(label, &chunk);
1665        let mut vm = fusevm::VM::new(chunk);
1666        register_builtins(&mut vm);
1667        // Seed vm.last_status with the executor's current LASTVAL so
1668        // sub-VMs (EXIT trap bodies, eval, source) see the inherited
1669        // `$?` from the caller's last command — matching C zsh where
1670        // lastval is a process global. Without this, the new VM
1671        // started at 0 and BUILTIN_GET_VAR's sync_status would write
1672        // 0 back into LASTVAL on the first `$?` read.
1673        vm.last_status = self.last_status();
1674        let _ctx = ExecutorContext::enter(self);
1675        match vm.run() {
1676            fusevm::VMResult::Ok(_) | fusevm::VMResult::Halted => {
1677                self.set_last_status(vm.last_status);
1678            }
1679            fusevm::VMResult::Error(e) => return Err(format!("VM error: {}", e)),
1680        }
1681        Ok(self.last_status())
1682    }
1683
1684    /// Execute via the lex+parse free ported + ZshCompiler pipeline.
1685    /// This is the only execution path; `execute_script` delegates here.
1686    pub fn execute_script_zsh_pipeline(&mut self, script: &str) -> Result<i32, String> {
1687        // Skip history expansion for non-interactive script execution
1688        // (`zsh -c '…'`, internal eval, sourced files). zsh's `!`
1689        // history sub only fires on the REPL command line, never on
1690        // a pre-parsed script body. The interactive REPL has its
1691        // own dedicated path that calls expand_history before
1692        // dispatching here.
1693        // Save & clear errflag around the parse so a fresh syntax
1694        // error is distinguishable from one already in flight. Mirrors
1695        // Src/init.c loop()'s pre-parse `errflag &= ~ERRFLAG_ERROR;`.
1696        let saved_errflag = errflag.load(Ordering::Relaxed);
1697        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
1698        crate::ported::parse::parse_init(script);
1699        let program = crate::ported::parse::parse();
1700        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
1701        errflag.store(saved_errflag, Ordering::Relaxed);
1702        if parse_failed {
1703            // c:Src/init.c — when the parser fires `zerr(...)`, the C
1704            // shell's `loop()` body skips the eval pass and continues;
1705            // there's no second "parse error" diagnostic. The Rust
1706            // binary's call sites print `zshrs: <e>` on Err, doubling
1707            // up on the message the parser already emitted via zerr.
1708            // Use a `__SILENCED__` sentinel that the binary's
1709            // execute_script wrapper recognizes as "already reported,
1710            // exit silently". Bug #142 in docs/BUGS.md (double-print
1711            // half).
1712            return Err("__SILENCED__".to_string());
1713        }
1714
1715        let compiler = crate::compile_zsh::ZshCompiler::new();
1716        let chunk = compiler.compile(&program);
1717        let status = self.run_chunk(chunk, "execute_script_zsh_pipeline")?;
1718
1719        // Fire EXIT trap if set. Two storage paths:
1720        //   (a) `trap 'cmd' EXIT` writes the body text into
1721        //       `traps_table` via bin_trap (Src/builtin.c) — fire
1722        //       directly via execute_script.
1723        //   (b) `TRAPEXIT() { ... }` function-named form goes
1724        //       through settrap(SIGEXIT, None, ZSIG_FUNC) at
1725        //       funcdef time (fusevm_bridge.rs BUILTIN_REGISTER_COMPILED_FN
1726        //       arm) and lives in shfunctab + sigtrapped — fire
1727        //       via dotrap(SIGEXIT) which dispatches the named
1728        //       shfunc. Bug #157 in docs/BUGS.md.
1729        // Remove the trap from `traps_table` first to prevent
1730        // infinite recursion of `(a)`; `(b)`'s sigtrapped flag
1731        // is cleared by dotrap's own intrap guard.
1732        let exit_body = crate::ported::builtin::traps_table()
1733            .lock()
1734            .ok()
1735            .and_then(|mut t| t.remove("EXIT"));
1736        if let Some(action) = exit_body {
1737            tracing::debug!("firing EXIT trap (new pipeline)");
1738            // c:Src/signals.c — the EXIT trap body sees $? at the
1739            // value the script left off (so `trap 'echo $?' EXIT;
1740            // (exit 7)` prints 7), but the SHELL's final exit code
1741            // is still the pre-trap value (running `echo` inside
1742            // the trap doesn't reset the script's exit status).
1743            // Preserve `status` and re-apply it after the trap
1744            // body returns.
1745            let _ = self.execute_script_zsh_pipeline(&action);
1746            self.set_last_status(status);
1747        }
1748        // c:Src/signals.c::dotrap(SIGEXIT) — fire TRAPEXIT() shfunc
1749        // if installed via the function-name path. The TRAPEXIT()
1750        // form goes through settrap(SIGEXIT, None, ZSIG_FUNC) at
1751        // funcdef time (sets sigtrapped[SIGEXIT] |= ZSIG_FUNC).
1752        // Dispatching from here AFTER run_chunk returns means we're
1753        // outside the VM context — dotrap can't safely re-enter
1754        // via dispatch_function_call (which uses with_executor).
1755        // Route through execute_script_zsh_pipeline which sets up
1756        // a fresh VM context — invoke the function by name.
1757        let trapped = crate::ported::signals::sigtrapped
1758            .lock()
1759            .ok()
1760            .and_then(|g| g.get(crate::signals_h::SIGEXIT as usize).copied())
1761            .unwrap_or(0);
1762        if (trapped & crate::ported::zsh_h::ZSIG_FUNC as i32) != 0 {
1763            // The TRAP<SIG> function is stored in shfunctab as
1764            // "TRAPEXIT"; calling it by name re-enters
1765            // execute_script_zsh_pipeline with a fresh VM context.
1766            let _ = self.execute_script_zsh_pipeline("TRAPEXIT");
1767        }
1768        // c:Src/init.c::zexit — `callhookfunc("zshexit", NULL, 1, NULL)`.
1769        // Fire the `zshexit` shfunc + walk `zshexit_functions` array.
1770        // Routed through execute_script_zsh_pipeline calls because
1771        // we're outside the VM context here (post-run_chunk). Iterate
1772        // the array directly + call zshexit by name. Bug #215 in
1773        // docs/BUGS.md.
1774        //
1775        // Re-entry guard: each call to execute_script_zsh_pipeline
1776        // (whether top-level script or the named-fn dispatch below)
1777        // hits this code at its tail. Without a guard, the zshexit
1778        // hook recurses infinitely (calls itself at end via this
1779        // path). Use a thread-local depth counter and skip the
1780        // dispatch when depth > 0.
1781        thread_local! {
1782            static ZSHEXIT_HOOK_DEPTH: std::cell::Cell<u32> = const {
1783                std::cell::Cell::new(0)
1784            };
1785        }
1786        let hook_depth = ZSHEXIT_HOOK_DEPTH.with(|c| c.get());
1787        if hook_depth == 0 {
1788            ZSHEXIT_HOOK_DEPTH.with(|c| c.set(hook_depth + 1));
1789            if crate::ported::hashtable::shfunctab_lock()
1790                .read()
1791                .ok()
1792                .map(|t| t.contains_key("zshexit"))
1793                .unwrap_or(false)
1794            {
1795                let _ = self.execute_script_zsh_pipeline("zshexit");
1796            }
1797            let exit_arr = crate::ported::params::paramtab()
1798                .read()
1799                .ok()
1800                .and_then(|t| t.get("zshexit_functions").and_then(|p| p.u_arr.clone()))
1801                .unwrap_or_default();
1802            for fn_name in exit_arr {
1803                let exists = crate::ported::hashtable::shfunctab_lock()
1804                    .read()
1805                    .ok()
1806                    .map(|t| t.contains_key(&fn_name))
1807                    .unwrap_or(false);
1808                if exists {
1809                    let _ = self.execute_script_zsh_pipeline(&fn_name);
1810                }
1811            }
1812            ZSHEXIT_HOOK_DEPTH.with(|c| c.set(hook_depth));
1813        }
1814        // Preserve script status; trap body shouldn't override it.
1815        self.set_last_status(status);
1816
1817        let _ = status;
1818        Ok(self.last_status())
1819    }
1820    /// `execute_script` — see implementation.
1821    #[tracing::instrument(skip(self, script), fields(len = script.len()))]
1822    pub fn execute_script(&mut self, script: &str) -> Result<i32, String> {
1823        // lex+parse free ported + ZshCompiler is the only execution path.
1824        self.execute_script_zsh_pipeline(script)
1825    }
1826
1827    /// Whether `name` is a known function. Checks the compiled-functions
1828    /// table and the autoload-pending registry — `autoload foo` should
1829    /// make `whence foo`/`type foo`/`functions foo` recognize `foo` as
1830    /// a function before it's actually loaded. Doesn't trigger autoload
1831    /// itself; use `maybe_autoload` first if you need to load before
1832    /// introspecting.
1833    pub fn function_exists(&self, name: &str) -> bool {
1834        // Either compiled (already loaded) or shfunctab has an
1835        // autoload stub with PM_UNDEFINED set (pending). Matches C's
1836        // `lookupshfunc(name)` semantics at `Src/exec.c:5215`.
1837        if self.functions_compiled.contains_key(name) {
1838            return true;
1839        }
1840        crate::ported::hashtable::shfunctab_lock()
1841            .read()
1842            .ok()
1843            .map(|t| t.get(name).is_some())
1844            .unwrap_or(false)
1845    }
1846
1847    /// Sorted list of every known function name (union of compiled + source).
1848    pub fn function_names(&self) -> Vec<String> {
1849        let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1850        for k in self.functions_compiled.keys() {
1851            set.insert(k.clone());
1852        }
1853        for k in self.function_source.keys() {
1854            set.insert(k.clone());
1855        }
1856        set.into_iter().collect()
1857    }
1858
1859    /// Dispatch a function by name. Thin passthru — autoload-materialize
1860    /// the body if needed, build a synthetic `shfunc`, and hand off to
1861    /// the canonical `doshfunc` port (`Src/exec.c:5823` →
1862    /// `src/ported/exec.rs::doshfunc`). doshfunc owns ALL scope
1863    /// management (starttrapscope/endtrapscope, startparamscope/
1864    /// endparamscope, funcdepth bump, pipestats save/restore, scriptname
1865    /// snapshot, BREAKS/CONTFLAG/LOOPS/RETFLAG snapshot+restore, `$0`
1866    /// override via FUNCTIONARGZERO, etc.). The body run itself is the
1867    /// Rust-only adaptation passed via the `body_runner` closure because
1868    /// zshrs runs function bodies through fusevm bytecode (not C zsh's
1869    /// wordcode walker via `runshfunc`).
1870    ///
1871    /// Returns `None` when the name isn't a known function so the caller
1872    /// can fall through to external dispatch.
1873    /// Body-only counterpart to [`dispatch_function_call`] — runs
1874    /// the function body WITHOUT wrapping in `doshfunc`. Used as the
1875    /// `body_runner` closure target by `src/ported/` callers that
1876    /// already wrap their own `crate::ported::exec::doshfunc(...)`
1877    /// call (so going back through `dispatch_function_call` would
1878    /// double-wrap the scope). Mirrors C's `runshfunc(prog, wrappers,
1879    /// name)` at `exec.c:6042` from doshfunc's perspective.
1880    pub fn run_function_body_only(&mut self, name: &str, args: &[String]) -> Option<i32> {
1881        // Same Rust-port short-circuit as dispatch_function_call,
1882        // sans the doshfunc wrap.
1883        if let Some(rust_fn) = crate::compsys::router::try_rust_dispatch(name) {
1884            return Some(rust_fn(args));
1885        }
1886        // Autoload prelude (same as dispatch_function_call's).
1887        if !self.functions_compiled.contains_key(name) {
1888            if let Some(stub) = crate::ported::utils::getshfunc(name) {
1889                if (stub.node.flags as u32 & PM_UNDEFINED) != 0 {
1890                    let boxed = Box::new(stub.clone());
1891                    let ptr = Box::into_raw(boxed);
1892                    let _ = crate::ported::exec::loadautofn(ptr, 0, 0, 0);
1893                    unsafe {
1894                        let _ = Box::from_raw(ptr);
1895                    }
1896                    if let Some(body) = crate::ported::utils::getshfunc(name).and_then(|f| f.body) {
1897                        let registered = autoload_register_source(name, &body);
1898                        let _ = self.execute_script_zsh_pipeline(&registered);
1899                    }
1900                } else if let Some(body) = stub.body.clone() {
1901                    let registered = autoload_register_source(name, &body);
1902                    let _ = self.execute_script_zsh_pipeline(&registered);
1903                }
1904            }
1905        }
1906        let chunk = self.functions_compiled.get(name).cloned()?;
1907        let seed_status = self.last_status();
1908        let _ = args; // fusevm body reads $1..$N from PPARAMS
1909        let mut vm = fusevm::VM::new(chunk);
1910        register_builtins(&mut vm);
1911        vm.last_status = seed_status;
1912        let _ = vm.run();
1913        Some(vm.last_status)
1914    }
1915
1916    pub fn dispatch_function_call(&mut self, name: &str, args: &[String]) -> Option<i32> {
1917        // c:Src/exec.c — `disable -f NAME` flips the DISABLED flag on
1918        // the shfunctab entry. `lookupshfunc` (which dispatch consults)
1919        // returns NULL for DISABLED entries, falling through to PATH
1920        // lookup → "command not found". zshrs keeps the compiled body
1921        // in functions_compiled independently of the flag, so check
1922        // shfunctab and short-circuit when DISABLED is set. Bug #221
1923        // in docs/BUGS.md.
1924        let is_disabled = crate::ported::hashtable::shfunctab_lock()
1925            .read()
1926            .ok()
1927            .and_then(|t| {
1928                let entry = t.get_including_disabled(name)?;
1929                Some(
1930                    (entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0,
1931                )
1932            })
1933            .unwrap_or(false);
1934        if is_disabled {
1935            return None;
1936        }
1937        // zshrs-original: `[compsys] backend = "rust"` short-circuit.
1938        // When a `_NAME` has a Rust port AND the user opted into the
1939        // rust backend, run the Rust fn directly here — but still
1940        // through the canonical doshfunc scope-management path below
1941        // (we synthesize a body_runner from the fn pointer). Router
1942        // returns None for names without a Rust port → graceful
1943        // fallback to the shfunc autoload path.
1944        //
1945        // Note: `compcore::callcompfunc` (the compsys entry hit by
1946        // Tab) wraps doshfunc itself per C `compcore.c:835`, so the
1947        // Rust _main_complete dispatch lands HERE only when called
1948        // from a non-compcore caller (e.g. a user shell script
1949        // directly invoking `_main_complete`). The doshfunc scope
1950        // wrap below applies uniformly to both.
1951        let direct_rust_fn: Option<fn(&[String]) -> i32> =
1952            crate::compsys::router::try_rust_dispatch(name);
1953        // Autoload prelude skipped when a Rust port wins — no upstream
1954        // shell function to load.
1955        if direct_rust_fn.is_none() && !self.functions_compiled.contains_key(name) {
1956            if let Some(stub) = crate::ported::utils::getshfunc(name) {
1957                if (stub.node.flags as u32 & PM_UNDEFINED) != 0 {
1958                    let boxed = Box::new(stub.clone());
1959                    let ptr = Box::into_raw(boxed);
1960                    let load_rc = crate::ported::exec::loadautofn(ptr, 0, 0, 0);
1961                    unsafe {
1962                        let _ = Box::from_raw(ptr);
1963                    }
1964                    if let Some(body) = crate::ported::utils::getshfunc(name).and_then(|f| f.body) {
1965                        let registered = autoload_register_source(name, &body);
1966                        let _ = self.execute_script_zsh_pipeline(&registered);
1967                    } else if load_rc != 0 {
1968                        // c:Src/exec.c:5713-5719 / 5635-5644 —
1969                        // `execautofn`'s `if (!loadautofn(...)) return 1`
1970                        // propagates the loadautofn failure as the
1971                        // command's exit status. zshrs's previous
1972                        // path returned None here, falling through to
1973                        // execute_external which emitted a SECOND
1974                        // diagnostic (`command not found: NAME`) on
1975                        // top of loadautofn's `function definition
1976                        // file not found`. Mirror C: when load failed
1977                        // AND the stub still has no body, surface
1978                        // status=1 so the caller does NOT fall back
1979                        // to PATH search.
1980                        return Some(1);
1981                    }
1982                } else if let Some(body) = stub.body.clone() {
1983                    // c:Src/Modules/parameter.c::setpmfunction — function
1984                    // registered via `functions[name]=body` lives in
1985                    // shfunctab with `body` set but `functions_compiled`
1986                    // empty (the canonical port stores the parsed eprog,
1987                    // not a fusevm Chunk). Lazy-compile here by feeding
1988                    // the body through the standard funcdef pipeline so
1989                    // the next CallFunction op finds the chunk.
1990                    let registered = autoload_register_source(name, &body);
1991                    let _ = self.execute_script_zsh_pipeline(&registered);
1992                }
1993            }
1994        }
1995        // When a Rust port is registered, skip the fusevm Chunk
1996        // lookup entirely — the body_runner closure below will run
1997        // the Rust fn pointer directly. Otherwise require a compiled
1998        // chunk for the autoloaded body.
1999        let chunk_opt = if direct_rust_fn.is_some() {
2000            None
2001        } else {
2002            Some(self.functions_compiled.get(name).cloned()?)
2003        };
2004
2005        // zshrs-specific bookkeeping that doshfunc doesn't own:
2006        // - prompt_funcstack (PS4 trace) push/pop
2007        // - local_scope_depth FUNCNEST guard
2008        //
2009        // c:Src/exec.c::funcnest_check — C zsh allows FUNCNEST=500 by
2010        // default and the per-call stack usage is small enough that
2011        // 500 nested calls fit comfortably in the default 8MB thread
2012        // stack. zshrs's per-call stack usage is heavier (vm_helper
2013        // state, fusevm closures, parse buffers) — empirically the
2014        // Rust stack overflows around depth 120. Cap the effective
2015        // check at a safe ceiling (80) so the user-visible
2016        // `maximum nested function level reached` diagnostic fires
2017        // before the SIGABRT crash. User-set FUNCNEST values above
2018        // 80 are silently clamped. Bug #519 — critical: previously
2019        // ANY infinite-recursion function crashed the shell with
2020        // `thread 'main' has overflowed its stack` exit 134.
2021        const FUNCNEST_RUST_CEILING: usize = 80;
2022        let funcnest_user: usize = self
2023            .scalar("FUNCNEST")
2024            .and_then(|s| s.parse().ok())
2025            .unwrap_or(100);
2026        let funcnest_limit = funcnest_user.min(FUNCNEST_RUST_CEILING);
2027        if self.local_scope_depth >= funcnest_limit {
2028            eprintln!(
2029                "{}: maximum nested function level reached; increase FUNCNEST?",
2030                name
2031            );
2032            return Some(1);
2033        }
2034        let display_name = if name.starts_with("_zshrs_anon_") {
2035            "(anon)".to_string()
2036        } else {
2037            name.to_string()
2038        };
2039        let line_base = self.function_line_base.get(name).copied().unwrap_or(0);
2040        let def_file = self.function_def_file.get(name).cloned().flatten();
2041        self.prompt_funcstack
2042            .push((name.to_string(), line_base, def_file));
2043        self.local_scope_depth += 1;
2044
2045        // Synthetic shfunc for doshfunc — carries the name + def-file
2046        // info so funcstack push gets a proper filename. funcdef/body
2047        // stay None because the wordcode body is irrelevant on this
2048        // path (body_runner runs the fusevm Chunk directly).
2049        // c:Src/exec.c:5390-5410 — execfuncdef records the
2050        // current `scriptfilename` on the shfunc at definition
2051        // time so funcsourcetrace can show file:line of the
2052        // function's source. The function_def_file map stores
2053        // this; fall back to the live scriptfilename so dynamic
2054        // / non-`compile_funcdef`-routed definitions still get a
2055        // sensible filename. Without the fallback, the synth_shf
2056        // saw None and the funcstack push at exec.rs:5719
2057        // defaulted to an empty string, which the funcsourcetrace
2058        // getfn rendered as `:N` (or worse, picked up the
2059        // function name from a parallel field). Bug #515.
2060        let synth_filename = self
2061            .function_def_file
2062            .get(name)
2063            .cloned()
2064            .flatten()
2065            .or_else(|| self.scriptfilename.clone());
2066        // c:Src/exec.c:5409 — `shf->lineno = lineno;` (def line).
2067        // `function_line_base[name]` carries compile_funcdef's
2068        // `lineno_offset = first_body_line - 1` — equals the def line
2069        // for multi-line `f() {\n body }` but underflows to 0 for
2070        // INLINE `f() { body }` (def and body share a line). zsh's
2071        // funcsourcetrace reports the def line as 1-based, so clamp
2072        // to >= 1 to handle the inline case without rebuilding
2073        // line tracking through the parser. Bug #396.
2074        let synth_lineno =
2075            std::cmp::max(1i64, self.function_line_base.get(name).copied().unwrap_or(0));
2076        let mut synth_shf = crate::ported::zsh_h::shfunc {
2077            node: crate::ported::zsh_h::hashnode {
2078                next: None,
2079                nam: display_name.clone(),
2080                flags: 0,
2081            },
2082            filename: synth_filename,
2083            lineno: synth_lineno,
2084            funcdef: None,
2085            redir: None,
2086            sticky: None,
2087            body: None,
2088        };
2089        // doshargs: C convention — argv[0] = function name (for
2090        // FUNCTIONARGZERO `$0`), argv[1..] = real positional args.
2091        let mut doshargs: Vec<String> = vec![display_name.clone()];
2092        doshargs.extend(args.iter().cloned());
2093
2094        // Seed `$?` with the parent's last status — C zsh's
2095        // doshfunc inherits lastval automatically because it's a
2096        // process-global; the fusevm VM creates a fresh
2097        // `vm.last_status = 0` per call, so we mirror the inherit
2098        // explicitly. Without this, a function reading `$?` BEFORE
2099        // running any command sees 0 instead of the caller's status.
2100        let seed_status = self.last_status();
2101        let body_args: Vec<String> = args.to_vec();
2102        let body_runner = move || -> i32 {
2103            // Branch: Rust port (direct fn call) or fusevm Chunk
2104            // (autoloaded shell body). Both run INSIDE doshfunc's
2105            // scope so prologue/epilogue applies identically.
2106            if let Some(f) = direct_rust_fn {
2107                return f(&body_args);
2108            }
2109            let chunk = chunk_opt
2110                .as_ref()
2111                .expect("chunk_opt must be Some when direct_rust_fn is None");
2112            crate::fusevm_disasm::maybe_print_stdout(
2113                &format!(
2114                    "function:{}",
2115                    body_args.first().map(|s| s.as_str()).unwrap_or("")
2116                ),
2117                chunk,
2118            );
2119            let mut vm = fusevm::VM::new(chunk.clone());
2120            register_builtins(&mut vm);
2121            vm.last_status = seed_status;
2122            let _ = vm.run();
2123            vm.last_status
2124        };
2125
2126        // Enter executor context BEFORE doshfunc so the body_runner's
2127        // VM builtins can `with_executor(...)` to reach this state.
2128        let _ctx = ExecutorContext::enter(self);
2129        let status = crate::ported::exec::doshfunc(&mut synth_shf, doshargs, false, body_runner);
2130        drop(_ctx);
2131
2132        self.prompt_funcstack.pop();
2133        self.local_scope_depth -= 1;
2134
2135        // Honor explicit `return N` from inside the function body.
2136        if let Some(ret) = self.returning.take() {
2137            self.set_last_status(ret);
2138            Some(ret)
2139        } else {
2140            self.set_last_status(status);
2141            Some(status)
2142        }
2143    }
2144
2145    pub(crate) fn execute_external(
2146        &mut self,
2147        cmd: &str,
2148        args: &[String],
2149        redirects: &[Redirect],
2150    ) -> Result<i32, String> {
2151        self.execute_external_bg(cmd, args, redirects, false)
2152    }
2153
2154    fn execute_external_bg(
2155        &mut self,
2156        cmd: &str,
2157        args: &[String],
2158        _redirects: &[Redirect],
2159        background: bool,
2160    ) -> Result<i32, String> {
2161        tracing::trace!(cmd, bg = background, "exec external");
2162        // c:Src/exec.c:824-876 — when arg0 has no `/`, C zsh requires
2163        // a PATH search. With PATH unset, the search yields no hit
2164        // and C emits `command not found: <cmd>`. Rust's
2165        // `Command::new(name)` delegates to libc `execvp`, which on
2166        // many platforms falls back to a built-in default PATH when
2167        // the env entry is missing — so `unset PATH; ls` still finds
2168        // `/bin/ls` and runs it, breaking the security boundary the
2169        // unset is supposed to establish (#416). Gate explicitly:
2170        // when cmd is a bare name (no `/`) and zshrs's own PATH
2171        // param is unset OR empty, emit the canonical
2172        // "command not found" diagnostic and return 127 BEFORE
2173        // touching libc.
2174        if !cmd.contains('/') {
2175            let path_set_and_nonempty = crate::ported::params::getsparam("PATH")
2176                .map(|p| !p.is_empty())
2177                .unwrap_or(false);
2178            if !path_set_and_nonempty {
2179                let sn = crate::ported::utils::scriptname_get()
2180                    .unwrap_or_else(|| "zshrs".to_string());
2181                eprintln!("{}:1: command not found: {}", sn, cmd);
2182                return Ok(127);
2183            }
2184        }
2185        let mut command = Command::new(cmd);
2186        command.args(args);
2187
2188        // Redirect handling lives in fusevm's WithRedirectsBegin/End
2189        // ops at compile time; `_redirects` arrives empty here.
2190
2191        if background {
2192            match command.spawn() {
2193                Ok(child) => {
2194                    let pid = child.id();
2195                    let cmd_str = format!("{} {}", cmd, args.join(" "));
2196                    let job_id = self.jobs.add_job(child, cmd_str, JobState::Running);
2197                    println!("[{}] {}", job_id, pid);
2198                    Ok(0)
2199                }
2200                Err(e) => {
2201                    let sn = crate::ported::utils::scriptname_get()
2202                        .unwrap_or_else(|| "zshrs".to_string());
2203                    if e.kind() == io::ErrorKind::NotFound {
2204                        // zsh: absolute paths emit "no such file or
2205                        // directory" (the OS error, since the path was
2206                        // tried directly), not "command not found"
2207                        // (which implies PATH search).
2208                        if cmd.starts_with('/') {
2209                            eprintln!("{}:1: no such file or directory: {}", sn, cmd);
2210                        } else {
2211                            eprintln!("{}:1: command not found: {}", sn, cmd);
2212                        }
2213                        Ok(127)
2214                    } else {
2215                        Err(format!("{}: {}: {}", sn, cmd, e))
2216                    }
2217                }
2218            }
2219        } else {
2220            match command.status() {
2221                Ok(status) => Ok(status.code().unwrap_or(1)),
2222                Err(e) => {
2223                    // Use scriptname (the user-visible shell identifier
2224                    // — "zsh" in --zsh mode, "zshrs" otherwise) instead
2225                    // of a hardcoded "zshrs:" prefix so --zsh-mode
2226                    // diagnostics byte-match C zsh's stderr format.
2227                    let sn = crate::ported::utils::scriptname_get()
2228                        .unwrap_or_else(|| "zshrs".to_string());
2229                    if e.kind() == io::ErrorKind::NotFound {
2230                        // c:Src/exec.c — `command_not_found_handler` user
2231                        // hook: when a command lookup fails AND a function
2232                        // by that name is defined, call it with the cmd
2233                        // name + original args and return its rc instead
2234                        // of the default 127 + "command not found" error.
2235                        // Documented in zshmisc(1) under "Special
2236                        // Functions". Bug #426.
2237                        //
2238                        // The hook only fires for bare names (PATH search
2239                        // failed); absolute paths skip it and emit the
2240                        // OS-error path below — matches zsh behavior.
2241                        if !cmd.starts_with('/') {
2242                            let mut hook_args = Vec::with_capacity(args.len() + 1);
2243                            hook_args.push(cmd.to_string());
2244                            hook_args.extend_from_slice(args);
2245                            if let Some(rc) = self.dispatch_function_call(
2246                                "command_not_found_handler",
2247                                &hook_args,
2248                            ) {
2249                                return Ok(rc);
2250                            }
2251                        }
2252                        // zsh: absolute paths emit "no such file or
2253                        // directory" (the OS error, since the path was
2254                        // tried directly), not "command not found"
2255                        // (which implies PATH search).
2256                        if cmd.starts_with('/') {
2257                            eprintln!("{}:1: no such file or directory: {}", sn, cmd);
2258                        } else {
2259                            eprintln!("{}:1: command not found: {}", sn, cmd);
2260                        }
2261                        Ok(127)
2262                    } else if e.kind() == io::ErrorKind::PermissionDenied {
2263                        // zsh: non-executable file → "permission denied"
2264                        // on stderr and exit 126 (POSIX "command found
2265                        // but not executable").
2266                        eprintln!("{}:1: permission denied: {}", sn, cmd);
2267                        Ok(126)
2268                    } else {
2269                        Err(format!("{}: {}: {}", sn, cmd, e))
2270                    }
2271                }
2272            }
2273        }
2274    }
2275    /// Parse `cmd_str` via parse_init+parse and pull out the first Simple
2276    /// command's words, untokenized + variable-expanded, ready to spawn
2277    /// as argv. Used by process-substitution where we need raw argv to
2278    /// hand to `Command::new`. Returns empty vec if the cmd isn't a
2279    /// simple shape — pipelines / compound forms aren't process-sub
2280    /// friendly anyway.
2281    fn simple_cmd_words(&mut self, cmd_str: &str) -> Vec<String> {
2282        // Mirror Src/init.c-style errflag save/clear/check around the
2283        // parse. Process-sub argv extraction silently bails on syntax
2284        // errors (matches zsh's behavior when the inner command can't
2285        // be parsed).
2286        let saved_errflag = errflag.load(Ordering::Relaxed);
2287        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
2288        crate::ported::parse::parse_init(cmd_str);
2289        let prog = crate::ported::parse::parse();
2290        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
2291        errflag.store(saved_errflag, Ordering::Relaxed);
2292        if parse_failed {
2293            return Vec::new();
2294        }
2295        let first = match prog.lists.first() {
2296            Some(l) => l,
2297            None => return Vec::new(),
2298        };
2299        let pipe = &first.sublist.pipe;
2300        if let crate::parse::ZshCommand::Simple(simple) = &pipe.cmd {
2301            simple
2302                .words
2303                .iter()
2304                .map(|w| {
2305                    // Untokenize then variable-expand — text-based
2306                    // word expansion for the spawned argv.
2307                    let untoked = crate::lex::untokenize(w);
2308                    singsub(&untoked)
2309                })
2310                .collect()
2311        } else {
2312            Vec::new()
2313        }
2314    }
2315    /// `run_command_substitution` — see implementation.
2316    pub fn run_command_substitution(&mut self, cmd_str: &str) -> String {
2317        // `$(< FILE)` — zsh shorthand for "read FILE contents". Faster
2318        // than spawning `cat`. The leading `<` (after stripping
2319        // whitespace) means "read this file". Trailing newline is
2320        // stripped (same as command-substitution).
2321        let trimmed = cmd_str.trim_start();
2322        // Only treat as `$(<file)` shorthand when the SINGLE leading `<`
2323        // is followed by a filename, not another `<`. `$(<<<"hi" cat)`
2324        // starts with `<<<` (here-string) and must go through the full
2325        // parse path, not the read-file shortcut.
2326        if let Some(rest) = trimmed.strip_prefix('<').filter(|s| !s.starts_with('<')) {
2327            let filename = rest.trim();
2328            // c:Src/lex.c — the `$(<file)` shortcut ONLY applies when
2329            // the body is exactly `<` + ONE word. Anything else (extra
2330            // args, redirects, semicolons, pipes) is a regular command
2331            // list and must go through the full parse path so `2>/dev/null`
2332            // / `>file` / `|cmd` / `; next` etc. work. Without this
2333            // gate, `$(< file 2>/dev/null)` treated `file 2>/dev/null`
2334            // as the literal filename and errored on the missing file.
2335            // Bug #615.
2336            let is_single_word = !filename.is_empty()
2337                && !filename.chars().any(|c| {
2338                    matches!(c,
2339                        ' ' | '\t' | '\n' | ';' | '&' | '|' |
2340                        '<' | '>' | '(' | ')' | '`' | '"' | '\''
2341                    )
2342                });
2343            if is_single_word {
2344                // Expand any leading $ / tilde in the filename so
2345                // `$(< $f)` and `$(< ~/x)` work.
2346                let resolved = if filename.contains('$') || filename.starts_with('~') {
2347                    singsub(filename)
2348                } else {
2349                    filename.to_string()
2350                };
2351                let resolved = resolved.to_string();
2352                match fs::read_to_string(&resolved) {
2353                    Ok(contents) => {
2354                        return contents.trim_end_matches('\n').to_string();
2355                    }
2356                    Err(_) => {
2357                        eprintln!("zshrs:1: no such file or directory: {}", resolved);
2358                        return String::new();
2359                    }
2360                }
2361            }
2362            // Multi-word / has-redirects → fall through to full parse.
2363        }
2364
2365        // Port of getoutput(char *cmd, int qt) from Src/exec.c. Parse and compile via
2366        // the lex+parse free ported + ZshCompiler pipeline, run on a
2367        // sub-VM with the host wired up. Stdout is captured through
2368        // an in-process pipe via dup2 — no fork. The sub-VM emits
2369        // Op::Exec for unknown command names, which forks/execs
2370        // through the host.
2371
2372        // Set up the stdout-capture pipe. We dup the original stdout
2373        // so post-run we can restore it; the write end is dup2'd onto
2374        // STDOUT_FILENO so all output the sub-VM emits (including from
2375        // forked children, which inherit fd 1) lands in the pipe.
2376        let (read_fd, write_fd) = {
2377            let mut fds = [0i32; 2];
2378            if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
2379                return String::new();
2380            }
2381            (fds[0], fds[1])
2382        };
2383        let saved_stdout = unsafe { libc::dup(libc::STDOUT_FILENO) };
2384        if saved_stdout < 0 {
2385            unsafe {
2386                libc::close(read_fd);
2387                libc::close(write_fd);
2388            }
2389            return String::new();
2390        }
2391        // Flush Rust's stdout BufWriter against the ORIGINAL fd before
2392        // dup2 swaps fd 1 to the capture pipe. Without this, bytes left
2393        // buffered by a prior `print -n` get drained to fd 1 AFTER the
2394        // dup2, which routes them into the cmd-subst's pipe — they end
2395        // up in the captured result and disappear from terminal output.
2396        //
2397        // Bug #10 in docs/BUGS.md — `print -n "A"; v=$(true); print -n
2398        // "B"; v=$(true); print -n "C"; echo` printed only `C` because
2399        // `A` and `B` were redirected into the empty cmd-subst's pipe
2400        // and discarded as its "output". C zsh's getoutput() forks, so
2401        // the child inherits the buffer COPY and the parent's buffer
2402        // stays untouched; zshrs runs cmd-subst in-process so the
2403        // parent buffer is the only one — must flush before the swap.
2404        let _ = io::stdout().flush();
2405        // c:Bug #56 — publish the saved outer stdout so a trap firing
2406        // during the nested run routes body output to the parent's
2407        // real stdout instead of the cmdsub's pipe-bound fd 1.
2408        let saved_stderr_for_trap = unsafe { libc::dup(libc::STDERR_FILENO) };
2409        crate::fusevm_bridge::CMDSUBST_OUTER_FDS.with(|s| {
2410            s.borrow_mut()
2411                .push((saved_stdout, saved_stderr_for_trap))
2412        });
2413        unsafe {
2414            libc::dup2(write_fd, libc::STDOUT_FILENO);
2415            libc::close(write_fd);
2416        }
2417
2418        // Parse + compile + run.
2419        // Push CS_CMDSUBST for `%_` xtrace prefix — direct port of
2420        // Src/exec.c:4783 `cmdpush(CS_CMDSUBST);` around execode().
2421        // Trace lines emitted by the inner program inherit this token
2422        // so their PS4 prefix shows "cmdsubst" matching zsh -x.
2423        cmdpush(crate::ported::zsh_h::CS_CMDSUBST as u8); // c:zsh.h:2799
2424                                                          // Save LINENO so the inner cmdsubst's line counter doesn't
2425                                                          // leak into the outer trace — direct port of Src/exec.c:1407
2426                                                          // `oldlineno = lineno;` followed by `lineno = oldlineno;`
2427                                                          // restore at line 1640. Inner program parses fresh as line 1
2428                                                          // and increments from there; once it returns, the outer
2429                                                          // line at the `$(…)` site must read the original outer
2430                                                          // lineno (so xtrace renders `+:5:> echo …` not `+:1:> …`).
2431        let saved_lineno = getsparam("LINENO");
2432        // Anchor the inner program's lineno to the outer's current
2433        // $LINENO so xtrace inside the cmdsubst renders the outer
2434        // line. zsh's execlist preserves lineno across the inner
2435        // exec — for our sub-VM (fresh compile) we use lineno_addend
2436        // to shift inner's line N → outer_lineno + (N - 1).
2437        let outer_lineno: u64 = self
2438            .scalar("LINENO")
2439            .and_then(|s| s.parse::<u64>().ok())
2440            .unwrap_or(0);
2441        // Mirror Src/init.c errflag save/clear/check pattern around
2442        // the nested parse so an inner syntax error doesn't bleed into
2443        // the outer execution.
2444        let saved_errflag = errflag.load(Ordering::Relaxed);
2445        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
2446        crate::ported::parse::parse_init(cmd_str);
2447        let parsed = crate::ported::parse::parse();
2448        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
2449        errflag.store(saved_errflag, Ordering::Relaxed);
2450        let prog = if parse_failed { None } else { Some(parsed) };
2451        let mut cmd_status: Option<i32> = None;
2452        if let Some(prog) = prog {
2453            let mut compiler = crate::compile_zsh::ZshCompiler::new();
2454            compiler.lineno_addend = outer_lineno.saturating_sub(1);
2455            let chunk = compiler.compile(&prog);
2456            if !chunk.ops.is_empty() {
2457                crate::fusevm_disasm::maybe_print_stdout("run_command_substitution", &chunk);
2458                // c:Src/exec.c:4783 — `$(...)` runs in a subshell, so
2459                // assignments / setopt / cd / trap changes inside
2460                // mustn't leak to the parent. zsh forks; we run
2461                // in-process and snapshot/restore manually. Same
2462                // snapshot shape used by host_subshell_begin/end for
2463                // the `(...)` subshell form.
2464                let paramtab_snap = crate::ported::params::paramtab()
2465                    .read()
2466                    .ok()
2467                    .map(|t| t.clone())
2468                    .unwrap_or_default();
2469                let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
2470                    .lock()
2471                    .ok()
2472                    .map(|m| m.clone())
2473                    .unwrap_or_default();
2474                let pparams_snap = self.pparams();
2475                let opts_snap = crate::ported::options::opt_state_snapshot();
2476                let traps_snap = crate::ported::builtin::traps_table()
2477                    .lock()
2478                    .map(|t| t.clone())
2479                    .unwrap_or_default();
2480                // c:Src/exec.c:4783 — function definitions / unfunction
2481                // inside `$(...)` must also be isolated from the parent.
2482                // C zsh's getoutput() forks, so the child's shfunctab
2483                // mutations die with the child. zshrs's in-process
2484                // cmd-subst needs to snapshot/restore the function
2485                // tables manually alongside the param/opts/trap snaps
2486                // already in this block. Bug #455.
2487                let shfunctab_snap = crate::ported::hashtable::shfunctab_lock()
2488                    .read()
2489                    .ok()
2490                    .map(|t| t.snapshot())
2491                    .unwrap_or_default();
2492                let functions_compiled_snap = self.functions_compiled.clone();
2493                let function_source_snap = self.function_source.clone();
2494                let mut vm = fusevm::VM::new(chunk);
2495                register_builtins(&mut vm);
2496                vm.set_shell_host(Box::new(ZshrsHost));
2497                // Seed inner $? with the outer's last_status so the
2498                // sub-shell inherits the parent's exit code. Direct
2499                // port of Src/exec.c:4783 around execcmd_exec — the
2500                // child inherits `lastval` at fork time, so `false;
2501                // echo $(echo $?)` reads 1, not the freshly-zeroed
2502                // sub-VM default. Without this, every cmd-subst
2503                // started with $?==0 regardless of the parent's
2504                // last command.
2505                vm.last_status = self.last_status();
2506                // `exit N` inside a cmd-subst should terminate ONLY
2507                // the sub-shell (C zsh: cmd-subst forks, the child
2508                // `_exit(N)`s; status reaches the parent as
2509                // cmd-subst exit). zshrs runs in-process, so we
2510                // route through the SUBSHELL_DEPTH-gated deferred
2511                // path inside zexit (builtin.rs:7713): bump
2512                // SUBSHELL_DEPTH so `exit` sets EXIT_PENDING/
2513                // EXIT_VAL instead of calling realexit (which would
2514                // process::exit and kill the parent shell). After
2515                // the sub-VM returns, harvest EXIT_PENDING/EXIT_VAL
2516                // as the cmd-subst's status, then restore the
2517                // parent's flags so the outer VM continues normally.
2518                use crate::ported::builtin::{
2519                    BREAKS, EXIT_PENDING, EXIT_VAL, RETFLAG, SHELL_EXITING, SUBSHELL_DEPTH,
2520                };
2521                use std::sync::atomic::Ordering::Relaxed;
2522                let saved_exit_pending = EXIT_PENDING.swap(0, Relaxed);
2523                let saved_exit_val = EXIT_VAL.swap(0, Relaxed);
2524                let saved_shell_exiting = SHELL_EXITING.swap(0, Relaxed);
2525                let saved_retflag = RETFLAG.swap(0, Relaxed);
2526                let saved_breaks = BREAKS.swap(0, Relaxed);
2527                SUBSHELL_DEPTH.fetch_add(1, Relaxed);
2528                let _ctx = ExecutorContext::enter(self);
2529                let _ = vm.run();
2530                let inner_exit_pending = EXIT_PENDING.load(Relaxed);
2531                let inner_exit_val = EXIT_VAL.load(Relaxed);
2532                let inner_status = if inner_exit_pending != 0 {
2533                    inner_exit_val & 0xFF
2534                } else {
2535                    vm.last_status
2536                };
2537                cmd_status = Some(inner_status);
2538                SUBSHELL_DEPTH.fetch_sub(1, Relaxed);
2539                // c:Src/exec.c:4783 execcmdoutsubst — `$(...)` is a
2540                // subshell, and zsh fires the EXIT trap when the
2541                // subshell ends BUT only if the trap was installed
2542                // INSIDE the subshell. An EXIT trap inherited from
2543                // the parent fires when the parent shell exits, not
2544                // again at cmdsub end. Detect "installed inside" by
2545                // comparing the current traps_table["EXIT"] entry
2546                // against the pre-cmdsub snapshot — fire only when
2547                // the body differs (newly set, removed, or replaced).
2548                // Pop the body before execute_script to avoid the
2549                // re-fire inside execute_script_zsh_pipeline's own
2550                // EXIT-handler tail at vm_helper.rs:1490. Bug #354.
2551                let snap_exit = traps_snap.get("EXIT").cloned();
2552                let live_exit = crate::ported::builtin::traps_table()
2553                    .lock()
2554                    .ok()
2555                    .and_then(|t| t.get("EXIT").cloned());
2556                if live_exit != snap_exit {
2557                    if let Some(body) = live_exit {
2558                        if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
2559                            t.remove("EXIT");
2560                        }
2561                        let _ = crate::ported::exec_hooks::execute_script(&body);
2562                    }
2563                }
2564                // c:Src/signals.c::dotrap(SIGEXIT) — also fire the
2565                // TRAPEXIT() function-named form (ZSIG_FUNC) — but
2566                // only if it was defined INSIDE the subshell (the
2567                // parent's TRAPEXIT fires at parent exit, not here).
2568                // ZSIG_FUNC bit on sigtrapped[SIGEXIT] tells us
2569                // whether a TRAPEXIT function is registered; check
2570                // BEFORE the snapshot restore.
2571                // Skip for now — function-form detection mirrors the
2572                // raw-body check above; deferred until a clean
2573                // sigtrapped snapshot/restore pair exists.
2574                // Restore parent's exit / loop / function-return
2575                // state so the outer VM continues normally.
2576                EXIT_PENDING.store(saved_exit_pending, Relaxed);
2577                EXIT_VAL.store(saved_exit_val, Relaxed);
2578                SHELL_EXITING.store(saved_shell_exiting, Relaxed);
2579                RETFLAG.store(saved_retflag, Relaxed);
2580                BREAKS.store(saved_breaks, Relaxed);
2581                // Restore parent state. The inner cmd-subst's stdout
2582                // (the captured pipe contents) is the only thing
2583                // that leaks out.
2584                if let Ok(mut t) = crate::ported::params::paramtab().write() {
2585                    *t = paramtab_snap;
2586                }
2587                if let Ok(mut m) = crate::ported::params::paramtab_hashed_storage().lock() {
2588                    *m = paramtab_hashed_snap;
2589                }
2590                self.set_pparams(pparams_snap);
2591                crate::ported::options::opt_state_restore(opts_snap);
2592                if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
2593                    *t = traps_snap;
2594                }
2595                // Restore function tables (parallel to the trap/param
2596                // restore above). Bug #455.
2597                if let Ok(mut t) = crate::ported::hashtable::shfunctab_lock().write() {
2598                    t.restore(shfunctab_snap);
2599                }
2600                self.functions_compiled = functions_compiled_snap;
2601                self.function_source = function_source_snap;
2602            }
2603        }
2604        // Restore LINENO so outer xtrace sees the outer line.
2605        if let Some(ln) = saved_lineno {
2606            self.set_scalar("LINENO".to_string(), ln);
2607        }
2608        cmdpop();
2609        // Propagate the inner cmd's status to the parent shell. zsh:
2610        // `a=$(false); echo $?` → 1 because cmd-subst status leaks to
2611        // $?. Set last_status on the executor so $? reads the right
2612        // value for callers that don't have a SetStatus(0) overwrite
2613        // (echo, test, etc.). Bare assignment paths still get the
2614        // SetStatus(0) from compile_simple — that's a separate gap.
2615        // Empty cmd-subst (`\`\``, `$()`) resets status to 0 per
2616        // Src/exec.c — the inner ran no command so the "last
2617        // command's exit" is the implicit success of "did nothing".
2618        // Without this branch, a prior command's non-zero status
2619        // leaked through the empty cmd-subst.
2620        let final_status = cmd_status.unwrap_or(0);
2621        self.set_last_status(final_status);
2622        // c:Src/exec.c:4775 — `getoutput` (the C cmd-subst path used by
2623        // both `$(…)` and `` `…` ``) propagates the inner exit through
2624        // `cmdoutval`, then the caller does `LASTVAL = cmdoutval`. Mirror
2625        // by writing the cmd-subst's exit into the ported `cmdoutval`
2626        // global so `getoutput()`'s post-call `LASTVAL = cmdoutval` (at
2627        // exec.rs:559-562) and the C-equivalent `cmdoutval = lastval`
2628        // bookkeeping in execcmd_exec's assignment paths both see the
2629        // real exit. Without this, backtick assignments (`a=\`false\`;
2630        // echo $?`) reported 0 because getoutput's caller path read a
2631        // cmdoutval that was never updated by the in-process hook.
2632        crate::ported::exec::cmdoutval.store(final_status, std::sync::atomic::Ordering::Relaxed);
2633
2634        // Flush any buffered Rust-side stdout so it reaches the pipe
2635        // before we restore.
2636        let _ = io::stdout().flush();
2637
2638        // Pop the trap-routing stack BEFORE restoring stdout so any
2639        // trap that fires during the restore goes to the cmdsub's
2640        // pipe (matching what zsh's forked cmdsub would do — the
2641        // child's fd 1 is the pipe right up until the child exits).
2642        crate::fusevm_bridge::CMDSUBST_OUTER_FDS.with(|s| {
2643            s.borrow_mut().pop();
2644        });
2645        // c:Bug #353 — restore fd 2 from the saved outer stderr. A
2646        // body that ran `exec 2>&1` (no command, just redirects)
2647        // would have committed fd 2 → the cmdsub's pipe write end.
2648        // In zsh's forked cmdsub the committed redirect dies with
2649        // the child; zshrs's in-process cmdsub would leak the dup
2650        // back to the parent and keep the pipe write-end alive,
2651        // blocking the parent's read on the read_end forever.
2652        // Always restoring fd 2 here rolls back any commit so the
2653        // pipe write-end count drops to zero when we drop the
2654        // local write_fd reference (which already happened above).
2655        if saved_stderr_for_trap >= 0 {
2656            unsafe {
2657                libc::dup2(saved_stderr_for_trap, libc::STDERR_FILENO);
2658                libc::close(saved_stderr_for_trap);
2659            }
2660        }
2661        // Restore stdout and read what was captured.
2662        unsafe {
2663            libc::dup2(saved_stdout, libc::STDOUT_FILENO);
2664            libc::close(saved_stdout);
2665        }
2666        let read_file = unsafe { File::from_raw_fd(read_fd) };
2667        let mut output = String::new();
2668        let _ = io::BufReader::new(read_file).read_to_string(&mut output);
2669
2670        // POSIX: trailing newlines stripped from cmd-sub result.
2671        while output.ends_with('\n') {
2672            output.pop();
2673        }
2674        output
2675    }
2676}
2677
2678#[cfg(test)]
2679mod tests {
2680    use super::*;
2681
2682    #[test]
2683    fn test_simple_echo() {
2684        let _g = crate::test_util::global_state_lock();
2685        let mut exec = ShellExecutor::new();
2686        let status = exec.execute_script("true").unwrap();
2687        assert_eq!(status, 0);
2688    }
2689
2690    #[test]
2691    fn test_if_true() {
2692        let _g = crate::test_util::global_state_lock();
2693        let mut exec = ShellExecutor::new();
2694        let status = exec.execute_script("if true; then true; fi").unwrap();
2695        assert_eq!(status, 0);
2696    }
2697
2698    #[test]
2699    fn test_if_false() {
2700        let _g = crate::test_util::global_state_lock();
2701        let mut exec = ShellExecutor::new();
2702        let status = exec
2703            .execute_script("if false; then true; else false; fi")
2704            .unwrap();
2705        assert_eq!(status, 1);
2706    }
2707
2708    #[test]
2709    fn test_for_loop() {
2710        let _g = crate::test_util::global_state_lock();
2711        let mut exec = ShellExecutor::new();
2712        exec.execute_script("for i in a b c; do true; done")
2713            .unwrap();
2714        assert_eq!(exec.last_status(), 0);
2715    }
2716
2717    #[test]
2718    fn test_and_list() {
2719        let _g = crate::test_util::global_state_lock();
2720        let mut exec = ShellExecutor::new();
2721        let status = exec.execute_script("true && true").unwrap();
2722        assert_eq!(status, 0);
2723
2724        let status = exec.execute_script("true && false").unwrap();
2725        assert_eq!(status, 1);
2726    }
2727
2728    #[test]
2729    fn test_or_list() {
2730        let _g = crate::test_util::global_state_lock();
2731        let mut exec = ShellExecutor::new();
2732        let status = exec.execute_script("false || true").unwrap();
2733        assert_eq!(status, 0);
2734    }
2735
2736    /// Pin: `forklevel` matches the C global declared at
2737    /// `Src/exec.c:1052` (`int forklevel;`). Like `int` in C, the
2738    /// Rust port is an AtomicI32 starting at 0 (no fork has occurred
2739    /// at process start). Per `Src/exec.c:1221` (`forklevel =
2740    /// locallevel;`), every subshell entry copies `locallevel` into
2741    /// the global; the SIGPIPE handler at `Src/signals.c:808` reads
2742    /// it back to distinguish the top-level shell from a subshell.
2743    #[test]
2744    fn test_forklevel_default_zero_and_roundtrip() {
2745        let _g = crate::test_util::global_state_lock();
2746        use std::sync::atomic::Ordering;
2747        let prev = crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed);
2748        // Default state at process start: zero (matches C's BSS init
2749        // of `int forklevel;` to 0).
2750        crate::ported::exec::FORKLEVEL.store(0, Ordering::Relaxed);
2751        assert_eq!(crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed), 0);
2752        // Simulate the c:1221 store: `forklevel = locallevel;`.
2753        crate::ported::exec::FORKLEVEL.store(3, Ordering::Relaxed);
2754        assert_eq!(crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed), 3);
2755        crate::ported::exec::FORKLEVEL.store(prev, Ordering::Relaxed);
2756    }
2757}
2758
2759// Plugin-Framework-Agnostic State-Modification Recorder hook helpers.
2760/// Recorder helper: emit one record for an array/scalar mutation
2761/// targeting a path-family parameter (path/fpath/manpath/module_path/
2762/// cdpath, lower- or upper-cased), or one `assign` record for any
2763/// other name. Centralises the path-family list so `BUILTIN_SET_ARRAY`,
2764/// `BUILTIN_APPEND_ARRAY`, and `BUILTIN_APPEND_SCALAR_OR_PUSH` share
2765/// the same routing.
2766///
2767/// `is_append` distinguishes `arr=(...)` from `arr+=(...)` so the
2768/// emitted event carries the APPEND attr bit and replay can choose
2769/// between fresh-set and extend semantics.
2770///
2771/// `attrs` carries any pre-existing type info from
2772/// `recorder_attrs_for(name)` (readonly/export/global) — array shape
2773/// and APPEND get OR'd in by emit_array_assign.
2774#[cfg(feature = "recorder")]
2775pub(crate) fn emit_path_or_assign(
2776    name: &str,
2777    values: &[String],
2778    attrs: crate::recorder::ParamAttrs,
2779    is_append: bool,
2780    ctx: &crate::recorder::RecordCtx,
2781) {
2782    let lower = name.to_ascii_lowercase();
2783    let kind_name: Option<&'static str> = match lower.as_str() {
2784        "path" => Some("path"),
2785        "fpath" => Some("fpath"),
2786        "manpath" => Some("manpath"),
2787        "module_path" => Some("module_path"),
2788        "cdpath" => Some("cdpath"),
2789        _ => None,
2790    };
2791    match kind_name {
2792        Some(k) => {
2793            for v in values {
2794                crate::recorder::emit_path_mod(v, k, ctx.clone());
2795                // Each fpath addition also surfaces every `_completion`
2796                // file inside the directory — matches zinit-report's
2797                // per-plugin "Completions:" listing. Only fpath dirs
2798                // get this treatment; PATH dirs hold executables, not
2799                // completion functions.
2800                if k == "fpath" {
2801                    crate::recorder::discover_completions_in_fpath_dir(v, ctx);
2802                }
2803            }
2804        }
2805        None => {
2806            // Non-path arrays: emit ONE `assign` event with the
2807            // ordered element list preserved in value_array. Replay
2808            // reconstructs `name=(elem1 elem2 ...)` exactly without
2809            // having to re-split a joined string.
2810            crate::recorder::emit_array_assign(
2811                name,
2812                values.to_vec(),
2813                attrs,
2814                is_append,
2815                ctx.clone(),
2816            );
2817        }
2818    }
2819}
2820
2821use std::os::unix::fs::MetadataExt;
2822
2823bitflags::bitflags! {
2824    /// Flags for zfork()
2825    #[derive(Debug, Clone, Copy, Default)]
2826    pub struct ForkFlags: u32 {
2827        const NOJOB = 1 << 0;    // Don't add to job table
2828        const NEWGRP = 1 << 1;   // Create new process group
2829        const FGTTY = 1 << 2;    // Take foreground terminal
2830        const KEEPSIGS = 1 << 3; // Keep signal handlers
2831    }
2832}
2833
2834bitflags::bitflags! {
2835    /// Flags for entersubsh()
2836    #[derive(Debug, Clone, Copy, Default)]
2837    pub struct SubshellFlags: u32 {
2838        const NOMONITOR = 1 << 0; // Disable job control
2839        const KEEPFDS = 1 << 1;   // Keep file descriptors
2840        const KEEPTRAPS = 1 << 2; // Keep trap handlers
2841    }
2842}
2843
2844/// Result of fork operation
2845#[derive(Debug)]
2846/// `fork()` outcome (parent / child / error).
2847/// Mirrors the integer return of `zfork()` from Src/exec.c:349.
2848pub enum ForkResult {
2849    /// `Parent` variant.
2850    Parent(i32), // Contains child PID
2851    /// `Child` variant.
2852    Child,
2853}
2854
2855/// Redirection mode
2856#[derive(Debug, Clone, Copy)]
2857/// File-redirection mode (`>` / `>>` / `<` / etc.).
2858/// Mirrors the `REDIR_*` enum from Src/zsh.h.
2859pub enum RedirMode {
2860    /// `Dup` variant.
2861    Dup,
2862    /// `Close` variant.
2863    Close,
2864}
2865
2866/// Builtin command type
2867#[derive(Debug, Clone, Copy)]
2868/// Builtin classification.
2869/// Mirrors the `BINF_*` flag set Src/builtin.c uses to
2870/// classify special vs regular builtins.
2871pub enum BuiltinType {
2872    /// `Normal` variant.
2873    Normal,
2874    /// `Disabled` variant.
2875    Disabled,
2876}
2877
2878use crate::fusevm_bridge::with_executor;
2879use crate::ported::glob::*;
2880use crate::ported::hist::*;
2881use crate::ported::jobs::*;
2882use crate::ported::math::*;
2883use crate::ported::module::*;
2884use crate::ported::modules::cap::*;
2885use crate::ported::modules::terminfo::*;
2886use crate::ported::options::*;
2887use crate::ported::params::*;
2888use crate::ported::pattern::*;
2889use crate::ported::prompt::*;
2890use crate::ported::signals::*;
2891use crate::ported::subst::*;
2892use crate::ported::utils::{zerr, zerrnam, zwarn, zwarnnam};
2893use ::regex::{Error as RegexError, Regex, RegexBuilder};
2894
2895impl ShellExecutor {
2896    /// Every option name in `ZSH_OPTIONS_SET` (port of `optns[]` at
2897    /// `Src/options.c:79+`).
2898    pub(crate) fn all_zsh_options() -> Vec<&'static str> {
2899        ZSH_OPTIONS_SET.iter().copied().collect()
2900    }
2901
2902    /// `name → default-on` map via canonical `default_on_options`
2903    /// (port of `defset()` macro at `Src/options.c:73`).
2904    pub(crate) fn default_options() -> HashMap<String, bool> {
2905        let on = default_on_options();
2906        Self::all_zsh_options()
2907            .into_iter()
2908            .map(|n| (n.to_string(), on.contains(n)))
2909            .collect()
2910    }
2911}
2912impl ShellExecutor {
2913    /// PURE PASSTHRU to the canonical `params::getsparam` (C port of
2914    /// `Src/params.c::getsparam`). Every special-name case the old
2915    /// 316-line body handled lives in `params::lookup_special_var` +
2916    /// `getsparam`'s paramtab/env walk. Returns an empty string for
2917    /// unset names (matching the old fn's signature; callers that
2918    /// need the set/unset distinction call `scalar` / `has_scalar`
2919    /// directly).
2920    pub(crate) fn get_variable(&self, name: &str) -> String {
2921        getsparam(name).unwrap_or_default()
2922    }
2923}
2924
2925// Source-form registration step used by the autoload-load path
2926// (`dispatch_function_call` / `run_function_body_only`). Decides
2927// whether to feed the file body to the funcdef pipeline VERBATIM
2928// (zsh-style: the body's own `function NAME() {...}` definition
2929// registers the function on execution) or to WRAP it in
2930// `NAME() {...}` (ksh-style or multi-statement: the body is just
2931// commands or includes additional statements past the def).
2932//
2933// The classification mirrors c:Src/exec.c:5725 + 5750 — KSHAUTOLOAD-
2934// equivalent vs zsh-style autoload. The structural check is the
2935// canonical `stripkshdef` (Src/exec.c:6291, ported at exec.rs:10548):
2936// parse the body to Eprog, run stripkshdef, and check whether it
2937// returned a stripped (different-length wordcode) Eprog. When it
2938// did, the file is the single-funcdef shape `[function] NAME [()] {
2939// INNER }`; running the file source directly through the funcdef
2940// pipeline registers NAME via the WC_FUNCDEF opcode at
2941// fusevm_bridge.rs:6330, matching C's `shf->funcdef = stripkshdef(
2942// prog, name)` semantics (the inner body becomes the function's
2943// body). When it didn't strip — single statement that isn't a
2944// funcdef, or multiple list nodes (e.g. `function ztm() {...}` +
2945// trailing `ztm "$@"` self-call) — we fall back to wrap-and-run so
2946// the canonical funcdef opcode still fires and any extra
2947// statements run inside the registered body, matching C's
2948// behavior of using the whole prog as funcdef in that case.
2949fn autoload_register_source(name: &str, body: &str) -> String {
2950    let stripped = crate::ported::exec::parse_string(body, 0)
2951        .map(|prog| {
2952            let original_len = prog.prog.len();
2953            // stripkshdef returns the input untouched when the prog
2954            // doesn't match the single-`function NAME` shape, and a
2955            // shorter (body-only) prog when it does. Compare the
2956            // wordcode length to detect the strip without owning the
2957            // post-strip Eprog (we only need the yes/no answer here).
2958            let prog_box = Box::new(prog);
2959            crate::ported::exec::stripkshdef(Some(prog_box), name)
2960                .map(|p| p.prog.len() != original_len)
2961                .unwrap_or(false)
2962        })
2963        .unwrap_or(false);
2964    if stripped {
2965        body.to_string()
2966    } else {
2967        format!("{name}() {{\n{body}\n}}")
2968    }
2969}
2970
2971// Push a label onto the static `zsh_eval_context` (port of C's
2972// `Src/exec.c:1251-1266`) AND mirror to the paramtab `zsh_eval_context`
2973// array entry + tied `ZSH_EVAL_CONTEXT` scalar so the shell-visible
2974// expansions reflect the call-chain context. Bug #262 in docs/BUGS.md.
2975// Lives in vm_helper (not src/ported/) because src/ported/ is reserved
2976// for direct C-source ports.
2977pub fn push_zsh_eval_context(label: &str) {
2978    if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
2979        ctx.push(label.to_string());
2980        sync_zsh_eval_context_to_param(&ctx);
2981    }
2982}
2983
2984/// Pop the most recently pushed label from the static and the
2985/// paramtab/scalar mirror. Pairs with `push_zsh_eval_context`.
2986pub fn pop_zsh_eval_context() {
2987    if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
2988        ctx.pop();
2989        sync_zsh_eval_context_to_param(&ctx);
2990    }
2991}
2992
2993/// Replace the shell-visible mirror with the current stack contents.
2994/// Writes the array entry directly (PM_READONLY bypass — same pattern
2995/// the binary's `-c` ZSH_EVAL_CONTEXT init uses at bins/zshrs.rs).
2996fn sync_zsh_eval_context_to_param(stack: &[String]) {
2997    let joined = stack.join(":");
2998    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
2999        if let Some(pm) = tab.get_mut("zsh_eval_context") {
3000            pm.u_arr = Some(stack.to_vec());
3001            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
3002        }
3003        if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
3004            pm.u_str = Some(joined);
3005            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
3006        }
3007    }
3008}
3009
3010impl ShellExecutor {
3011    /// Execute the trap body for a signal name from the REPL signal
3012    /// loop (bins/zshrs.rs CtrlC/CtrlD dispatch). Thin passthru to
3013    /// `traps_table` lookup + `execute_script` — kept as a method
3014    /// because the REPL loop owns `&mut ShellExecutor` and needs a
3015    /// single call point. The async signal-handler dispatch path
3016    /// goes through `crate::ported::signals::dotrap` instead.
3017    pub fn run_trap(&mut self, signal: &str) {
3018        let action = crate::ported::builtin::traps_table()
3019            .lock()
3020            .ok()
3021            .and_then(|t| t.get(signal).cloned());
3022        if let Some(body) = action {
3023            if !body.is_empty() {
3024                let _ = self.execute_script(&body);
3025            }
3026        }
3027    }
3028}
3029
3030impl ShellExecutor {
3031    pub(crate) fn apply_prompt_theme(&mut self, theme: &str, preview: bool) {
3032        let (ps1, rps1) = match theme {
3033            "minimal" => ("%# ", ""),
3034            "off" => ("$ ", ""),
3035            "adam1" => (
3036                "%B%F{cyan}%n@%m %F{blue}%~%f%b %# ",
3037                "%F{yellow}%D{%H:%M}%f",
3038            ),
3039            "redhat" => ("[%n@%m %~]$ ", ""),
3040            _ => ("%n@%m %~ %# ", ""),
3041        };
3042        if preview {
3043            println!("PS1={:?}", ps1);
3044            println!("RPS1={:?}", rps1);
3045        } else {
3046            self.set_scalar("PS1".to_string(), ps1.to_string());
3047            self.set_scalar("RPS1".to_string(), rps1.to_string());
3048            self.set_scalar("prompt_theme".to_string(), theme.to_string());
3049        }
3050    }
3051}
3052impl ShellExecutor {
3053    /// Expand glob pattern via canonical `glob_path` (port of
3054    /// `Src/glob.c::zglob`). Adds executor-side `current_command_glob_failed`
3055    /// cell so the dispatch layer skips the current command on NOMATCH +
3056    /// looks_like_glob instead of exiting the shell.
3057    pub fn expand_glob(&self, pattern: &str) -> Vec<String> {
3058        let expanded = glob_path(pattern);
3059        if !expanded.is_empty() {
3060            return expanded;
3061        }
3062        // No matches. Mirror zsh's `setopt nullglob` / `nomatch`
3063        // dispatch (Src/glob.c:1873-1886) here because glob_path
3064        // returns an empty Vec without knowing executor state.
3065        // c:Src/glob.c:1567-1569 `gf_nullglob` per-glob — the `(N)`
3066        // qualifier acts like `setopt nullglob` for this expression
3067        // alone. parse_qualifiers detects the suffix `(...)` block;
3068        // the resulting `qualifiers.nullglob` mirrors C's gf_nullglob
3069        // carrier.
3070        let per_glob_nullglob = crate::ported::glob::parse_qualifiers(pattern)
3071            .1
3072            .map(|q| q.nullglob)
3073            .unwrap_or(false);
3074        let nullglob = opt_state_get("nullglob").unwrap_or(false) || per_glob_nullglob;
3075        if nullglob {
3076            return Vec::new();
3077        }
3078        let nomatch = opt_state_get("nomatch").unwrap_or(true);
3079        // Use canonical `haswilds` (port of Src/pattern.c:4306-4376)
3080        // instead of the Rust-only `looks_like_glob`. C zsh's
3081        // `Src/glob.c:1876` NOMATCH branch fires whenever the input
3082        // tripped haswilds during the `zglob` entry check —
3083        // including patterns whose internal `(` / `)` form a group
3084        // or alternation but don't end with `)` (e.g. `abc(a)def`,
3085        // `(abc`). The previous `looks_like_glob` only caught
3086        // trailing-`(...)` qualifiers, leaving mid-word groups and
3087        // unclosed parens to fall through to the literal-passthrough
3088        // branch. #170 in docs/BUGS.md.
3089        let is_glob = crate::ported::pattern::haswilds(pattern);
3090        if nomatch && is_glob {
3091            // c:Src/glob.c:1876-1880 — `else if (isset(NOMATCH)) {`
3092            //   `zerr("no matches found: %s", ostr);`
3093            //   `zfree(matchbuf, 0);`
3094            //   `restore_globstate(saved);`
3095            //   `return;`
3096            // `}`
3097            // C aborts via ERRFLAG_ERROR set by zerr() at c:Src/utils.c
3098            // and the matchbuf/state cleanup. The Rust port mirrors
3099            // both: zerr() in utils.rs sets ERRFLAG_ERROR via
3100            // `errflag.fetch_or(ERRFLAG_ERROR, ...)` already; we then
3101            // re-set explicitly (defensive — historically this line
3102            // had `fetch_and(!ERRFLAG_ERROR)` which CLEARED the flag
3103            // immediately after zerr, making `echo /never/*` print
3104            // the literal and exit 0 instead of erroring like zsh —
3105            // parity bug #13).
3106            zerr(&format!("no matches found: {}", pattern)); // c:1877
3107            self.current_command_glob_failed.set(true);
3108            // c:Src/glob.c:1876-1880 — zerr sets ERRFLAG_ERROR and
3109            // glob_failed cell carries the signal. The ERRFLAG_ERROR
3110            // clear (so subsequent sublists run) now lives at the
3111            // dispatcher's post-command-boundary at
3112            // fusevm_bridge.rs:299 where current_command_glob_failed
3113            // is consumed — matches C's execlist behavior of clearing
3114            // command-error errflag between sublists.
3115            return Vec::new(); // c:1880 return
3116        }
3117        // Pattern has no glob meta — pass through literally.
3118        vec![pattern.to_string()]
3119    }
3120    /// True iff the literal `pattern` actually contains a glob metachar
3121    /// in a position that would have triggered globbing. Used to avoid
3122    /// spurious "no matches" errors when expand_glob is called on a
3123    /// plain path that happened to route through this code (e.g. some
3124    /// fast paths bridge unconditionally).
3125    pub(crate) fn looks_like_glob(pattern: &str) -> bool {
3126        // A trailing `(qualifier)` is itself a glob trigger — e.g.
3127        // `path(L+10)` should be treated as a glob even when the
3128        // body has no `*`/`?`/`[...]`.
3129        let has_qual_suffix = if let Some(open) = pattern.rfind('(') {
3130            pattern.ends_with(')') && open + 1 < pattern.len() - 1
3131        } else {
3132            false
3133        };
3134        // Strip trailing `(...)` qualifier so we test the pattern body.
3135        let body = if let Some(open) = pattern.rfind('(') {
3136            if pattern.ends_with(')') {
3137                &pattern[..open]
3138            } else {
3139                pattern
3140            }
3141        } else {
3142            pattern
3143        };
3144        // Walk character-by-character so escaped metachars (`\*`, `\?`,
3145        // `\[`) are NOT counted as glob triggers. zsh: `echo \*` prints
3146        // a literal `*`; without the unescaped check, looks_like_glob
3147        // returned true on the bare `*` and the runtime glob expansion
3148        // aborted with NOMATCH.
3149        let chars: Vec<char> = body.chars().collect();
3150        let mut i = 0;
3151        let mut has_unescaped_star = false;
3152        let mut has_unescaped_question = false;
3153        let mut has_unescaped_bracket_open: Option<usize> = None;
3154        while i < chars.len() {
3155            let c = chars[i];
3156            if c == '\\' && i + 1 < chars.len() {
3157                // Escaped char — skip both.
3158                i += 2;
3159                continue;
3160            }
3161            match c {
3162                '*' => has_unescaped_star = true,
3163                '?' => has_unescaped_question = true,
3164                '[' if has_unescaped_bracket_open.is_none() => {
3165                    has_unescaped_bracket_open = Some(i);
3166                }
3167                _ => {}
3168            }
3169            i += 1;
3170        }
3171        // `[` only counts when there's a matching `]` after it.
3172        let has_bracket_class = has_unescaped_bracket_open
3173            .map(|i| body[i + 1..].contains(']'))
3174            .unwrap_or(false);
3175        // `<N-M>` numeric range glob is also a trigger — match shape
3176        // `<` + optional digits + `-` + optional digits + `>` outside
3177        // any bracket expression.
3178        let has_numeric_range =
3179            body.contains('<') && body.contains('>') && !extract_numeric_ranges(body).is_empty();
3180        has_unescaped_star
3181            || has_unescaped_question
3182            || has_bracket_class
3183            || has_qual_suffix
3184            || has_numeric_range
3185    }
3186}
3187
3188impl ShellExecutor {
3189    pub(crate) fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
3190        if !dest.exists() {
3191            fs::create_dir_all(dest)?;
3192        }
3193        for entry in fs::read_dir(src)? {
3194            let entry = entry?;
3195            let file_type = entry.file_type()?;
3196            let src_path = entry.path();
3197            let dest_path = dest.join(entry.file_name());
3198
3199            if file_type.is_dir() {
3200                Self::copy_dir_recursive(&src_path, &dest_path)?;
3201            } else {
3202                fs::copy(&src_path, &dest_path)?;
3203            }
3204        }
3205        Ok(())
3206    }
3207}
3208
3209// Magic-assoc scan-by-name aggregator. C's per-table getfn/scanfn
3210// pointers in paramdef[] (Src/Modules/parameter.c:825+) handle this
3211// indirectly via paramtab dispatch; this Rust-only helper exposes a
3212// single `partab_get` / `partab_scan_keys` entry that the bridge
3213// uses for name → keys lookup.
3214use std::cell::RefCell;
3215thread_local! {
3216    static SCAN_KEYS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
3217}
3218
3219/// Lookup helper for `${name[key]}` magic-assoc reads — dispatches
3220/// through canonical `PARTAB` (Src/Modules/parameter.c:2235 ports).
3221/// Returns `None` if name isn't a known magic-assoc.
3222pub fn partab_get(name: &str, key: &str) -> Option<String> {
3223    // c:Src/Modules/system.c:902,904 — `sysparams` and `errnos` are
3224    // bound by zsh/system's boot_/setup_ chain. Same for `mapfile`
3225    // from zsh/mapfile. Without explicit `zmodload`, these names
3226    // are unset in zsh; gate the PARTAB dispatch here so they
3227    // resolve via the empty-fallback path (matching ${sysparams[k]:-x}
3228    // taking the default). Bug #69 in docs/BUGS.md.
3229    if let Some(modname) = module_gated_partab_module(name) {
3230        if !crate::ported::module::MODULESTAB.lock().unwrap().is_loaded(modname) {
3231            return None;
3232        }
3233    }
3234    for entry in PARTAB.iter() {
3235        if entry.name == name {
3236            return (entry.getfn)(std::ptr::null_mut(), key).and_then(|p| p.u_str);
3237        }
3238    }
3239    None
3240}
3241
3242/// Returns the owning module name for partab entries that are
3243/// bound by an explicit zmodload — `sysparams`/`errnos` from
3244/// zsh/system, `mapfile` from zsh/mapfile. Other partab entries
3245/// (aliases/commands/functions/...) are part of zsh/main and
3246/// always available.
3247fn module_gated_partab_module(name: &str) -> Option<&'static str> {
3248    match name {
3249        "sysparams" | "errnos" => Some("zsh/system"),
3250        "mapfile" => Some("zsh/mapfile"),
3251        _ => None,
3252    }
3253}
3254
3255/// PM_ARRAY lookup for `${name}` / `${name[N]}` — walks
3256/// PARTAB_ARRAY and dispatches the whole-array getfn (Src/Modules/
3257/// parameter.c:2239-2291 ports). Returns `None` if name isn't a
3258/// known PM_ARRAY magic-assoc.
3259pub fn partab_array_get(name: &str) -> Option<Vec<String>> {
3260    // Bug #69 — gate module-bound PARTAB names on the owning
3261    // module's MOD_LINKED && !MOD_UNLOAD state.
3262    if let Some(modname) = module_gated_partab_module(name) {
3263        if !crate::ported::module::MODULESTAB.lock().unwrap().is_loaded(modname) {
3264            return None;
3265        }
3266    }
3267    for entry in PARTAB_ARRAY.iter() {
3268        if entry.name == name {
3269            return Some((entry.getfn)(std::ptr::null_mut()));
3270        }
3271    }
3272    None
3273}
3274
3275/// Look up a PARTAB_ARRAY entry's flags, OR'd with the implicit
3276/// PM_SPECIAL | PM_HIDE | PM_HIDEVAL the C SPECIALPMDEF macro adds
3277/// (zsh.h:2123). Used by `${(t)name}` so reserved/special arrays
3278/// like `historywords` / `funcstack` report
3279/// `array-readonly-hide-hideval-special` matching zsh exactly.
3280pub fn partab_array_flags(name: &str) -> Option<u32> {
3281    for entry in PARTAB_ARRAY.iter() {
3282        if entry.name == name {
3283            let pm_special = crate::ported::zsh_h::PM_SPECIAL;
3284            let pm_hide = crate::ported::zsh_h::PM_HIDE;
3285            let pm_hideval = crate::ported::zsh_h::PM_HIDEVAL;
3286            return Some(entry.flags as u32 | pm_special | pm_hide | pm_hideval);
3287        }
3288    }
3289    None
3290}
3291
3292/// Scan helper for `${(k)name}` — enumerates keys via canonical
3293/// scanfn, collected into Vec via SCAN_KEYS thread-local.
3294pub fn partab_scan_keys(name: &str) -> Option<Vec<String>> {
3295    // Bug #69 — gate module-bound PARTAB names on the owning
3296    // module's MOD_LINKED && !MOD_UNLOAD state.
3297    if let Some(modname) = module_gated_partab_module(name) {
3298        if !crate::ported::module::MODULESTAB.lock().unwrap().is_loaded(modname) {
3299            return None;
3300        }
3301    }
3302    for entry in PARTAB.iter() {
3303        if entry.name == name {
3304            SCAN_KEYS.with(|k| k.borrow_mut().clear());
3305            fn cb(node: &crate::ported::zsh_h::HashNode, _flags: i32) {
3306                SCAN_KEYS.with(|k| k.borrow_mut().push(node.nam.clone()));
3307            }
3308            (entry.scanfn)(std::ptr::null_mut(), Some(cb), 0);
3309            return Some(SCAN_KEYS.with(|k| k.borrow().clone()));
3310        }
3311    }
3312    None
3313}
3314/// Populate paramtab with PM_SPECIAL placeholder Params for every
3315/// PARTAB / PARTAB_ARRAY entry — Rust-only init helper, no direct
3316/// C counterpart (closest is `handlefeatures` walking `partab[]`
3317/// in `Src/Modules/parameter.c:2341` boot/enables chain).
3318///
3319/// Each magic-assoc name gets a Param with `entry.flags | PM_SPECIAL`.
3320/// Value reads still route through `partab_get` / `partab_array_get`;
3321/// having the Param in paramtab makes `paramtab.get(name)` return
3322/// Some(Param) so `${+name}` / `${(t)name}` / `typeset -p name` see
3323/// the entry. Without this, those reads returned empty for every
3324/// magic-assoc (aliases, commands, functions, etc.).
3325///
3326/// Called from ShellExecutor::new() since zshrs's bin entry skips
3327/// the canonical module-bootstrap chain.
3328pub fn init_partab_params() {
3329    use crate::ported::modules::parameter::{PARTAB, PARTAB_ARRAY};
3330    use crate::ported::zsh_h::{
3331        hashnode, param, Param, PM_HIDE, PM_HIDEVAL, PM_READONLY, PM_SPECIAL,
3332    };
3333    let mut tab = match paramtab().write() {
3334        Ok(t) => t,
3335        Err(_) => return,
3336    };
3337    // c:Src/zsh.h SPECIALPMDEF macro: `flags | PM_SPECIAL | PM_HIDE |
3338    // PM_HIDEVAL`. All magic-assoc/array params get HIDE+HIDEVAL added
3339    // by the macro itself.
3340    //
3341    // PM_READONLY is preserved on the stub for params that legitimately
3342    // need user-write protection (reswords, dis_reswords, patchars,
3343    // dis_patchars — all compute via getfn and have no legitimate
3344    // internal-write path). Other specials that DO have internal-write
3345    // paths (e.g. funcstack from function-call tracking) get the bit
3346    // stripped so the runtime can mutate their u_arr. Bug #374.
3347    let user_protected: &[&str] = &[
3348        "reswords",
3349        "dis_reswords",
3350        "patchars",
3351        "dis_patchars",
3352        "historywords",
3353        "errnos",
3354        "keymaps",
3355    ];
3356    let mk_pm = |name: &str, flags: i32| -> Param {
3357        let keep_readonly = user_protected.contains(&name);
3358        let pre_readonly_mask = if keep_readonly {
3359            !0i32
3360        } else {
3361            !(PM_READONLY as i32)
3362        };
3363        Box::new(param {
3364            node: hashnode {
3365                next: None,
3366                nam: name.to_string(),
3367                flags: (flags & pre_readonly_mask)
3368                    | PM_SPECIAL as i32
3369                    | PM_HIDE as i32
3370                    | PM_HIDEVAL as i32,
3371            },
3372            u_data: 0,
3373            u_arr: None,
3374            u_str: None,
3375            u_val: 0,
3376            u_dval: 0.0,
3377            u_hash: None,
3378            gsu_s: None,
3379            gsu_i: None,
3380            gsu_f: None,
3381            gsu_a: None,
3382            gsu_h: None,
3383            base: 0,
3384            width: 0,
3385            env: None,
3386            ename: None,
3387            old: None,
3388            level: 0,
3389        })
3390    };
3391    // c:Src/Modules/system.c:902,904 + Src/Modules/mapfile.c — these
3392    // params are provided by modules that real zsh requires explicit
3393    // `zmodload` for. Seeding them unconditionally makes
3394    // `${+sysparams}` return 1 by default (bug #69 in docs/BUGS.md),
3395    // diverging from zsh which returns 0 until the user runs
3396    // `zmodload zsh/system`. Skip here; `seed_partab_param` below adds
3397    // them on demand from the module's load path.
3398    let module_gated: &[&str] = &[
3399        "sysparams", // zsh/system
3400        "errnos",    // zsh/system
3401        "mapfile",   // zsh/mapfile
3402    ];
3403    for entry in PARTAB.iter() {
3404        if module_gated.contains(&entry.name) {
3405            continue;
3406        }
3407        tab.insert(entry.name.to_string(), mk_pm(entry.name, entry.flags));
3408    }
3409    for entry in PARTAB_ARRAY.iter() {
3410        if module_gated.contains(&entry.name) {
3411            continue;
3412        }
3413        tab.insert(entry.name.to_string(), mk_pm(entry.name, entry.flags));
3414    }
3415}
3416
3417/// Insert a single PARTAB / PARTAB_ARRAY entry into paramtab. Called
3418/// from `zmodload <module>` once the module's boot completes, so that
3419/// `${+sysparams}` (etc.) flip from 0 → 1 only after explicit load.
3420/// No direct C counterpart — the C path runs through the module's
3421/// `setup_/boot_` chain which adds the SPECIALPMDEF entry via the
3422/// general hashtable machinery. Bug #69 in docs/BUGS.md.
3423pub fn seed_partab_param(name: &str) {
3424    use crate::ported::modules::parameter::{PARTAB, PARTAB_ARRAY};
3425    use crate::ported::zsh_h::{hashnode, param, PM_HIDE, PM_HIDEVAL, PM_READONLY, PM_SPECIAL};
3426    let mut tab = match crate::ported::params::paramtab().write() {
3427        Ok(t) => t,
3428        Err(_) => return,
3429    };
3430    if tab.contains_key(name) {
3431        return; // already seeded
3432    }
3433    let flags = PARTAB
3434        .iter()
3435        .find(|e| e.name == name)
3436        .map(|e| e.flags)
3437        .or_else(|| PARTAB_ARRAY.iter().find(|e| e.name == name).map(|e| e.flags));
3438    let Some(flags) = flags else {
3439        return;
3440    };
3441    let pm = Box::new(param {
3442        node: hashnode {
3443            next: None,
3444            nam: name.to_string(),
3445            flags: (flags & !(PM_READONLY as i32))
3446                | PM_SPECIAL as i32
3447                | PM_HIDE as i32
3448                | PM_HIDEVAL as i32,
3449        },
3450        u_data: 0,
3451        u_arr: None,
3452        u_str: None,
3453        u_val: 0,
3454        u_dval: 0.0,
3455        u_hash: None,
3456        gsu_s: None,
3457        gsu_i: None,
3458        gsu_f: None,
3459        gsu_a: None,
3460        gsu_h: None,
3461        base: 0,
3462        width: 0,
3463        env: None,
3464        ename: None,
3465        old: None,
3466        level: 0,
3467    });
3468    tab.insert(name.to_string(), pm);
3469}
3470
3471/// Names provided by `zsh/system` / `zsh/mapfile` etc. that are
3472/// gated on explicit `zmodload`. Used by the bin_zmodload path to
3473/// re-seed paramtab after the module's boot completes.
3474pub fn module_gated_params_for(module: &str) -> &'static [&'static str] {
3475    match module {
3476        "zsh/system" => &["sysparams", "errnos"],
3477        "zsh/mapfile" => &["mapfile"],
3478        _ => &[],
3479    }
3480}
3481impl ShellExecutor {
3482    /// `enter_posix_mode` — see implementation.
3483    pub fn enter_posix_mode(&mut self) {
3484        self.posix_mode = true;
3485        self.plugin_cache = None;
3486        self.compsys_cache = None;
3487        self.compinit_pending = None;
3488        self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
3489        // Direct call to the canonical `emulate()` port
3490        // (Src/options.c:533) — `-R` semantics = fully=true.
3491        // bin_emulate goes through dispatch_builtin which needs an
3492        // ExecutorContext that isn't set up yet at apply_cli_flags
3493        // time; the underlying emulate() doesn't need one.
3494        crate::ported::options::emulate("sh", true);
3495    }
3496    /// `enter_ksh_mode` — see implementation.
3497    pub fn enter_ksh_mode(&mut self) {
3498        self.plugin_cache = None;
3499        self.compsys_cache = None;
3500        self.compinit_pending = None;
3501        self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
3502        crate::ported::options::emulate("ksh", true);
3503    }
3504}
3505
3506/// Thin (text, pattern) → bool wrapper over the canonical
3507/// `patcompile()` + `pattry()` pair from `Src/pattern.c`. Argument
3508/// order is flipped so callers read naturally. Lives in vm_helper.rs
3509/// (non-port file) as the public convenience entry for extensions
3510/// and the VM bridge; `src/ported/*` files inline the compile+match
3511/// idiom directly to preserve PORT.md Rule 1 faithfulness.
3512pub fn glob_match_static(s: &str, pattern: &str) -> bool {
3513    let Some(prog) = patcompile(pattern, PAT_HEAPDUP as i32, None) else {
3514        return false;
3515    };
3516    // (#b) (GF_BACKREF) — capture-aware path. Use pattryrefs so the
3517    // per-group begin/end offsets surface, then write $match /
3518    // $mbegin / $mend (c:Src/pattern.c GF_BACKREF handling at
3519    // c:775 + c:2417). Falls through to the basic pattry path when
3520    // (#b) isn't on — that matches the previous behaviour exactly
3521    // and avoids the small extra cost (state clone + Vec<i32> alloc)
3522    // for the (#b)-free common case.
3523    let gf = prog.0.globflags;
3524    let has_backref = (gf & crate::ported::zsh_h::GF_BACKREF as i32) != 0;
3525    let matched;
3526    if has_backref {
3527        let mut nump: i32 = 0;
3528        let mut begp: Vec<i32> = Vec::new();
3529        let mut endp: Vec<i32> = Vec::new();
3530        matched = crate::ported::pattern::pattryrefs(
3531            &prog,
3532            s,
3533            s.len() as i32,
3534            -1,
3535            None,
3536            0,
3537            Some(&mut nump),
3538            Some(&mut begp),
3539            Some(&mut endp),
3540        );
3541        if matched {
3542            let n = (nump as usize).min(begp.len()).min(endp.len());
3543            let mut match_arr: Vec<String> = Vec::with_capacity(n);
3544            let mut begin_arr: Vec<String> = Vec::with_capacity(n);
3545            let mut end_arr: Vec<String> = Vec::with_capacity(n);
3546            // KSHARRAYS off → 1-based; on → 0-based. C path:
3547            // `setiparam("MBEGIN", patoffset + !isset(KSHARRAYS))`
3548            // — so the base offset added to each begin/end index.
3549            let ksharrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
3550            let base = if ksharrays { 0 } else { 1 };
3551            for i in 0..n {
3552                let b = begp[i].max(0) as usize;
3553                let e = endp[i].max(0) as usize;
3554                let lo = b.min(s.len());
3555                let hi = e.min(s.len()).max(lo);
3556                match_arr.push(s[lo..hi].to_string());
3557                begin_arr.push((b + base).to_string());
3558                // mend is the INDEX of the last matched char (inclusive),
3559                // so end - 1 + base. For an empty span use the begin
3560                // offset (zsh's setiparam("MEND", mlen + patoffset + ...
3561                // - 1) shape from c:2444-2446).
3562                end_arr.push(((e + base).saturating_sub(1)).to_string());
3563            }
3564            crate::ported::params::setaparam("match", match_arr);
3565            crate::ported::params::setaparam("mbegin", begin_arr);
3566            crate::ported::params::setaparam("mend", end_arr);
3567        }
3568    } else {
3569        matched = pattry(&prog, s);
3570    }
3571    // c:Src/pattern.c GF_MATCHREF — `(#m)pat` writes the matched
3572    // substring to $MATCH on success. In `[[ str == pat ]]` cond
3573    // context the pattern matches the whole string, so on success
3574    // $MATCH = the input.
3575    if matched && pattern.contains("(#m)") {
3576        crate::ported::params::setsparam("MATCH", s);
3577        crate::ported::params::setiparam("MBEGIN", 1);
3578        crate::ported::params::setiparam("MEND", s.chars().count() as i64);
3579    }
3580    matched
3581}