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