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