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