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    // c:Src/params.c:1090-1115 createparam — a `local NAME` / `typeset
119    // NAME` replaces a special's paramtab node with a plain one, so the
120    // name is no longer a hash. Answering here would strand the read on
121    // the O(1) assoc fast path and never reach paramsubst's scalar
122    // subscript arm (`${options[1]}` came back empty for a local).
123    if magic_special_shadowed(resolved.as_str()) {
124        return None;
125    }
126    // c:Src/Zle/complete.c:1272/1411 — `compstate[nmatches]` is a LIVE gsu
127    // integer (`get_nmatches` = `permmatches(0) ? 0 : nmatches`), not stored
128    // data, so the hashed store never held it and every shell-side read
129    // returned the EMPTY string. `_parameters` (`local -i nm=$compstate[
130    // nmatches]` … `(( compstate[nmatches] > nm ))`) therefore always
131    // reported "added nothing" and returned 1 — `unset <TAB>` offered 197
132    // names against zsh's 496 — and the same idiom in `_alternative`,
133    // `_describe` and `_arguments` mis-fired the same way.
134    // The same applies to the other NINE gsu-backed rows
135    // (c:complete.c:1261-1300): list_lines, list_max, unambiguous,
136    // unambiguous_cursor, unambiguous_positions, insert_positions, vared,
137    // all_quotes, ignored. Only `nmatches` was served live here, so
138    // `$compstate[list_lines]` and friends read empty from shell code
139    // where zsh reports a value.
140    if resolved == "compstate" && crate::ported::zle::compcore::LIVE_COMPSTATE_KEYS.contains(&key) {
141        return Some((
142            true,
143            Some(
144                crate::ported::zle::compcore::get_compstate_str(key).unwrap_or_else(|| {
145                    if key == "nmatches" {
146                        "0".to_string()
147                    } else {
148                        String::new()
149                    }
150                }),
151            ),
152        ));
153    }
154    crate::ported::params::paramtab_hashed_storage()
155        .lock()
156        .ok()
157        .and_then(|s| {
158            s.get(resolved.as_str())
159                .map(|m| (m.contains_key(key), m.get(key).cloned()))
160        })
161}
162use crate::ported::utils::{errflag, ERRFLAG_ERROR};
163use crate::ported::zsh_h::PM_UNDEFINED;
164use crate::ported::zsh_h::WC_SIMPLE;
165use crate::ported::zsh_h::{options, MAX_OPS};
166use crate::ported::zsh_h::{PM_ARRAY, PM_HASHED, PM_INTEGER, PM_READONLY};
167use parking_lot::Mutex;
168use std::collections::HashSet;
169use std::ffi::CStr;
170use std::ffi::CString;
171use std::fs;
172use std::io::Read;
173use std::os::unix::ffi::OsStrExt;
174use std::os::unix::fs::FileTypeExt;
175use std::os::unix::fs::PermissionsExt;
176use std::os::unix::io::FromRawFd;
177use std::sync::atomic::AtomicI32;
178use std::sync::atomic::Ordering;
179use std::time::{SystemTime, UNIX_EPOCH};
180use walkdir::WalkDir;
181
182// Backward-compat re-exports for free ported recently relocated to their
183// canonical-C-file Rust modules. Existing call-sites in this file (and
184// elsewhere) still reference these unqualified.
185#[allow(unused_imports)]
186pub(crate) use crate::func_body_fmt::FuncBodyFmt;
187#[allow(unused_imports)]
188pub(crate) use crate::ported::hist::bufferwords as bufferwords_z_tuple;
189#[allow(unused_imports)]
190pub(crate) use crate::ported::math::{parse_assign, parse_compound, parse_pre_inc};
191#[allow(unused_imports)]
192pub use crate::ported::params::convbase as format_int_in_base;
193pub use crate::ported::params::convbase_underscore;
194// `getarrvalue` is already re-exported by `pub use crate::ported::params::*`
195// below; an explicit `pub(crate) use` here only shadowed that public export.
196#[allow(unused_imports)]
197pub(crate) use crate::ported::utils::base64_decode;
198#[allow(unused_imports)]
199pub(crate) use crate::ported::utils::{ispwd, printprompt4, quotedzputs};
200
201pub(crate) use crate::intercepts::intercept_matches;
202/// AOP advice type — before, after, or around.
203pub use crate::intercepts::{AdviceKind, Intercept};
204
205/// Result from background compinit thread.
206pub use crate::compinit_bg::CompInitBgResult;
207use std::io::Write;
208use std::sync::LazyLock;
209
210/// State snapshot for plugin delta computation.
211pub(crate) use crate::plugin_cache::PluginSnapshot;
212
213/// Cached compiled regexes for hot paths
214pub(crate) static REGEX_CACHE: LazyLock<Mutex<HashMap<String, Regex>>> =
215    LazyLock::new(|| Mutex::new(HashMap::with_capacity(64)));
216
217// fusevm VM bridge (extension; not a port of Src/exec.c) lives in
218// src/fusevm_bridge.rs. Re-exports below let the rest of the codebase
219// reference symbols as `crate::ported::exec::X`.
220pub(crate) use crate::fusevm_bridge::ExecutorContext;
221pub use crate::fusevm_bridge::*;
222
223/// `ZSH_VERSION` / `ZSH_PATCHLEVEL` / `ZSH_VERSION_DATE` consts
224/// generated by `build.rs` from `src/zsh/Config/version.mk`. Use
225/// `zsh_version::ZSH_VERSION` etc. at call sites so version bumps
226/// pick up automatically.
227pub mod zsh_version {
228    include!(concat!(env!("OUT_DIR"), "/zsh_version.rs"));
229}
230
231/// Match an intercept pattern against a command name or full command string.
232/// Supports: exact match, glob ("git *", "_*", "*"), or "all".
233
234/// O(1) builtin-name lookup set derived from the canonical
235/// `BUILTINS` table (`src/ported/builtin.rs:122`, the 1:1 port of
236/// `static struct builtin builtins[]` at `Src/builtin.c:40-137`).
237/// Earlier incarnation hardcoded a separate 130-entry list which
238/// drifted whenever new builtins landed in the canonical table — and
239/// shadowed the `fusevm::shell_builtins::BUILTIN_SET` u16 opcode
240/// constant. Renaming to `BUILTIN_NAMES` removes the shadow; the
241/// initialiser walks `BUILTINS` so the set stays in sync.
242///
243/// The hardcoded entries inside `LazyLock::new` below are kept as
244/// the union of: (1) names from `BUILTINS` (walked at first access),
245/// (2) zshrs daemon-side builtins from `ZSHRS_BUILTIN_NAMES`. Both
246/// arms run once at static init.
247pub(crate) static BUILTIN_NAMES: LazyLock<HashSet<String>> = LazyLock::new(|| {
248    let mut s: HashSet<String> = HashSet::new();
249    // Walk the canonical `BUILTINS` table — the 1:1 port of
250    // `static struct builtin builtins[]` at `Src/builtin.c:40-137`
251    // (ported at `src/ported/builtin.rs:122`). Every name in there is
252    // a real zsh builtin; the set stays in sync as new ports land.
253    for b in crate::ported::builtin::BUILTINS.iter() {
254        s.insert(b.node.nam.clone());
255    }
256    // Daemon-side (zshrs-specific extensions).
257    for &n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.iter() {
258        s.insert(n.to_string());
259    }
260    s
261});
262
263use crate::exec_jobs::{JobState, JobTable};
264use crate::parse::{Redirect, RedirectOp, ShellCommand, ShellWord, VarModifier, ZshParamFlag};
265use indexmap::IndexMap;
266use std::collections::HashMap;
267use std::env;
268use std::fs::{File, OpenOptions};
269use std::io;
270use std::path::{Path, PathBuf};
271use std::process::{Child, Command, Stdio};
272
273// Re-exports for call-sites that reference `crate::ported::exec::<Name>`.
274pub use crate::bash_complete::CompSpec;
275pub use crate::ported::builtin::AutoloadFlags;
276pub use crate::ported::modules::zutil::zstyle_entry;
277
278/// One inline-assignment scope (`X=foo Y=bar cmd`).
279///
280/// `saved` holds `(name, prev_var, prev_env)` for each name the
281/// PREFIX assignments touched, so END_INLINE_ENV can put the shell
282/// var and the process env back.
283///
284/// `recording` is what keeps the frame from swallowing assignments
285/// the *command itself* performs. zsh's `addvars()` (Src/exec.c:4142)
286/// walks only the parsed WC_ASSIGN chain, and `save_params`
287/// (Src/exec.c:4410) snapshots only those names; once the command
288/// runs, the save list is closed. The bytecode emits the prefix
289/// assignments between BEGIN_INLINE_ENV and SEAL_INLINE_ENV, and
290/// SEAL clears this flag, so `X=y . file` no longer records (and
291/// then reverts) every global the sourced file assigns.
292pub struct InlineEnvFrame {
293    /// Per-name pre-assignment state: `(name, prev_var, prev_env)`.
294    pub saved: Vec<(String, Option<String>, Option<String>)>,
295    /// True only while the prefix assignments are being executed.
296    pub recording: bool,
297}
298
299impl InlineEnvFrame {
300    /// New frame, open for recording until SEAL_INLINE_ENV runs.
301    pub fn new() -> Self {
302        Self {
303            saved: Vec::new(),
304            recording: true,
305        }
306    }
307}
308
309impl Default for InlineEnvFrame {
310    fn default() -> Self {
311        Self::new()
312    }
313}
314
315/// Snapshot of subshell-isolated state. Captured at `(` entry, restored at
316/// `)` exit. zsh subshell semantics: assignments inside `(…)` don't leak to
317/// the outer scope — and that includes `export`. zsh forks a child for the
318/// subshell so the child's env::set_var dies with the child; without a fork
319/// (zshrs runs subshells in-process for perf), we snapshot+restore the OS
320/// env table around the subshell. Otherwise `(export y=v)` would leak `y`
321/// to the parent shell, breaking every script that uses a subshell to
322/// scope an env override.
323/// Snapshot of mutable executor state across a subshell
324/// boundary.
325/// Port of the `entersubsh()` save/restore Src/exec.c does at
326/// line 1084 — captures everything that must be replaced when a
327/// `(...)` group fires.
328pub struct SubshellSnapshot {
329    /// Snapshot of `paramtab` (the C-canonical parameter store) at
330    /// subshell entry. Step 1 of the unification mirrors writes to
331    /// paramtab, so subshell-scoped assignments now show up there
332    /// too — without this snapshot, restoring only `variables` /
333    /// `arrays` / `assoc_arrays` leaks the subshell's writes to the
334    /// parent via paramtab (e.g. `x=outer; (x=inner); echo $x` returned
335    /// `inner` because paramsubst reads through paramtab).
336    /// Same node storage as the live table (`Src/params.c:854`
337    /// `newparamtable(151, "paramtab")`) so restoring a snapshot restores
338    /// C's bucket-walk order too, not just the name→value mapping.
339    pub paramtab: crate::ported::hashtable::hashtable_nodes<crate::ported::zsh_h::Param>,
340    /// `paramtab_hashed_storage` field.
341    pub paramtab_hashed_storage: crate::cow_map::CowHashMap<String, IndexMap<String, String>>,
342    /// `positional_params` field.
343    pub positional_params: Vec<String>,
344    /// `env_vars` field.
345    pub env_vars: HashMap<String, String>,
346    /// Values of the special parameters whose backing store is a process
347    /// GLOBAL rather than the parameter table — `Src/params.c`'s `char *ifs`
348    /// (IFS), `wordchars`, `home`, `histsiz`, … — each reached through a GSU
349    /// getfn/setfn pair (the dispatch list at params.rs:12548).
350    ///
351    /// The `paramtab` snapshot above restores the param NODE, but the node only
352    /// carries the GSU pair; the value itself lives in the global, which a
353    /// paramtab restore doesn't touch. C forks for `(...)`, so a child's writes
354    /// to those globals die with it. zshrs runs subshells in-process, so
355    /// `(IFS=,; :)` left the PARENT's IFS as `,` — and every later word-split
356    /// in the parent silently used it. Same fork-copy reasoning as `opts` /
357    /// `umask` / `aliases` above.
358    pub special_globals: Vec<(String, String)>,
359    /// Parent's `zstyletab` at subshell entry (Src/Modules/zutil.c:106
360    /// `static HashTable zstyletab`). C forks for `(...)`, so a
361    /// `zstyle` set inside the subshell dies with the child. zshrs runs
362    /// subshells in-process, so a subshell-scoped `zstyle` leaked into
363    /// the parent AND — because `setstypat` (c:388-396) inserts a
364    /// same-weight pattern AFTER the already-present ones — a second
365    /// subshell re-defining the same (context, style) pair only
366    /// REPLACED the leaked entry instead of establishing a fresh
367    /// definition order. Same fork-copy reasoning as `aliases` /
368    /// `shfuncs` / `modules`.
369    pub zstyles: crate::ported::modules::zutil::style_table,
370    /// Flock fds (`Src/utils.c:2111` `addlockfd`) live at subshell
371    /// entry. `zsystem flock FILE` keeps the fd open for the life of
372    /// the shell; under C's forked `(...)` the child's fd — and hence
373    /// the lock — dies when the subshell exits. zshrs runs subshells
374    /// in-process, so the lock outlived the subshell and every later
375    /// `zsystem flock` on that file (from a real forked background job)
376    /// blocked forever. Recorded here so `subshell_end` can close the
377    /// fds the subshell itself opened.
378    pub flock_fds: Vec<i32>,
379    /// `loops` / `breaks` / `contflag` at subshell entry
380    /// (c:Src/loop.c, c:Src/builtin.c bin_break). C forks for `(...)`,
381    /// so a `break` executed inside dies with the child and the parent's
382    /// loop runs on: `for i in 1 2; do (break); print after; done` prints
383    /// `after` twice. zshrs runs subshells in-process, so the three
384    /// counters have to be restored by hand at the boundary.
385    pub loop_flags: (i32, i32, i32),
386    /// Process working directory at subshell entry. `cd` inside the
387    /// subshell shouldn't leak to the parent; we restore on End.
388    pub cwd: Option<PathBuf>,
389    /// File-creation mask at subshell entry. zsh forks for `(...)` so
390    /// `umask` set inside dies with the child; we run subshells in
391    /// process so we must restore the mask on End. Otherwise
392    /// `umask 022; (umask 077); umask` shows 077 in the parent.
393    pub umask: u32,
394    /// Parent's traps at subshell entry. zsh's `(trap "echo X" EXIT;
395    /// true)` runs the trap when the subshell exits — BEFORE the parent
396    /// continues. Without this snapshot, the trap inherited from parent
397    /// would fire, OR a trap set inside the subshell would leak to the
398    /// parent's process exit. Restored on subshell_end after the
399    /// subshell's own EXIT trap (if any) has fired. Stores a snapshot
400    /// of `crate::ported::builtin::traps_table()` (canonical).
401    pub traps: HashMap<String, String>,
402    /// Parent's shell options at subshell entry. `(set -e)` /
403    /// `(setopt extendedglob)` mustn't leak; zsh forks the subshell
404    /// so child options die with the child. We run in-process, so we
405    /// must restore the option store on subshell_end.
406    pub opts: HashMap<String, bool>,
407    /// Parent's alias entries at subshell entry. zsh forks for
408    /// `(...)` so `(alias x=y)` inside a subshell dies with the
409    /// child and doesn't leak to the parent. zshrs runs subshells
410    /// in-process, so we must restore the alias table on
411    /// subshell_end. Bug #209 in docs/BUGS.md. Stored as a flat
412    /// Vec<(name, text, flags)> snapshot. The node FLAGS must
413    /// round-trip: ALIAS_GLOBAL / DISABLED distinguish global and
414    /// disabled aliases in the shared aliastab — the previous
415    /// (name, text) shape restored every entry with flags=0, so ANY
416    /// subshell (`(true)`, zsh-z's `(zshz --add … &)` precmd)
417    /// reflagged every global alias to REGULAR in the parent:
418    /// `alias -g` listed nothing and `${+galiases[x]}` went 0 one
419    /// prompt after every define.
420    pub aliases: Vec<(String, String, i32)>,
421    /// Parent's shell-function table at subshell entry. C zsh's
422    /// `entersubsh` (`Src/exec.c`) forks before running the
423    /// subshell body so `(f() { ... })` defining a function dies
424    /// with the child and never leaks to the parent. zshrs runs
425    /// subshells in-process, so we must clone `shfunctab` on entry
426    /// and restore on exit. Bug #208 in docs/BUGS.md. Stored as a
427    /// clone of the whole `shfunc_table` — the bucket layout is part
428    /// of the state, because `${(k)functions}` / `compadd -k functions`
429    /// emit C's bucket-walk order verbatim
430    /// (`Src/Modules/parameter.c:480-481`); rebuilding the table from
431    /// an unordered map on restore would reshuffle that order after
432    /// every `( … )` / `$( … )`.
433    pub shfuncs: std::sync::Arc<crate::ported::hashtable::shfunc_table>,
434    /// Parent's compiled-function chunks at subshell entry. Companion
435    /// to `shfuncs` above — `ShellExecutor.functions_compiled` is the
436    /// runtime dispatch table that `Op::CallFunction` reads through;
437    /// without restoring it, a subshell `(g() { override; })` leaves
438    /// the override bytecode chunk in place so the parent's
439    /// `g` call still runs the override after `subshell_end`
440    /// restored shfunctab. Bug #208 in docs/BUGS.md.
441    pub functions_compiled: HashMap<String, fusevm::Chunk>,
442    /// Parent's function source map at subshell entry. Companion to
443    /// `functions_compiled` so `typeset -f` / `whence` show the
444    /// parent's source after subshell exit, not the subshell's
445    /// overridden body. Bug #208 in docs/BUGS.md.
446    pub function_source: HashMap<String, String>,
447    /// Parent's modulestab `modules` map at subshell entry. zsh forks
448    /// for `(...)` so a `(zmodload zsh/X)` inside the subshell sets
449    /// MOD_INIT_B on the child's modulestab; when the child exits the
450    /// flag dies with it and the parent's modulestab is untouched.
451    /// zshrs runs subshells in-process, so a subshell `zmodload`
452    /// would otherwise flip the parent's `${modules[zsh/X]}` from
453    /// unset to "loaded". Snapshot here and restore on subshell_end.
454    /// Bug #210 in docs/BUGS.md. Stored as `(name → flags)`
455    /// since `module` struct doesn't derive Clone (LinkList/
456    /// Linkedmod) — and the only thing `zmodload` mutates that
457    /// affects introspection is the flags bitmask (MOD_INIT_B
458    /// for loaded, MOD_UNLOAD for unloaded).
459    pub modules: HashMap<String, i32>,
460    /// Parent's THINGYTAB (ZLE widget registry) at subshell entry.
461    /// zsh forks for `(...)` so `zle -N w f` / `zle -D w` inside the
462    /// subshell flip widget bindings only in the child; when the
463    /// child exits the parent's widget table is untouched. zshrs runs
464    /// subshells in-process so a subshell's `zle -D w` would
465    /// otherwise unbind the parent's widget. Bug #453 in docs/BUGS.md.
466    pub thingytab: HashMap<String, crate::ported::zle::zle_thingy::Thingy>,
467    /// Parent's KEYMAPNAMTAB (named keymap registry) at subshell
468    /// entry. Same fork-copy semantics as THINGYTAB — a subshell's
469    /// `bindkey -N km` / `bindkey -D km` mutates only the child's
470    /// keymap registry in C zsh. Bug #454 in docs/BUGS.md.
471    pub keymapnamtab:
472        crate::ported::hashtable::hashtable_nodes<crate::ported::zle::zle_keymap::KeymapName>,
473    /// Parent's `$!` (clone::lastpid) at subshell entry. C zsh forks
474    /// for `(...)`, so a background job started INSIDE the subshell
475    /// sets the child's `lastpid` only — `( : & ); echo $!` prints 0
476    /// in zsh. zshrs runs subshells in-process, so restore on end.
477    pub lastpid: i32,
478    /// Job-control state at subshell entry: (JOBTAB clone, CURJOB,
479    /// PREVJOB, MAXJOB, THISJOB). C zsh forks for `(...)` so any
480    /// `disown` / `wait` / new `&` job inside the subshell mutates the
481    /// CHILD's copy of jobtab and dies with it (Src/exec.c::entersubsh
482    /// fork semantics); the parent's table is untouched. zshrs's
483    /// in-process subshell must snapshot/restore to match — without
484    /// this, `sleep 1 & (disown); jobs` shows an empty table where
485    /// zsh still lists the job. Bug #462.
486    pub jobtab: Vec<crate::ported::zsh_h::job>,
487    /// `curjob` at subshell entry (Src/jobs.c:75 global).
488    pub curjob: i32,
489    /// `prevjob` at subshell entry (Src/jobs.c:80 global).
490    pub prevjob: i32,
491    /// `maxjob` at subshell entry (Src/jobs.c:71 global).
492    pub maxjob: usize,
493    /// `thisjob` at subshell entry (Src/jobs.c:77 global).
494    pub thisjob: i32,
495    /// User-range fds (0-9) at subshell entry: `(fd, saved_dup)`
496    /// pairs where `saved_dup` is an `F_DUPFD >= 10` copy, or -1 when
497    /// the fd was closed at entry. C zsh forks for `(...)` so a bare
498    /// `exec >file` / `exec 3<&-` inside the child dies with it
499    /// (Src/exec.c entersubsh fork semantics); the in-process
500    /// subshell must restore the parent's fd table on End. Without
501    /// this, `(exec >t.log; ...); cat t.log` left the PARENT's fd 1
502    /// pointing at t.log and `cat` looped forever copying the file
503    /// into itself.
504    pub saved_fds: Vec<(i32, i32)>,
505    /// `sigtrapped[]` at subshell entry (Src/signals.c:39). C's
506    /// `entersubsh` clears per-signal trap STATE via `unsettrap(sig)`
507    /// (c:Src/exec.c:1088-1092), which zeroes both the body and the
508    /// sigtrapped flags. zshrs cleared only the body table, so the flags
509    /// desynced: a subshell that dropped a trap body still reported the
510    /// signal as trapped. Snapshot the whole vector so subshell_end can
511    /// restore the parent's exact state (including an inherited
512    /// ZSIG_IGNORED on SIGQUIT).
513    pub sigtrapped: Vec<i32>,
514    /// `subsh` at subshell entry (Src/exec.c:160 global). C's
515    /// `entersubsh` sets `subsh = 1` for a real (non-ESUB_FAKE)
516    /// subshell at c:Src/exec.c:1192-1193, and the forked child
517    /// carries it for the whole body. PRINT_EXIT_VALUE reads it
518    /// (c:4309 `&& !subsh`), which is why zsh prints nothing for
519    /// `setopt printexitvalue; (false)` while still reporting a
520    /// bare `false`. zshrs runs `( … )` in-process, so the flag has
521    /// to be set on entry and restored by hand on End.
522    pub subsh: i32,
523    /// Names of builtins carrying `DISABLED` in `builtintab` at
524    /// subshell entry (c:Src/builtin.c:541-547 `enable`/`disable`
525    /// flip `node.flags & DISABLED`; c:Src/hashtable.c:1097
526    /// `builtintab`). C forks for `(...)`, so a `(disable typeset)`
527    /// marks the flag only in the child's copy of `builtintab` and
528    /// the parent still sees the builtin. zshrs runs subshells
529    /// in-process against the process-global `BUILTINS_DISABLED`
530    /// set, so `( disable typeset ); typeset x=1` reported
531    /// `command not found: typeset` in the PARENT.
532    pub builtins_disabled: std::collections::HashSet<String>,
533    /// Names of reserved words carrying `DISABLED` in `reswdtab` at
534    /// subshell entry (c:Src/builtin.c:541-547 `disable -r`;
535    /// c:Src/hashtable.c:1124 `reswdtab = newhashtable(23,
536    /// "reswdtab", NULL)`). Same fork-copy reasoning as
537    /// `builtins_disabled` — `(disable -r typeset)` must not change
538    /// how the parent PARSES `typeset foo=`cmd``.
539    pub reswds_disabled: std::collections::HashSet<String>,
540}
541
542#[allow(unused_imports)]
543pub(crate) use crate::ported::pattern::{
544    extract_numeric_ranges, numeric_range_contains, numeric_ranges_to_star,
545};
546
547/// Top-level shell executor state.
548/// Fork-equivalent event counter — incremented on every external
549/// spawn and in-process subshell entry. C zsh's `time` keyword
550/// reports only for JOBS (forked work, Src/jobs.c printtime via the
551/// job table); zshrs runs builtins/braces/functions in-process with
552/// no job, so the TIME_SUBLIST handler compares this counter across
553/// the timed body to decide whether to emit the report.
554pub static FORK_EVENTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
555
556/// Port of the file-static globals + `Estate` chain Src/exec.c
557/// uses — `execlist()` (line 1349) drives every list, with
558/// `execpline()` (line 1668), `execpline2()` (line 1991),
559/// `execsimple()` (line 1290), and the per-`WC_*` `execfuncs[]`
560/// table (line 268) feeding off it. The Rust port collapses
561/// everything into one `ShellExecutor` so we don't need
562/// thread-local globals.
563pub struct ShellExecutor {
564    /// Mirrors C zsh's file-static `scriptname` (Src/init.c). Used by
565    /// PS4's `%N` and the `scriptname:line: …` prefix on error
566    /// messages. Inside a function, MUTATES to the function name
567    /// (Src/exec.c:5903 `scriptname = dupstring(name)`). Init sets
568    /// this in `-c` mode to the binary basename per init.c:479; when
569    /// sourcing a file via `source`/`bin_dot`, it becomes the
570    /// resolved file path; otherwise it falls back through `$0` →
571    /// `$ZSH_ARGZERO`.
572    pub scriptname: Option<String>,
573    /// Mirrors C zsh's `scriptfilename` global (Src/init.c). Tracks
574    /// the FILE BEING READ (vs scriptname which tracks the active
575    /// function name during a call). Used by PS4's `%x` and certain
576    /// error-message prefixes that want the file location, NOT the
577    /// function name.
578    ///
579    /// At -c-mode init, scriptname == scriptfilename == "zsh"
580    /// (Src/init.c:479). When entering a function, ONLY scriptname
581    /// updates (exec.c:5903); scriptfilename stays at the outer
582    /// file path, so `%x` inside a function still shows the file
583    /// the function was called from.
584    pub scriptfilename: Option<String>,
585    /// Stack of subshell-state snapshots. Each `(…)` subshell pushes a copy
586    /// of variables/arrays/assoc_arrays at entry and pops/restores at exit.
587    /// Without this, `(x=inner; …); echo $x` shows `inner` instead of the
588    /// outer-scope value.
589    pub subshell_snapshots: Vec<SubshellSnapshot>,
590    /// Stack of inline-assignment scopes — `X=foo Y=bar cmd` pushes
591    /// a frame at the start, the assigns run inside it, and `cmd`
592    /// returns into END_INLINE_ENV which restores both shell-vars
593    /// and process-env to the pre-frame state. Each frame holds
594    /// `(name, prev_var, prev_env)` per assigned name. zsh's
595    /// equivalent is the parser-level "addvar" list executed under
596    /// `addvars()` (Src/exec.c) right before the command exec.
597    pub inline_env_stack: Vec<InlineEnvFrame>,
598    /// Set by `expand_glob`'s no-match arm when `nomatch` is on (zsh
599    /// default) — instructs the simple-command dispatcher to skip
600    /// executing the current command, set last_status=1, and continue
601    /// to the next command in the script. zsh's bin_simple uses the
602    /// errflag global for the same role: error printed, command
603    /// suppressed, script continues. Without this we were calling
604    /// `process::exit(1)` deep inside expand_glob, killing the whole
605    /// shell on any unmatched glob even with multi-statement input.
606    /// `Cell` because the no-match site only has a `&self` borrow.
607    pub current_command_glob_failed: std::cell::Cell<bool>,
608    /// `jobs` field.
609    pub jobs: JobTable,
610    /// `fpath` field.
611    pub fpath: Vec<PathBuf>,
612    /// `history` field.
613    pub history: Option<HistoryEngine>,
614    pub(crate) process_sub_counter: u32,
615    pub completions: HashMap<String, CompSpec>, // command -> completion spec
616    pub zstyles: Vec<zstyle_entry>,             // zstyle configurations
617    /// Current function scope depth for `local` tracking.
618    pub local_scope_depth: usize,
619    /// Last arg of the currently-running command, deferred into `$_`
620    /// when the next command dispatches. zsh: `$_` reflects the LAST
621    /// command's last arg, so `echo hi; echo $_` prints `hi` (not the
622    /// `_` arg of `echo $_` itself). Promoted in `pop_args` and
623    /// `host.exec` before the command's args are read.
624    pub pending_underscore: Option<String>,
625    /// True while expanding inside a double-quoted context. Set by
626    /// `BUILTIN_EXPAND_TEXT` mode 1 around `expand_string` calls.
627    /// Used by parameter-flag application to suppress array-only flags
628    /// (`(o)`/`(O)`/`(n)`/`(i)`/`(M)`/`(u)`) — zsh's behaviour: those
629    /// flags only fire in array context.
630    pub in_dq_context: u32,
631    /// True (>0) while expanding the RHS of a scalar assignment.
632    /// Direct port of zsh's `PREFORK_SINGLE` bit set by
633    /// Src/exec.c::addvars line 2546 (`prefork(vl, isstr ?
634    /// (PREFORK_SINGLE|PREFORK_ASSIGN) : PREFORK_ASSIGN, ...)`).
635    /// Subst_port's paramsubst reads this via `ssub` and suppresses
636    /// `(f)` / `(s:STR:)` / `(0)` / `(z)` split flags per
637    /// Src/subst.c:1759 + 3902, so `y="${(f)x}"` preserves x's
638    /// original separator (newlines) instead of re-joining with
639    /// IFS-first-char (space).
640    pub in_scalar_assign: u32,
641    /// `profiling_enabled` field.
642    pub profiling_enabled: bool,
643    // compsys - completion system cache
644    /// `compsys_cache` field.
645    /// SQLite mirror, opened on FIRST USE via [`ShellExecutor::compsys_cache`].
646    ///
647    /// It is a dbview/FTS mirror for inspection — the authoritative completion
648    /// cache is the rkyv shards — so nothing on a normal command path touches
649    /// it. Opening it in the constructor still cost every shell three file
650    /// opens (`compsys.db`, `-wal`, `-shm`) plus WAL setup, including
651    /// `zshrs -f -c exit`, which cannot consult it at all.
652    pub compsys_cache: std::cell::OnceCell<Option<CompsysCache>>,
653    // Background compinit — receiver for async fpath scan result
654    /// `compinit_pending` field.
655    pub compinit_pending: Option<(
656        std::sync::mpsc::Receiver<CompInitBgResult>,
657        std::time::Instant,
658    )>,
659    // Plugin source cache — stores side effects of source/. in SQLite
660    /// `plugin_cache` field.
661    pub plugin_cache: Option<crate::plugin_cache::PluginCache>,
662    // cdreplay - deferred compdef calls for zinit turbo mode
663    /// `deferred_compdefs` field.
664    pub deferred_compdefs: Vec<Vec<String>>,
665    // Control flow signals
666    pub returning: Option<i32>, // Set by return builtin, cleared after function returns
667    /// zsh compatibility mode - use .zcompdump, fpath scanning, etc.
668    /// Also serves as the `--zsh` parity-test flag: caches off, daemon
669    /// off, plugin_cache replay off so every `source` re-runs the file
670    /// fresh per Src/builtin.c:6080-6123 bin_dot semantics.
671    pub zsh_compat: bool,
672    /// bash compatibility mode (`--bash`). Same parity-mode semantics
673    /// as `zsh_compat` (caches/daemon/replay off) plus bash-specific
674    /// behavior tweaks where bash 5.x diverges from zsh — e.g.
675    /// `BASH_VERSION` / `BASH_REMATCH` exposed, `[[ =~ ]]` populates
676    /// match indices the bash way, mapfile/readarray as builtins.
677    pub bash_compat: bool,
678    /// POSIX sh strict mode — no SQLite, no worker pool, no zsh extensions
679    pub posix_mode: bool,
680    /// Worker thread pool for background tasks (compinit, process subs, etc.)
681    pub worker_pool: std::sync::Arc<crate::worker::WorkerPool>,
682    /// AOP intercept table: command/function name → advice chain.
683    /// Glob patterns supported (e.g. "git *", "*").
684    pub intercepts: Vec<Intercept>,
685    /// Async job handles: id → receiver for (status, stdout)
686    pub async_jobs: HashMap<u32, crossbeam_channel::Receiver<(i32, String)>>,
687    /// Next async job ID
688    pub next_async_id: u32,
689    /// Per-scope saved-fd stacks for `Op::WithRedirectsBegin/End`. Each entry
690    /// is a Vec of (fd, saved_dup_fd) pairs taken from `dup(fd)` before the
691    /// redirect was applied; `with_redirects_end` `dup2`s them back and closes.
692    pub redirect_scope_stack: Vec<Vec<(i32, i32)>>,
693    /// Per-scope MULTIOS tee state. Each entry is `(pipe_write_fd,
694    /// JoinHandle)`: the pipe write-end currently dup2'd onto the
695    /// command's fd, and the splitter thread that reads from the
696    /// pipe read-end and writes to every collected target. Closed
697    /// + joined by `host_redirect_scope_end` BEFORE the saved fds
698    /// are restored so the splitter drains every byte the body
699    /// wrote into the pipe. Bug #36 in docs/BUGS.md.
700    pub multios_scope_stack: Vec<Vec<(i32, std::thread::JoinHandle<()>)>>,
701    /// True while applying a bare `exec`'s redirect list (`exec 1>&-`,
702    /// `exec 2>/dev/null` — no command words). `host_apply_redirect`
703    /// then skips pushing the saved fd into the enclosing scope so the
704    /// fd change survives group/command teardown.
705    /// c:Src/exec.c:3978-3986 — nullexec==1: "we specifically *don't*
706    /// restore the original fd's before returning"; C's per-execcmd
707    /// `save[]` means exec's redirs never enter the enclosing group's
708    /// save list either. Toggled by `BUILTIN_EXEC_PERM_REDIRS`.
709    pub exec_redirs_permanent: bool,
710    /// Set in a forked pipeline-stage child right after its stdout is
711    /// dup2'd onto the pipe write-end. Consumed by the FIRST
712    /// `host_redirect_scope_begin` (the stage command's own redirect
713    /// list) into `pipe_output_scope`.
714    /// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output,
715    /// 1, NULL)`: the pipe occupies mfds[1] in the SAME execcmd that
716    /// processes the stage command's redirect list.
717    pub pipe_output_pending: bool,
718    /// Index into `redirect_scope_stack` of the scope whose redirect
719    /// list shares an execcmd with the pipeline output on fd 1. A
720    /// write-side redirect of fd 1 applied at exactly this scope depth
721    /// MULTIOS-splits (tees) instead of replacing — c:Src/exec.c:
722    /// 2447-2480 addfd "split the stream". Cleared when that scope ends.
723    pub pipe_output_scope: Option<usize>,
724    /// Set by `host_apply_redirect` when a redirect target couldn't be
725    /// opened (permission denied, no such directory, etc). The next
726    /// builtin/command checks this at entry and short-circuits with
727    /// status 1 instead of running. Mirrors zsh's "command skip" on
728    /// redirect failure.
729    pub redirect_failed: bool,
730    /// Compiled function bodies — name → fusevm::Chunk. Populated by
731    /// `BUILTIN_REGISTER_FUNCTION` (from `FunctionDef` lowering) and lazily by
732    /// `ZshrsHost::call_function` when only an AST exists in `self.functions`
733    /// (autoloaded, sourced, etc.). `Op::CallFunction` dispatches through here.
734    pub functions_compiled: HashMap<String, fusevm::Chunk>,
735    /// Canonical source text for functions. Populated by autoload paths (the
736    /// raw file/cache body), runtime FuncDef compile (the parsed source span),
737    /// and `unfunction` removal. Used by introspection (`whence`, `which`,
738    /// `typeset -f`) instead of reconstructing from a ShellCommand AST. When a
739    /// function is in `functions_compiled` but not here, introspection falls
740    /// back to `text::getpermtext(self.functions[name])`.
741    pub function_source: HashMap<String, String>,
742    /// `first_body_line - 1` per compiled function — matches inner
743    /// `ZshCompiler::lineno_offset` / zsh `funcstack->flineno` combined with
744    /// relative `$LINENO` for Src/prompt.c:909 `%I`.
745    pub function_line_base: HashMap<String, i64>,
746    /// `scriptfilename` when `BUILTIN_REGISTER_COMPILED_FN` ran — `%x` inside
747    /// a function (prompt.c:931-934) reads `funcstack->filename`.
748    pub function_def_file: HashMap<String, Option<String>>,
749    /// Innermost-last stack of active compiled-call frames for prompt `%I` / `%x`.
750    pub prompt_funcstack: Vec<(String, i64, Option<String>)>,
751    /// Scalar→(array, sep) tie table set up by `typeset -T VAR var [SEP]`.
752    /// Array→(scalar, sep) reverse-tie table. Used by BUILTIN_SET_ARRAY to
753    /// join the array elements with `sep` and mirror to the scalar side.
754    pub tied_array_to_scalar: HashMap<String, (String, String)>,
755
756    // ── ztest framework counters (extensions/ztest.rs) ──────────────────
757    //
758    // Mirrors strykelang's per-VMHelper test counters
759    // (strykelang/builtins.rs:22292-22308 + builtins.rs::test_pass/_fail/_skip,
760    //  builtins.rs::test_pass_count etc.). Each `zassert_*` builtin bumps
761    // the per-block counter; `ztest_run` rolls per-block into _total and
762    // resets the per-block side, so a single test file with multiple
763    // `ztest_run` calls can reuse the counters. The worker-pool runner in
764    // src/extensions/ztest.rs reads pass_total+pass_count and
765    // fail_total+fail_count after `execute_script` returns for the cumulative
766    // numbers (strykelang/cli_runners.rs:115-118). `ztest_run_failed` is a
767    // sticky bool the runner reads so a test that asserts but then exits
768    // 0 still flags as failed. `ztest_suppress_stdout` matches
769    // VMHelper::suppress_stdout — the runner sets it inside the forked
770    // grandchild so the per-test stderr capture stays clean.
771    /// Per-block pass count (reset by `ztest_run`).
772    pub ztest_pass_count: std::sync::atomic::AtomicUsize,
773    /// Per-block fail count (reset by `ztest_run`).
774    pub ztest_fail_count: std::sync::atomic::AtomicUsize,
775    /// Per-block skip count (reset by `ztest_run`).
776    pub ztest_skip_count: std::sync::atomic::AtomicUsize,
777    /// Cumulative pass total across the run.
778    pub ztest_pass_total: std::sync::atomic::AtomicUsize,
779    /// Cumulative fail total across the run.
780    pub ztest_fail_total: std::sync::atomic::AtomicUsize,
781    /// Cumulative skip total across the run.
782    pub ztest_skip_total: std::sync::atomic::AtomicUsize,
783    /// Sticky failure flag — set by any `ztest_run` that observed fails;
784    /// the CLI runner reads this so a test that asserts then exits 0
785    /// still counts as a failed file.
786    pub ztest_run_failed: std::sync::atomic::AtomicBool,
787    /// Suppress per-assertion `✓`/`✗` lines on stderr. Set by the worker
788    /// runner inside the forked child when it has already redirected
789    /// fd 2 to a tmp file (we still want the lines, but only after the
790    /// runner re-emits them under print_lock to avoid line-tearing).
791    pub ztest_suppress_stdout: bool,
792}
793
794/// Context-isolated nested parse — the AST-path bridge for C's
795/// `parse_string` (`Src/exec.c:283`). zshrs executes via the ZshProgram
796/// AST + `compile_zsh`, not wordcode, so this can't live in `src/ported/`
797/// (C's `parse_string` returns `Eprog`, and the build.rs port-gate rejects
798/// any non-C-named fn there). It's the bridge that wraps the AST
799/// `parse_init`+`parse` in the SAME isolation `parse_string` provides.
800///
801/// Why this exists: a runtime parse — command-substitution body,
802/// process-substitution argv — must not clobber the outer
803/// `loop()`/`parse_event` reader's live input when it interleaves parsing
804/// with execution (faithful single-event mode).
805///
806/// The load-bearing piece is `strinbeg(0)`/`strinend()` (c:290/298). It
807/// sets the `strin` flag so that when the nested lexer drains `cmd_str`,
808/// `ingetc` returns EOF (input.rs:391) instead of falling through to
809/// `inputline()`, which would STEAL the outer reader's next SHIN line —
810/// e.g. `echo A` / `v=$(echo hi)` / `echo B` had the cmd-subst swallow
811/// `echo B` off stdin, and the outer loop then hit EOF after one command.
812/// `strinbeg` also runs `hbegin`/`lexinit`, so it must execute on isolated
813/// history+lexer state — hence the surrounding `zcontext_save`/`restore`
814/// (c:288/300), which saves+restores `tok`/`tokstr`/`lexbuf`/`isnewlin`/
815/// `incmdpos`/heredocs/`lexstop`/`toklineno`/history.
816///
817/// Two zshrs-specific globals `zcontext` doesn't cover are saved here too:
818/// the lexer-input window (`LEX_INPUT`/`LEX_POS`/`LEX_UNGET_BUF`) that
819/// `lex_init` overwrites with `cmd_str`, and the line counter `LEX_LINENO`
820/// (C saves `oldlineno` explicitly at parse_string c:291/295).
821///
822/// NOTE: using `inpush` (the literal C input stack) instead does NOT work
823/// in zshrs's hybrid input model — the outer piped reader pulls from SHIN
824/// via `inputline`, and pushing/popping the `inbuf` stack severs that
825/// continuation. The `LEX_INPUT` window + `strin` flag is the working
826/// equivalent.
827pub(crate) fn parse_isolated(input: &str) -> crate::parse::ZshProgram {
828    use crate::ported::lex::{
829        tok, LEXERR, LEX_INPUT, LEX_LINENO, LEX_POS, LEX_UNGET_BUF, LEX_FILE_WINDOW_STRIN,
830    };
831
832    // Inline Rust FFI: rewrite every `rust { ... }` block into a
833    // `__rust_compile '<base64>' <line>` command before it reaches the lexer.
834    // This is the shared source-string chokepoint for `-c`, script files, and
835    // nested (command/process-substitution) parses. The `.contains("rust")`
836    // gate keeps the common case (no FFI block) allocation-free — the vast
837    // majority of nested parses never mention `rust`.
838    let ffi_desugared = input
839        .contains("rust")
840        .then(|| crate::rust_ffi::desugar(input));
841    let input: &str = ffi_desugared.as_deref().unwrap_or(input);
842
843    crate::ported::context::zcontext_save(); // c:288
844                                             // Save the zshrs-specific lexer window + line counter that lex_init
845                                             // overwrites but zcontext doesn't cover.
846    let saved_input = LEX_INPUT.with_borrow(|s| s.clone());
847    let saved_pos = LEX_POS.get();
848    let saved_unget = LEX_UNGET_BUF.with_borrow(|b| b.clone());
849    let saved_lineno = LEX_LINENO.get(); // c:291 oldlineno
850    // The nested parse installs its own window; `lex_init` marks it a
851    // string unit. Put the outer window's kind back with the window.
852    let saved_file_window = LEX_FILE_WINDOW_STRIN.get();
853                                         // input.rs `lexstop` is the input-side half of C's single `lexstop`;
854                                         // draining the nested LEX_INPUT sets it true and zcontext only covers
855                                         // the lex.rs half (LEX_LEXSTOP). Restore it so the outer reader isn't
856                                         // left at EOF.
857    let saved_in_lexstop = crate::ported::input::lexstop.with(|c| c.get());
858
859    crate::ported::hist::strinbeg(0); // c:290 — strin++ → drained nested input EOFs (no SHIN steal)
860    crate::ported::parse::parse_init(input); // install cmd_str as LEX_INPUT (lex_init), LEX_LINENO=1
861    let program = crate::ported::parse::parse(); // c:294 (AST analog of par_list)
862
863    // Capture parse failure BEFORE the restores wipe the signals.
864    let parse_err = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0 || tok() == LEXERR;
865    if tok() == LEXERR && crate::ported::builtin::LASTVAL.load(Ordering::Relaxed) == 0 {
866        crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:296-297
867    }
868
869    crate::ported::hist::strinend(); // c:298 — strin--
870                                     // Restore the zshrs window, then the token/parse/history state.
871    LEX_INPUT.with_borrow_mut(|s| *s = saved_input);
872    LEX_POS.set(saved_pos);
873    LEX_UNGET_BUF.with_borrow_mut(|b| *b = saved_unget);
874    LEX_LINENO.set(saved_lineno); // c:295
875    LEX_FILE_WINDOW_STRIN.set(saved_file_window);
876    crate::ported::input::lexstop.with(|c| c.set(saved_in_lexstop));
877    crate::ported::context::zcontext_restore(); // c:300
878                                                // zcontext_restore → parse_context_restore clears ERRFLAG_ERROR
879                                                // (parse.c:354); re-raise so callers gating on the bit still see it.
880    if parse_err {
881        errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
882    }
883    program
884}
885
886/// Build the `scriptname[:lineno]` prefix zsh puts on an execution error.
887///
888/// c:Src/utils.c:301 — `zerrmsg` prints the line number ONLY when it is
889/// non-zero: `if ((unset(SHINSTDIN) || locallevel) && lineno) fprintf(file,
890/// "%lld: ", lineno);`. The command-not-found / no-such-file / permission-denied
891/// sites below emit DIRECTLY rather than through `zerr` (deliberately — see
892/// their comments, routing through zerr would set errflag and abort a script
893/// that zsh continues), but they hand-rolled `"{}:{}"` and so printed a bare
894/// `:0` inside a one-line function where zsh prints no line number at all:
895/// `f(){ nosuchcmd }; f` gave `f:0: command not found:` vs zsh's `f: command
896/// not found:`. Mirrors the C condition exactly. Bug #1070.
897fn zerr_prefix(sn: &str) -> String {
898    let lineno = crate::ported::lex::lineno();
899    let ll = crate::ported::params::locallevel.load(std::sync::atomic::Ordering::Relaxed);
900    if (crate::ported::zsh_h::unset(crate::ported::zsh_h::SHINSTDIN) || ll != 0) && lineno != 0 {
901        format!("{}:{}", sn, lineno)
902    } else {
903        sn.to_string()
904    }
905}
906
907thread_local! {
908    /// `(function name, its source file)` while that function's AUTOLOAD body
909    /// is being run. Empty at every other moment.
910    pub static AUTOLOAD_DEF_FILE: std::cell::RefCell<Vec<(String, String)>> =
911        const { std::cell::RefCell::new(Vec::new()) };
912}
913
914/// Scope guard for [`AUTOLOAD_DEF_FILE`].
915pub struct AutoloadFileGuard(bool);
916
917impl AutoloadFileGuard {
918    fn enter(name: &str) -> Self {
919        match crate::ported::hashtable::getshfuncfile(name) {
920            Some(f) => {
921                AUTOLOAD_DEF_FILE.with(|s| s.borrow_mut().push((name.to_string(), f)));
922                Self(true)
923            }
924            None => Self(false),
925        }
926    }
927}
928
929impl Drop for AutoloadFileGuard {
930    fn drop(&mut self) {
931        if self.0 {
932            AUTOLOAD_DEF_FILE.with(|s| {
933                s.borrow_mut().pop();
934            });
935        }
936    }
937}
938
939/// The file `name` is being autoloaded from, if that is happening right now.
940pub fn autoload_def_file(name: &str) -> Option<String> {
941    AUTOLOAD_DEF_FILE.with(|s| {
942        s.borrow()
943            .iter()
944            .rev()
945            .find(|(n, _)| n == name)
946            .map(|(_, f)| f.clone())
947    })
948}
949
950/// !!! WARNING: RUST-ONLY HELPER — NO DIRECT C COUNTERPART !!!
951///
952/// C's `zexecve` (Src/exec.c:504-643) performs the whole `#!` recovery
953/// in place: it is only ever reached in the already-forked child that is
954/// about to BECOME the command, so at c:566/571/581/585/627 it simply
955/// calls `execve()` a second time and never returns. zshrs reaches the
956/// same decision from a second call site — the `std::process::Command`
957/// spawn in `vm_helper::execute_external_bg` — which hands argv to the
958/// kernel from the PARENT process and therefore cannot "re-exec in
959/// place". This helper is c:534-634 verbatim with each of those five
960/// `execve(prog, argv)` calls replaced by `Ok((prog, argv))`, so both
961/// call sites share one implementation of the shebang rules.
962///
963/// `Err(eno)` is what C's `return eno` (c:643) would hand back — either
964/// the original `eno` or the `errno` from a failed open/read (c:632/634).
965#[allow(non_snake_case)]
966pub fn zexecve_recover(pth: &str, argv: &[String], eno: i32) -> Result<(String, Vec<String>), i32> {
967    if eno == libc::ENOEXEC || eno == libc::ENOENT {
968        // c:534
969        let cpth = match std::ffi::CString::new(pth) {
970            Ok(c) => c,
971            Err(_) => return Err(libc::ENOENT),
972        };
973        let fd = unsafe { libc::open(cpth.as_ptr(), libc::O_RDONLY | libc::O_NOCTTY) }; // c:538
974        if fd < 0 {
975            // c:633-634 — `} else eno = errno;` then fall through to `return eno`.
976            return Err(std::io::Error::last_os_error()
977                .raw_os_error()
978                .unwrap_or(libc::ENOENT));
979        }
980        let mut buf = vec![0u8; crate::ported::exec::POUNDBANGLIMIT + 1]; // c:541
981        let ct = unsafe {
982            libc::read(
983                fd,
984                buf.as_mut_ptr() as *mut libc::c_void,
985                crate::ported::exec::POUNDBANGLIMIT as libc::size_t,
986            )
987        }; // c:542
988        unsafe {
989            libc::close(fd);
990        } // c:543
991        if ct >= 0 {
992            // c:544
993            let ct = ct as usize;
994            if ct >= 2 && buf[0] == b'#' && buf[1] == b'!' {
995                // c:545
996                let mut t0 = 0;
997                while t0 < ct && buf[t0] != b'\n' {
998                    t0 += 1;
999                } // c:546-548
1000                if t0 == ct {
1001                    // c:549
1002                    // c:550 `zerr(...)`. C is inside the forked child that is
1003                    // about to `_exit`, so the errflag `zerr` raises is
1004                    // irrelevant there. This runs in the PARENT, where a raised
1005                    // errflag aborts the enclosing script — `( if
1006                    // bad-interp-cmd; then exit 0; else exit 1; fi )` returned
1007                    // 127 instead of running its else branch. `zwarn`
1008                    // (utils.rs:260) emits the identical text without the flag.
1009                    crate::ported::utils::zwarn(&format!(
1010                        // c:550
1011                        "{}: bad interpreter: {}: {}",
1012                        pth,
1013                        String::from_utf8_lossy(&buf[2..t0.min(ct)]),
1014                        std::io::Error::from_raw_os_error(eno)
1015                    ));
1016                } else {
1017                    // c:552
1018                    while t0 > 0 && (buf[t0] == b' ' || buf[t0] == b'\t' || buf[t0] == b'\n') {
1019                        buf[t0] = 0;
1020                        t0 -= 1;
1021                    } // c:553-554
1022                    let mut ptr_lo: usize = 2;
1023                    while ptr_lo < buf.len() && buf[ptr_lo] == b' ' {
1024                        ptr_lo += 1;
1025                    } // c:555
1026                    let ptr2_lo = ptr_lo;
1027                    let mut ptr_hi = ptr2_lo;
1028                    while ptr_hi < buf.len() && buf[ptr_hi] != 0 && buf[ptr_hi] != b' ' {
1029                        ptr_hi += 1;
1030                    } // c:556
1031                    let interp_str = String::from_utf8_lossy(&buf[ptr2_lo..ptr_hi]).into_owned();
1032                    if eno == libc::ENOENT {
1033                        // c:557 — pathprog rewrite path.
1034                        let pprog = if !interp_str.starts_with('/') {
1035                            // c:561
1036                            crate::ported::utils::pathprog(&interp_str)
1037                                .map(|p| p.display().to_string())
1038                        } else {
1039                            None
1040                        };
1041                        if let Some(pprog) = pprog {
1042                            // c:562
1043                            let mut argv_new: Vec<String> = Vec::with_capacity(argv.len() + 2);
1044                            argv_new.push(interp_str.clone()); // c:564
1045                            if ptr_hi >= buf.len() || buf[ptr_hi] == 0 {
1046                                argv_new.push(pth.to_string());
1047                            } else {
1048                                // c:567
1049                                let mut rest_lo = ptr_hi + 1;
1050                                while rest_lo < buf.len() && buf[rest_lo] == b' ' {
1051                                    rest_lo += 1;
1052                                }
1053                                let mut rest_hi = rest_lo;
1054                                while rest_hi < buf.len() && buf[rest_hi] != 0 {
1055                                    rest_hi += 1;
1056                                }
1057                                let arg_str =
1058                                    String::from_utf8_lossy(&buf[rest_lo..rest_hi]).into_owned();
1059                                argv_new.push(arg_str);
1060                                argv_new.push(pth.to_string());
1061                            }
1062                            for orig in argv.iter().skip(1) {
1063                                argv_new.push(orig.clone());
1064                            }
1065                            crate::ported::signals_h::winch_unblock(); // c:565/c:570
1066                            return Ok((pprog, argv_new)); // c:566/c:571
1067                        }
1068                        crate::ported::utils::zwarn(&format!(
1069                            // c:574 — `zerr`; see the c:550 note above for why
1070                            // this is `zwarn` in the parent-side port.
1071                            "{}: bad interpreter: {}: {}",
1072                            pth,
1073                            interp_str,
1074                            std::io::Error::from_raw_os_error(eno)
1075                        ));
1076                    } else if ptr_hi < buf.len() && buf[ptr_hi] != 0 {
1077                        // c:576
1078                        let mut rest_lo = ptr_hi + 1;
1079                        while rest_lo < buf.len() && buf[rest_lo] == b' ' {
1080                            rest_lo += 1;
1081                        }
1082                        let mut rest_hi = rest_lo;
1083                        while rest_hi < buf.len() && buf[rest_hi] != 0 {
1084                            rest_hi += 1;
1085                        }
1086                        let arg_str = String::from_utf8_lossy(&buf[rest_lo..rest_hi]).into_owned();
1087                        let mut argv_new: Vec<String> =
1088                            vec![interp_str.clone(), arg_str, pth.to_string()];
1089                        for orig in argv.iter().skip(1) {
1090                            argv_new.push(orig.clone());
1091                        }
1092                        crate::ported::signals_h::winch_unblock(); // c:580
1093                        return Ok((interp_str, argv_new)); // c:581
1094                    } else {
1095                        // c:582
1096                        let mut argv_new: Vec<String> = vec![interp_str.clone(), pth.to_string()];
1097                        for orig in argv.iter().skip(1) {
1098                            argv_new.push(orig.clone());
1099                        }
1100                        crate::ported::signals_h::winch_unblock(); // c:584
1101                        return Ok((interp_str, argv_new)); // c:585
1102                    }
1103                }
1104            } else if eno == libc::ENOEXEC {
1105                // c:588 — binary-safety + /bin/sh fallback.
1106                let nul_pos = buf[..ct].iter().position(|&b| b == 0); // c:597
1107                let isbinary = match nul_pos {
1108                    None => false, // c:598
1109                    Some(npos) => {
1110                        let mut has_letter = false;
1111                        let mut binary = true;
1112                        for &b in &buf[..npos] {
1113                            // c:602-609
1114                            if (b as char).is_ascii_lowercase() || b == b'$' || b == b'`' {
1115                                has_letter = true;
1116                            }
1117                            if has_letter && b == b'\n' {
1118                                binary = false; // c:606
1119                                break;
1120                            }
1121                        }
1122                        binary
1123                    }
1124                };
1125                if !isbinary {
1126                    // c:611
1127                    let mut argv_new: Vec<String> = Vec::with_capacity(argv.len() + 2);
1128                    argv_new.push("sh".to_string()); // c:625
1129                    if !argv.is_empty() && (argv[0].starts_with('-') || argv[0].starts_with('+')) {
1130                        argv_new.push("-".to_string()); // c:623
1131                    }
1132                    for orig in argv.iter() {
1133                        argv_new.push(orig.clone());
1134                    }
1135                    crate::ported::signals_h::winch_unblock(); // c:626
1136                    return Ok(("/bin/sh".to_string(), argv_new)); // c:627
1137                }
1138            }
1139        }
1140    }
1141    Err(eno) // c:643
1142}
1143
1144impl ShellExecutor {
1145    /// Set a scalar parameter via the canonical `paramtab`
1146    /// (`Src/params.c:3350 setsparam`). The single store.
1147    pub fn set_scalar(&mut self, name: String, value: String) {
1148        setsparam(&name, &value); // c:params.c:3350
1149    }
1150
1151    /// Read positional parameters from canonical `PPARAMS`
1152    /// `Mutex<Vec<String>>` (Src/init.c:pparams). The single store.
1153    pub fn pparams(&self) -> Vec<String> {
1154        crate::ported::builtin::PPARAMS
1155            .lock()
1156            .map(|p| p.clone())
1157            .unwrap_or_default()
1158    }
1159
1160    /// Write positional parameters to canonical `PPARAMS`.
1161    pub fn set_pparams(&mut self, params: Vec<String>) {
1162        if let Ok(mut p) = crate::ported::builtin::PPARAMS.lock() {
1163            *p = params;
1164        }
1165    }
1166
1167    /// Read PM_* type flags from the paramtab Param entry. Used by
1168    /// SET_VAR / `+=` arms (case-fold, integer-add, readonly guard).
1169    /// Returns 0 when the name isn't in paramtab. Mirrors the C
1170    /// source's direct `pm->node.flags & PM_INTEGER` checks.
1171    pub fn param_flags(&self, name: &str) -> i32 {
1172        paramtab()
1173            .read()
1174            .ok()
1175            .and_then(|t| t.get(name).map(|p| p.node.flags))
1176            .unwrap_or(0)
1177    }
1178
1179    /// `readonly` / `typeset -r` / read-only-by-design (LINENO, PPID,
1180    /// $$, $?, $!, ...) — match user-side rejection in C's
1181    /// assignstrvalue at `Src/params.c:2699-2703` which gates on
1182    /// `pm->node.flags & PM_READONLY` where the IPDEF4 family declares
1183    /// `PM_READONLY_SPECIAL = PM_SPECIAL | PM_READONLY | PM_RO_BY_DESIGN`
1184    /// (all three bits set together), which `init_partab_params` now
1185    /// stamps in full. The PM_RO_BY_DESIGN arm below is therefore no
1186    /// longer the IPDEF4 rows' only read-only marker — it remains
1187    /// because `private` params (c:Src/Modules/param_private.c:174)
1188    /// carry PM_RO_BY_DESIGN WITHOUT PM_READONLY and need the
1189    /// scope-gated test. Bug #418-family / test_lineno_intrinsic_readonly.
1190    pub fn is_readonly_param(&self, name: &str) -> bool {
1191        let (flags, pm_level) = crate::ported::params::paramtab()
1192            .read()
1193            .ok()
1194            .and_then(|t| t.get(name).map(|p| (p.node.flags as u32, p.level)))
1195            .unwrap_or((0, 0));
1196        // c:Src/params.c assignsparam — a real PM_READONLY param always
1197        // rejects writes.
1198        if (flags & PM_READONLY) != 0 {
1199            return true;
1200        }
1201        if (flags & crate::ported::zsh_h::PM_RO_BY_DESIGN) != 0 {
1202            // c:Src/Modules/param_private.c pps_setfn (c:300-307) — a
1203            // PRIVATE param (PM_RO_BY_DESIGN + PM_REMOVABLE) is NOT blanket
1204            // read-only: a write is permitted iff it is in the SAME scope
1205            // (`locallevel == pm->level`, e.g. `() { private p=1; p=2 }`
1206            // → 2) or above the wrap level (`locallevel >
1207            // private_wraplevel`). A deeper nested-scope write is rejected
1208            // (setfn_error) — that is how a nested fn writing an OUTER
1209            // function's private still errors and aborts. zshrs never
1210            // wires the private GSU, so the level gate is enforced here.
1211            if (flags & crate::ported::zsh_h::PM_REMOVABLE) != 0 {
1212                let ll = crate::ported::params::locallevel.load(Ordering::Relaxed);
1213                let wrap = crate::ported::modules::param_private::private_wraplevel
1214                    .load(Ordering::Relaxed);
1215                return !(ll == pm_level || ll > wrap); // c:304 (negated: blocked)
1216            }
1217            // Non-removable PM_RO_BY_DESIGN = IPDEF4-family special
1218            // (LINENO/$?/$$…). These now also carry PM_READONLY and so
1219            // return true from the branch above; this arm stays as the
1220            // c:Src/zsh.h:1923 "readonly by design" fallback for any row
1221            // reached before `init_partab_params` has stamped the flag.
1222            return true;
1223        }
1224        false
1225    }
1226
1227    /// Most-recent-command exit status. Reads canonical
1228    /// `builtin::LASTVAL` AtomicI32 (`Src/builtin.c:6443`).
1229    pub fn last_status(&self) -> i32 {
1230        crate::ported::builtin::LASTVAL.load(Ordering::Relaxed)
1231    }
1232
1233    /// Write the most-recent-command exit status. The canonical
1234    /// store is `builtin::LASTVAL`; this is the single setter.
1235    /// Used everywhere `$?` / `%?` / errexit / ZERR trap read.
1236    pub fn set_last_status(&mut self, status: i32) {
1237        crate::ported::builtin::LASTVAL.store(status, Ordering::Relaxed);
1238    }
1239
1240    /// Set an indexed array parameter via canonical paramtab
1241    /// (`setaparam`, `Src/params.c:3595`). The single store.
1242    pub fn set_array(&mut self, name: String, value: Vec<String>) {
1243        setaparam(&name, value); // c:params.c:3595
1244    }
1245
1246    /// Set an associative array parameter via canonical
1247    /// `sethparam` (`Src/params.c:3602`). The single store.
1248    pub fn set_assoc(&mut self, name: String, value: IndexMap<String, String>) {
1249        let mut flat: Vec<String> = Vec::with_capacity(value.len() * 2);
1250        for (k, v) in &value {
1251            flat.push(k.clone());
1252            flat.push(v.clone());
1253        }
1254        sethparam(&name, flat); // c:params.c:3602
1255    }
1256
1257    /// Read a scalar parameter. Mirrors C `getsparam` at
1258    /// `Src/params.c:3076` — reads through paramtab, falls back to
1259    /// special-var hooks and env.
1260    pub fn scalar(&self, name: &str) -> Option<String> {
1261        getsparam(name)
1262    }
1263
1264    /// Read an array parameter via canonical `getaparam`
1265    /// (`Src/params.c:3101`).
1266    pub fn array(&self, name: &str) -> Option<Vec<String>> {
1267        getaparam(name)
1268    }
1269
1270    /// Read an associative array parameter from canonical
1271    /// `paramtab_hashed_storage`. Mirrors C `gethparam` at
1272    /// `Src/params.c:3115` — returns the typed `IndexMap`.
1273    pub fn assoc(&self, name: &str) -> Option<IndexMap<String, String>> {
1274        // c:Src/params.c:570-575 — nameref deref before the read.
1275        let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
1276            crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
1277            _ => name.to_string(),
1278        };
1279        // The live param's TYPE is authoritative — paramtab_hashed_storage is
1280        // keyed by NAME ONLY (no scope), so a local ARRAY that shadows a special
1281        // assoc (`local -a options` / `local -a commands` over the hidden
1282        // `options`/`commands` specials) leaves a stale (emptied) hashed_storage
1283        // entry behind. Without this guard `exec.assoc("options")` returned
1284        // Some(empty), so a subsequent bare `options=(-a -b -c)` routed through
1285        // sethparam → "bad set of key/value pairs for associative array"
1286        // (odd count), breaking `_sqlite`'s `local -a options; options=(…)`
1287        // (separate statements — the one-statement `local -a commands=(…)` form
1288        // that openssl uses is typed correctly by bin_typeset). Consult the
1289        // current param: if it exists and is NOT PM_HASHED, it's an array/scalar
1290        // shadow and the stale assoc storage must not be seen.
1291        if let Some(flags) = crate::ported::params::paramtab()
1292            .read()
1293            .ok()
1294            .and_then(|t| t.get(resolved.as_str()).map(|p| p.node.flags as u32))
1295        {
1296            if (flags & PM_HASHED) == 0 {
1297                return None;
1298            }
1299        }
1300        paramtab_hashed_storage()
1301            .lock()
1302            .ok()
1303            .and_then(|m| m.get(resolved.as_str()).cloned())
1304    }
1305
1306    /// Test whether a scalar parameter exists in paramtab.
1307    /// Mirrors the C `paramtab->getnode(name) != NULL` check.
1308    pub fn has_scalar(&self, name: &str) -> bool {
1309        getsparam(name).is_some()
1310    }
1311
1312    /// Test whether an array parameter exists in paramtab. Mirrors
1313    /// `getaparam(name).is_some()` (PM_ARRAY + populated `u_arr`, with
1314    /// digit-first-name rejection and nameref deref) WITHOUT cloning the
1315    /// backing vector — `getaparam` returns an owned `Vec<String>`, so a
1316    /// bare existence probe on a large array copied every element. Hot in
1317    /// the subscript-store dispatch (`a[i]=v` in a loop), so keep it a
1318    /// flag read.
1319    pub fn has_array(&self, name: &str) -> bool {
1320        if name.starts_with(|c: char| c.is_ascii_digit()) {
1321            return false;
1322        }
1323        let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
1324            crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
1325            _ => name.to_string(),
1326        };
1327        crate::ported::params::paramtab()
1328            .read()
1329            .ok()
1330            .and_then(|t| {
1331                t.get(resolved.as_str()).map(|p| {
1332                    (p.node.flags as u32 & crate::ported::zsh_h::PM_ARRAY) != 0 && p.u_arr.is_some()
1333                })
1334            })
1335            .unwrap_or(false)
1336    }
1337
1338    /// Test whether an associative array parameter exists. Reads
1339    /// canonical `paramtab_hashed_storage` (Src/params.c hashed
1340    /// PM_HASHED slot).
1341    pub fn has_assoc(&self, name: &str) -> bool {
1342        // c:Src/params.c:570-575 — nameref deref before the read.
1343        let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
1344            crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
1345            _ => name.to_string(),
1346        };
1347        // Live-param type is authoritative (see `assoc` above): a non-PM_HASHED
1348        // shadow (e.g. `local -a options`) hides the stale name-keyed
1349        // hashed_storage entry.
1350        if let Some(flags) = crate::ported::params::paramtab()
1351            .read()
1352            .ok()
1353            .and_then(|t| t.get(resolved.as_str()).map(|p| p.node.flags as u32))
1354        {
1355            if (flags & PM_HASHED) == 0 {
1356                return false;
1357            }
1358        }
1359        paramtab_hashed_storage()
1360            .lock()
1361            .ok()
1362            .map(|m| m.contains_key(resolved.as_str()))
1363            .unwrap_or(false)
1364    }
1365
1366    /// Unset an associative array parameter via canonical
1367    /// `unsetparam` (Src/params.c:3819) — PM_READONLY rejection,
1368    /// stdunsetfn dispatch, env clear. Also clears the zshrs-side
1369    /// `paramtab_hashed_storage` parallel IndexMap shadow.
1370    pub fn unset_assoc(&mut self, name: &str) {
1371        unsetparam(name);
1372        let _ = paramtab_hashed_storage()
1373            .lock()
1374            .ok()
1375            .as_deref_mut()
1376            .map(|m| m.remove(name));
1377    }
1378
1379    /// Read a regular (non-global) alias value. Reads canonical
1380    /// `aliastab` (Src/hashtable.c:1186). Filters out aliases that
1381    /// have the ALIAS_GLOBAL flag set so the regular-alias slot is
1382    /// distinct from the global-alias slot, mirroring C's two
1383    /// separate dispatch paths via `aliasflags` checks.
1384    pub fn alias(&self, name: &str) -> Option<String> {
1385        let tab = crate::ported::hashtable::aliastab_lock().read().ok()?;
1386        let a = tab.get(name)?;
1387        if (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0 {
1388            None
1389        } else {
1390            Some(a.text.clone())
1391        }
1392    }
1393
1394    /// Set a regular alias. Writes canonical aliastab with
1395    /// ALIAS_GLOBAL bit cleared.
1396    pub fn set_alias(&mut self, name: String, value: String) {
1397        if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
1398            tab.add(crate::ported::hashtable::createaliasnode(&name, &value, 0));
1399        }
1400    }
1401
1402    /// Set a global alias (`alias -g`). Writes canonical aliastab
1403    /// with ALIAS_GLOBAL bit set.
1404    pub fn set_global_alias(&mut self, name: String, value: String) {
1405        if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
1406            tab.add(crate::ported::hashtable::createaliasnode(
1407                &name,
1408                &value,
1409                crate::ported::zsh_h::ALIAS_GLOBAL as u32,
1410            ));
1411        }
1412    }
1413
1414    /// Set a suffix alias (`alias -s ext=cmd`). Writes canonical
1415    /// sufaliastab with ALIAS_SUFFIX node flag — mirrors C
1416    /// Src/builtin.c:4480-4481 (`flags1 |= ALIAS_SUFFIX; ht =
1417    /// sufaliastab;`) → c:4527 (`createaliasnode(value, flags1)`).
1418    /// Without ALIAS_SUFFIX in node.flags, `${saliases[k]}` /
1419    /// `${(k)saliases}` introspection (parameter.c:1953/2018) fails
1420    /// because both paths strict-equality-match flags == ALIAS_SUFFIX.
1421    pub fn set_suffix_alias(&mut self, name: String, value: String) {
1422        if let Ok(mut tab) = crate::ported::hashtable::sufaliastab_lock().write() {
1423            tab.add(crate::ported::hashtable::createaliasnode(
1424                &name,
1425                &value,
1426                crate::ported::zsh_h::ALIAS_SUFFIX as u32,
1427            ));
1428        }
1429    }
1430
1431    /// Snapshot the alias map as a sorted `Vec<(name, value)>`,
1432    /// only entries WITHOUT the ALIAS_GLOBAL flag (regular aliases).
1433    pub fn alias_entries(&self) -> Vec<(String, String)> {
1434        if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
1435            tab.iter_sorted()
1436                .into_iter()
1437                .filter(|(_, a)| (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) == 0)
1438                .map(|(k, a)| (k.clone(), a.text.clone()))
1439                .collect()
1440        } else {
1441            Vec::new()
1442        }
1443    }
1444
1445    /// Snapshot the global-alias entries (ALIAS_GLOBAL flag set).
1446    pub fn global_alias_entries(&self) -> Vec<(String, String)> {
1447        if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
1448            tab.iter_sorted()
1449                .into_iter()
1450                .filter(|(_, a)| (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0)
1451                .map(|(k, a)| (k.clone(), a.text.clone()))
1452                .collect()
1453        } else {
1454            Vec::new()
1455        }
1456    }
1457
1458    /// Snapshot the suffix-alias entries.
1459    pub fn suffix_alias_entries(&self) -> Vec<(String, String)> {
1460        if let Ok(tab) = crate::ported::hashtable::sufaliastab_lock().read() {
1461            tab.iter_sorted()
1462                .into_iter()
1463                .map(|(k, a)| (k.clone(), a.text.clone()))
1464                .collect()
1465        } else {
1466            Vec::new()
1467        }
1468    }
1469
1470    /// Unset an array parameter. Direct port of `unsetparam_pm` for
1471    /// a PM_ARRAY Param. Mirrors are kept for now while the field
1472    /// transitions.
1473    /// Unset an array parameter via canonical `unsetparam`
1474    /// (Src/params.c:3819). Routes through the C-faithful port
1475    /// that runs PM_NAMEREF skip + PM_READONLY rejection via
1476    /// unsetparam_pm + stdunsetfn dispatch + pm.old scope restore.
1477    /// Inline `tab.remove(name)` skipped all four.
1478    pub fn unset_array(&mut self, name: &str) {
1479        unsetparam(name);
1480    }
1481
1482    /// Unset a scalar parameter via canonical `unsetparam`. Same
1483    /// C-faithful path as `unset_array`; the C `unsetparam` itself
1484    /// is type-agnostic and dispatches through PM_TYPE inside.
1485    pub fn unset_scalar(&mut self, name: &str) {
1486        unsetparam(name);
1487    }
1488    /// Lightweight executor for a POOL WORKER THREAD. Unlike [`new`] (a full
1489    /// session bootstrap that re-derives PWD, imports the environment, seeds
1490    /// `OPTS_LIVE`, and writes ~30 default params into the GLOBAL param table),
1491    /// this constructs ONLY the per-executor struct fields and touches NO
1492    /// global state. A worker shares the already-populated, `RwLock`-synchronized
1493    /// globals (params / functions / options); re-seeding them here would clobber
1494    /// the live main session's values (IFS, OPTIND, `$_`, user options, …).
1495    ///
1496    /// The worker pool is shared (Arc) — a worker never spins up its own pool.
1497    /// Per-worker SQLite caches (compsys / plugin) and the history engine are
1498    /// left `None`: a worker runs short compute bodies, not interactive editing.
1499    ///
1500    /// Phase 1 of the in-process thread-execution model (replaces the
1501    /// subprocess-forking parallel builtins). The caller runs the body under
1502    /// `ExecutorContext::enter(&mut wex)` so the VM's thread_local executor
1503    /// resolves on the worker thread; param writes flow to the shared globals.
1504    pub fn new_worker(pool: std::sync::Arc<crate::worker::WorkerPool>) -> Self {
1505        // fpath from the inherited env, same as new() — pure read, no global write.
1506        let fpath = env::var("FPATH")
1507            .unwrap_or_default()
1508            .split(':')
1509            .filter(|s| !s.is_empty())
1510            .map(PathBuf::from)
1511            .collect();
1512        Self {
1513            scriptname: Some("zsh".to_string()),
1514            scriptfilename: Some("zsh".to_string()),
1515            subshell_snapshots: Vec::new(),
1516            inline_env_stack: Vec::new(),
1517            current_command_glob_failed: std::cell::Cell::new(false),
1518            jobs: JobTable::new(),
1519            fpath,
1520            history: None, // worker: no interactive history engine
1521            completions: HashMap::new(),
1522            process_sub_counter: 0,
1523            zstyles: Vec::new(),
1524            local_scope_depth: 0,
1525            pending_underscore: None,
1526            in_dq_context: 0,
1527            in_scalar_assign: 0,
1528            profiling_enabled: false,
1529            compsys_cache: std::cell::OnceCell::from(None), // worker: no per-thread SQLite mirror
1530            compinit_pending: None,
1531            plugin_cache: None, // worker: no per-thread plugin cache
1532            deferred_compdefs: Vec::new(),
1533            returning: None,
1534            zsh_compat: false,
1535            bash_compat: false,
1536            posix_mode: false,
1537            worker_pool: pool, // SHARED — never spawn a nested pool
1538            intercepts: Vec::new(),
1539            async_jobs: HashMap::new(),
1540            next_async_id: 1,
1541            redirect_scope_stack: Vec::new(),
1542            multios_scope_stack: Vec::new(),
1543            exec_redirs_permanent: false,
1544            pipe_output_pending: false,
1545            pipe_output_scope: None,
1546            redirect_failed: false,
1547            functions_compiled: HashMap::new(),
1548            function_source: HashMap::new(),
1549            function_line_base: HashMap::new(),
1550            function_def_file: HashMap::new(),
1551            prompt_funcstack: Vec::new(),
1552            tied_array_to_scalar: HashMap::new(),
1553            ztest_pass_count: std::sync::atomic::AtomicUsize::new(0),
1554            ztest_fail_count: std::sync::atomic::AtomicUsize::new(0),
1555            ztest_skip_count: std::sync::atomic::AtomicUsize::new(0),
1556            ztest_pass_total: std::sync::atomic::AtomicUsize::new(0),
1557            ztest_fail_total: std::sync::atomic::AtomicUsize::new(0),
1558            ztest_skip_total: std::sync::atomic::AtomicUsize::new(0),
1559            ztest_run_failed: std::sync::atomic::AtomicBool::new(false),
1560            ztest_suppress_stdout: false,
1561        }
1562    }
1563
1564    /// `new` — see implementation.
1565    pub fn new() -> Self {
1566        tracing::debug!("ShellExecutor::new() initializing");
1567
1568        // c:Src/init.c:1236-1259 — setupvals' pwd/oldpwd init, ported
1569        // here because the bin entry skips setupvals (see the
1570        // init_bltinmods note below). The validated value lands in the
1571        // live OS env: the bin entry's `$PWD` carrier (the analog of
1572        // C's `pwd` global — see the subshell-snapshot comment at
1573        // fusevm_bridge.rs `cwd:` field). set_pwd_env() pours it into
1574        // paramtab after the env-import loop, same order as C
1575        // (params.c:955).
1576        //
1577        // c:1242-1245 — "Try a cheap test to see if we can initialize
1578        // `PWD' from `HOME'." EMULATE_ZSH reads the `home` global,
1579        // which setupvals derives from getpwuid(getuid())->pw_dir
1580        // (c:1222-1225), falling back to "/" (c:1230-1232).
1581        let home = unsafe {
1582            let pw = libc::getpwuid(libc::getuid());
1583            if pw.is_null() {
1584                None
1585            } else {
1586                Some(
1587                    std::ffi::CStr::from_ptr((*pw).pw_dir)
1588                        .to_string_lossy()
1589                        .into_owned(),
1590                )
1591            }
1592        }
1593        .unwrap_or_else(|| "/".to_string()); // c:1230-1232 EMULATE_ZSH home = "/"
1594                                             // ispwd (src/zsh/Src/utils.c:809-829): a candidate is honored
1595                                             // only when it (a) is absolute, (b) stat's to the same
1596                                             // dev+inode as ".", and (c) has no `.`/`..` components.
1597                                             // Without this chain, a child that inherits $PWD from a parent
1598                                             // run in a different directory (cargo test setting
1599                                             // current_dir(tempdir) while leaking PWD=/project/root) treats
1600                                             // the stale PWD as the logical-path base, so `cd sub` resolves
1601                                             // against the wrong directory.
1602        let pwd_val = if ispwd(&home) {
1603            home // c:1245-1246 — pwd = ztrdup(ptr) [HOME]
1604        } else if let Some(p) = env::var("PWD")
1605            .ok()
1606            .filter(|p| p.len() < libc::PATH_MAX as usize && ispwd(p))
1607        {
1608            p // c:1247-1249 — pwd = ztrdup(getenv("PWD"))
1609        } else {
1610            crate::ported::compat::zgetcwd() // c:1250-1252 — pwd = zgetcwd()
1611        };
1612        env::set_var("PWD", &pwd_val);
1613        // c:1255-1259 — oldpwd = getenv("OLDPWD") ?: ztrdup(pwd).
1614        if env::var("OLDPWD").is_err() {
1615            env::set_var("OLDPWD", &pwd_val); // c:1257
1616        }
1617
1618        // Initialize fpath from FPATH env var or use defaults
1619        let fpath = env::var("FPATH")
1620            .unwrap_or_default()
1621            .split(':')
1622            .filter(|s| !s.is_empty())
1623            .map(PathBuf::from)
1624            .collect();
1625
1626        let history = HistoryEngine::new().ok();
1627
1628        // Seed canonical OPTS_LIVE with defaults BEFORE any setsparam
1629        // call. assignstrvalue early-returns when `unset(EXECOPT)`
1630        // (c:2701 guard); without the option table populated, EXECOPT
1631        // reads false and every paramtab write below is a silent no-op.
1632        if opt_state_len() == 0 {
1633            for (k, v) in Self::default_options() {
1634                opt_state_set(&k, v);
1635            }
1636        }
1637
1638        // c:Src/params.c:838-847 — `for (ip = special_params; ip->node.nam;
1639        //     ip++) paramtab->addnode(paramtab, ztrdup(ip->node.nam), ip);`
1640        // The specials go into paramtab FIRST, ahead of every non-special
1641        // seed below, because creation ORDER is observable: a new key is
1642        // front-inserted into its bucket chain (c:Src/hashtable.c:214-215)
1643        // and `${(k)parameters}` prints that chain walk verbatim
1644        // (c:Src/hashtable.c:420-434). Seeding NULLCMD / FUNCNEST / PS1 /
1645        // … before this loop put them AHEAD of the specials they follow in
1646        // the C table (`#` at c:304 vs NULLCMD at c:378; UID at c:312 vs
1647        // FUNCNEST at c:366), which is exactly where zshrs's parameter
1648        // order diverged from zsh's. With the table seeded first, those
1649        // later `setsparam`/`setiparam` calls hit an existing node and
1650        // replace it IN PLACE (c:187-203 `replacing:`), keeping C's slot.
1651        // c:Src/params.c:384-394 — IPDEF8/IPDEF9 macros stamp
1652        // `PM_SCALAR|PM_SPECIAL` (IPDEF8 for `PATH`/`FPATH`/etc.) and
1653        // `PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT` (IPDEF9 for `path`/
1654        // `fpath`/etc.) on every entry in the createparamtable table.
1655        // setsparam/setaparam above create plain PM_SCALAR/PM_ARRAY
1656        // entries; this loop applies the PM_SPECIAL + PM_TIED bits
1657        // (plus the IPDEF9 PM_DONTIMPORT bit on the array side) so
1658        // `${(t)PATH}` reads `scalar-tied-export-special` and
1659        // `${(t)path}` reads `array-tied-special`.
1660        //
1661        // Walks the `special_params` table (params.rs:464+) which is
1662        // the Rust port of the C IPDEF list. For each entry: OR the
1663        // declared pm_flags onto the existing paramtab entry. The
1664        // tied-pair entries (PM_TIED) also need PM_SPECIAL OR'd in
1665        // since the IPDEF8/IPDEF9 macros add PM_SPECIAL implicitly;
1666        // the table declares only the per-entry-distinct flags.
1667        let stamp_special_params = || {
1668            use crate::ported::params::{paramtab, special_params};
1669            use crate::ported::zsh_h::{PM_ARRAY, PM_DONTIMPORT, PM_SCALAR, PM_SPECIAL, PM_TIED};
1670            if let Ok(mut tab) = paramtab().write() {
1671                // Stamp PM_SPECIAL onto every entry the special_params
1672                // table declares. For tied scalars (PATH/FPATH/etc),
1673                // also walks `tied_name` to apply IPDEF9-flag bits
1674                // (PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT|PM_TIED) onto the
1675                // partner array entry (path/fpath/etc) — those array
1676                // names aren't in the special_params table directly
1677                // but C zsh's createparamtable emits IPDEF9 rows for
1678                // them at Src/params.c:425-432.
1679                use crate::ported::zsh_h::{hashnode, param, PM_DONTIMPORT as PM_DI, PM_UNSET};
1680                for entry in special_params.iter() {
1681                    // c:384/394 IPDEF8/9 — `D|PM_SCALAR|PM_SPECIAL` or
1682                    // `D|PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT`.
1683                    //
1684                    // Mask `entry.pm_flags` to the attribute bits that
1685                    // may be OR'd onto an existing Param.
1686                    //
1687                    // PM_READONLY IS included. C declares it on 16 rows
1688                    // via `PM_READONLY_SPECIAL` (c:Src/zsh.h:1925 —
1689                    // `PM_SPECIAL|PM_READONLY|PM_RO_BY_DESIGN`): the
1690                    // IPDEF1 pair `#`/`TTYIDLE` (c:304,314), IPDEF2 `-`
1691                    // (c:318), the IPDEF4 block `!`/`$`/`?`/`HISTCMD`/
1692                    // `LINENO`/`PPID`/`ZSH_SUBSHELL` (c:351-358) plus
1693                    // `status` (c:424), IPDEF9 `*`/`@` (c:392-393) and
1694                    // `zsh_eval_context` (c:438), and IPDEF8
1695                    // `ZSH_EVAL_CONTEXT` (c:408). `special_params`
1696                    // (params.rs:477+) declares exactly those 16 and no
1697                    // others, so the bit lands on precisely C's set.
1698                    //
1699                    // An earlier revision stripped PM_READONLY here to
1700                    // keep internal-runtime writes from tripping
1701                    // `assignstrvalue`'s guard. Nearly all of those
1702                    // writers already mutate the paramtab node in place
1703                    // — exactly as C writes the backing C global behind
1704                    // a no-op GSU (c:Src/params.c:351, IPDEF4 uses
1705                    // `varint_readonly_gsu` =
1706                    // `{intvargetfn, nullintsetfn, stdunsetfn}`). The
1707                    // in-place sites are `fusevm_bridge.rs:242,261,13045`
1708                    // (ZSH_SUBSHELL bump on `u_val`) and
1709                    // `fusevm_bridge.rs:12590,12594,12714,12718` +
1710                    // `exec.rs:7651,7655` (zsh_eval_context /
1711                    // ZSH_EVAL_CONTEXT push+pop). None call
1712                    // `setsparam`/`setiparam`.
1713                    //
1714                    // The ONE writer that did route through `setsparam`
1715                    // was `endparamscope`'s deferred scope-pop restore
1716                    // (`params.rs`, the `None =>` arm of the `deferred`
1717                    // loop): with no GSU wired it name-routes the
1718                    // restore and so re-entered the guard, emitting a
1719                    // spurious `read-only variable: NAME` while
1720                    // unwinding a scope that had shadowed one. C cannot
1721                    // reach the guard there because c:5915-5933 calls
1722                    // the setfn directly. That arm now drops the bit for
1723                    // the duration of the restore, matching C.
1724                    //
1725                    // With that handled, restoring the flag costs the
1726                    // runtime nothing while making `typeset X=v`,
1727                    // `readonly X=v` and `X+=v` reject the way
1728                    // c:Src/params.c:3216 does, and making
1729                    // `paramtypestr` (c:Src/Modules/parameter.c:75-76)
1730                    // emit the `-readonly` component that
1731                    // `${parameters[X]}` is read for.
1732                    //
1733                    // PM_UNSET is included: lookup_special_var arms for
1734                    // TRY_BLOCK_ERROR / TRY_BLOCK_INTERRUPT (and other
1735                    // PM_UNSET entries with sentinel defaults) check
1736                    // this bit to decide between "stored value" vs
1737                    // "uninitialized → return -1 sentinel". The flag
1738                    // gets cleared by assignstrvalue at c:3660 on any
1739                    // write, so it correctly tracks "ever assigned".
1740                    // Bug #143 in docs/BUGS.md.
1741                    let safe_pm_flags = entry.pm_flags
1742                        & (PM_TIED | PM_DI | PM_UNSET | crate::ported::zsh_h::PM_READONLY);
1743                    // c:Src/params.c — IPDEF macros set PM_TYPE bits
1744                    // (PM_INTEGER for IPDEF5/6, PM_ARRAY for IPDEF9,
1745                    // PM_HASHED for IPDEF-hash) along with PM_SPECIAL.
1746                    // zshrs's previous init only ORed PM_SPECIAL +
1747                    // tied/di/unset/readonly — never the type bit. If
1748                    // setsparam ran BEFORE init_partab_params (it does
1749                    // for OPTIND/SHLVL at vm_helper.rs:874/878), the
1750                    // param entry stayed PM_SCALAR and `typeset -p
1751                    // OPTIND` emitted `typeset OPTIND=1` instead of
1752                    // zsh's `typeset -i10 OPTIND=1`. OR the pm_type
1753                    // into the bits so the type attribute lands.
1754                    let mut bits = safe_pm_flags | PM_SPECIAL | entry.pm_type;
1755                    // c:Src/zsh.h:1925 — `PM_READONLY_SPECIAL` is the
1756                    // three-bit set `PM_SPECIAL|PM_READONLY|
1757                    // PM_RO_BY_DESIGN`. `special_params` stores only
1758                    // PM_READONLY per row (the other two are implied by
1759                    // the IPDEF macro), so complete the triple here:
1760                    // PM_SPECIAL is already OR'd into `bits` above, and
1761                    // this adds the PM_RO_BY_DESIGN companion that
1762                    // distinguishes a by-design readonly special from a
1763                    // user `readonly` (c:Src/zsh.h:1923).
1764                    if (entry.pm_flags & crate::ported::zsh_h::PM_READONLY) != 0 {
1765                        bits |= crate::ported::zsh_h::PM_RO_BY_DESIGN;
1766                    }
1767                    if entry.pm_type == PM_ARRAY {
1768                        bits |= PM_DI;
1769                    }
1770                    let _ = PM_SCALAR;
1771                    let _ = PM_DONTIMPORT;
1772                    if let Some(pm) = tab.get_mut(entry.name) {
1773                        let was_integer =
1774                            (pm.node.flags as u32 & crate::ported::zsh_h::PM_INTEGER) != 0;
1775                        pm.node.flags |= bits as i32;
1776                        // c:Src/params.c:344 IPDEF4 / c:353 IPDEF5 — the
1777                        // C struct literal initialises the `base` field
1778                        // to 10 for every PM_INTEGER special. zshrs's
1779                        // initial paramtab seeding doesn't carry that
1780                        // through (the special_paramdef table has no
1781                        // `base` field). Set the default here so
1782                        // `printparamnode`'s PMTF_USE_BASE arm at
1783                        // params.rs:9341 emits "10" between
1784                        // `integer` and the name (`integer 10 readonly
1785                        // !=0`). Bug #297 in docs/BUGS.md.
1786                        if entry.pm_type == crate::ported::zsh_h::PM_INTEGER && pm.base == 0 {
1787                            pm.base = 10;
1788                        }
1789                        // When OR-ing PM_INTEGER onto a param that
1790                        // was previously PM_SCALAR (i.e. setsparam ran
1791                        // BEFORE init_partab_params, storing the value
1792                        // in u_str), parse the u_str into u_val so the
1793                        // integer getter reads the correct value. C
1794                        // zsh's setsparam-equivalent path detects the
1795                        // pm's PM_TYPE first and routes through
1796                        // intsetfn, but zshrs's setsparam at the bin
1797                        // entry point predates init_partab_params, so
1798                        // it lands as PM_SCALAR storage that the
1799                        // type-flip needs to migrate.
1800                        if !was_integer
1801                            && entry.pm_type == crate::ported::zsh_h::PM_INTEGER
1802                            && pm.u_val == 0
1803                        {
1804                            if let Some(ref s) = pm.u_str {
1805                                pm.u_val = s.parse::<i64>().unwrap_or(0);
1806                                pm.u_str = None;
1807                            }
1808                        }
1809                        // c:Src/zsh.h IPDEF8/IPDEF9 — the third macro
1810                        // arg is the tied partner name; mapped into
1811                        // `pm->ename` so `typeset -p` can find the
1812                        // peer for the PM_TIED swap. Bug #410.
1813                        if let Some(peer) = entry.tied_name {
1814                            pm.ename = Some(peer.to_string());
1815                        }
1816                    } else {
1817                        // Param hasn't been created yet (e.g. PATH gets
1818                        // imported lazily via the env fallback in
1819                        // getsparam at params.rs:4104; array specials
1820                        // like `pipestatus` / `funcstack` / `dirstack`
1821                        // / `zsh_scheduled_events` aren't pre-populated).
1822                        // Seed an empty placeholder carrying the
1823                        // canonical flag set so subsequent setsparam /
1824                        // `(t)X` / `${+X}` observers see the IPDEF
1825                        // attribute bits AND `${+X}` returns 1.
1826                        let u_arr = if entry.pm_type == PM_ARRAY {
1827                            Some(Vec::new())
1828                        } else {
1829                            None
1830                        };
1831                        let pm: crate::ported::zsh_h::Param = Box::new(param {
1832                            node: hashnode {
1833                                next: None,
1834                                nam: entry.name.to_string(),
1835                                flags: (entry.pm_type as i32) | bits as i32,
1836                            },
1837                            u_data: 0,
1838                            u_tied: None,
1839                            u_arr,
1840                            u_str: None,
1841                            u_val: 0,
1842                            u_dval: 0.0,
1843                            u_hash: None,
1844                            gsu_s: None,
1845                            gsu_i: None,
1846                            gsu_f: None,
1847                            gsu_a: None,
1848                            gsu_h: None,
1849                            // c:Src/params.c:344 IPDEF4 / c:353 IPDEF5 —
1850                            // PM_INTEGER specials default base=10.
1851                            base: if entry.pm_type == crate::ported::zsh_h::PM_INTEGER {
1852                                10
1853                            } else {
1854                                0
1855                            },
1856                            width: 0,
1857                            env: None,
1858                            // c:Src/zsh.h IPDEF8/IPDEF9 — tied partner
1859                            // name. Bug #410.
1860                            ename: entry.tied_name.map(|s| s.to_string()),
1861                            old: None,
1862                            level: 0,
1863                        });
1864                        tab.insert(entry.name.to_string(), pm);
1865                    }
1866                    // Tied partner side. The previous loop body ORed
1867                    // PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT|PM_TIED onto the
1868                    // partner indiscriminately, but for a SCALAR ↔
1869                    // ARRAY tied pair (PATH ↔ path, FIGNORE ↔ fignore),
1870                    // that incorrectly stamped PM_ARRAY onto the scalar
1871                    // partner (FIGNORE, PATH, FPATH, MAILPATH, MANPATH,
1872                    // PSVAR, CDPATH, MODULE_PATH). Result: `(t)PATH`
1873                    // returned `array-tied-export-special` instead of
1874                    // `scalar-tied-export-special`.
1875                    //
1876                    // Both partners are already listed in `special_params`
1877                    // (the scalar at the IPDEF8 block, the array at the
1878                    // IPDEF9 block past the sentinel), so each gets its
1879                    // own pass through this loop and ends up with the
1880                    // correct flags. No cross-stamping needed.
1881                    let _ = entry.tied_name;
1882                }
1883            }
1884        };
1885        // c:Src/init.c:1277 — `inittyptab();  /* initialize the ztypes table */`
1886        // runs inside setupvals BEFORE `createparamtable()` (c:Src/init.c:1286).
1887        // This executor is the fusevm runtime's createparamtable entry point,
1888        // and the seeding below reaches `isident()` (WORDCHARS, …), which is
1889        // typtab-driven — with a zeroed typtab every name fails IIDENT and the
1890        // seed aborts with "not an identifier: WORDCHARS".
1891        crate::ported::utils::inittyptab(); // c:1277
1892        stamp_special_params(); // c:838-847 — create in C's order
1893                                // Standard zsh scalar param defaults — direct port of
1894                                // `createparamtable` (Src/params.c:817-988) + the `setupvals`
1895                                // tail. Writes through canonical `setsparam` (Src/params.c:3350).
1896                                //
1897                                // c:params.c:972-973 — ZSH_VERSION / ZSH_PATCHLEVEL.
1898                                // `zsh_version::ZSH_VERSION` (emitted by build.rs from the
1899                                // vendored `Config/version.mk`) is the development snapshot
1900                                // tag `5.9.0.3-test`; shipped zsh binaries report the clean
1901                                // release form (`5.9`). Bug #73 in docs/BUGS.md — cross-shell
1902                                // scripts that gate on `[[ $ZSH_VERSION = 5.9 ]]` or split on
1903                                // `.` expecting MAJOR.MINOR break on the `-test` suffix.
1904                                //
1905                                // Use the cleaned `patchlevel::ZSH_VERSION` here ("5.9") and
1906                                // surface the full snapshot tag as `$ZSHRS_VERSION` for
1907                                // zshrs-specific identity checks.
1908                                // ZSH_VERSION / ZSH_PATCHLEVEL / ZSHRS_VERSION / ZSH_NAME /
1909                                // ZSH_ARGZERO are NOT seeded here: C creates them at the END of
1910                                // `createparamtable` (c:970-973, after the environ import) and
1911                                // ZSH_NAME at `Src/init.c:1364` (setupvals, later still). They
1912                                // are seeded at those C positions further down, because a name
1913                                // created before the import lands in a different chain slot —
1914                                // `ZSH_NAME` seeded here came out BEHIND every same-bucket
1915                                // environment variable in `${(k)parameters}` instead of ahead
1916                                // of them (c:Src/hashtable.c:214-215 front-insert).
1917        setsparam("WORDCHARS", "*?_-.[]~=/&;!#$%^(){}<>");
1918        // SHLVL is NOT seeded here. c:Src/params.c:948-951 increments it
1919        // AFTER the environ-import loop, so the +1 lives at the end of that
1920        // loop below — see the `c:948-951` block. Doing it here instead meant
1921        // parsing the raw env string with `parse::<i32>()`, which mis-read
1922        // every non-decimal form C accepts via zstrtol_underscore
1923        // (SHLVL=0x10 must give 17, 010 → 9, 1_0 → 11, 9abc → 10, abc → 1).
1924        // POSIX/zsh default IFS: space + tab + newline + NUL.
1925        setsparam("IFS", " \t\n\0");
1926        // POSIX getopts: OPTIND starts at 1.
1927        setsparam("OPTIND", "1");
1928        // Note: OPTERR is NOT pre-initialised. zsh leaves it unset
1929        // even after `getopts` calls (verified: `getopts ":a" opt -a`
1930        // does not set it). It's a user-writable variable that
1931        // starts unset. Bug #150 in docs/BUGS.md.
1932        // zsh wipes inherited `$_` (unlike bash).
1933        setsparam("_", "");
1934        // c:params.c:5064 — histchars derives from bangchar+hatchar+
1935        // hashchar (defaults `!`, `^`, `#`). At init the special
1936        // entry may not exist yet — fall back to the literal default.
1937        let histchars_val = paramtab()
1938            .read()
1939            .ok()
1940            .and_then(|t| {
1941                t.get("histchars")
1942                    .or_else(|| t.get("HISTCHARS"))
1943                    .map(|pm| histcharsgetfn(pm))
1944            })
1945            .unwrap_or_else(|| "!^#".to_string());
1946        setsparam("histchars", &histchars_val);
1947
1948        // c:Src/params.c:870-871 — `setsparam("TIMEFMT", ...)` etc.
1949        // Seed TIMEFMT explicitly so `${(k)parameters}` lists it
1950        // (the createparamtable() ported in ported::params isn't
1951        // invoked from this bin entry — its setsparam calls don't
1952        // run, so TIMEFMT only existed via the lookup_special_var
1953        // fallback, which scanpmparameters can't see).
1954        setsparam("TIMEFMT", crate::ported::zsh_system_h::DEFAULT_TIMEFMT);
1955        // c:Src/params.c:892 — `setsparam("TMPPREFIX",
1956        // ztrdup_metafy(DEFAULT_TMPPREFIX));`, the line immediately
1957        // before the TIMEFMT seed above. `DEFAULT_TMPPREFIX` is
1958        // "/tmp/zsh" (c:configure.ac:3030 → config.h). Same reason as
1959        // TIMEFMT: createparamtable() is not reached from this bin
1960        // entry, so without this seed `$TMPPREFIX` existed only when
1961        // the environment happened to export it — every scrubbed-env
1962        // launch (cron, launchd/systemd unit, container entrypoint,
1963        // `env -i`) left it unset and every temp-file path derived
1964        // from it fell back per-call-site.
1965        //
1966        // C seeds unconditionally BEFORE the import loop (c:870 vs
1967        // c:893+) and the import then overwrites via assignsparam, so
1968        // an exported $TMPPREFIX still wins. zshrs's import only
1969        // rewrites an entry that is still PM_UNSET, so the env value is
1970        // resolved HERE instead — same end state, and the node is
1971        // created at C's position in the bucket chain. Skipping the
1972        // seed when the environment had TMPPREFIX (the previous shape)
1973        // deferred creation into the import loop, which put TMPPREFIX
1974        // behind every environment variable that hashes to its bucket.
1975        //
1976        // The lookup reads the process-entry environ snapshot, the same
1977        // source the import loop below walks (see the `environ` static
1978        // in ported::params for why the live environment is not it).
1979        let env_at_entry = |name: &str| -> Option<String> {
1980            crate::ported::params::environ
1981                .get()
1982                .and_then(|v| {
1983                    v.iter()
1984                        .find(|(k, _)| k == name)
1985                        .map(|(_, val)| val.clone())
1986                })
1987                .or_else(|| std::env::var(name).ok())
1988        };
1989        setsparam(
1990            "TMPPREFIX",
1991            env_at_entry("TMPPREFIX")
1992                .as_deref()
1993                .unwrap_or(crate::ported::config_h::DEFAULT_TMPPREFIX),
1994        ); // c:870
1995           // c:Src/init.c:1214-1215 — `nullcmd = ztrdup("cat");
1996           // readnullcmd = ztrdup(DEFAULT_READNULLCMD);`. Real paramtab
1997           // seeds (NOT read-time fallbacks) so `unset NULLCMD` truly
1998           // unsets — the bare-redirect "redirection with no command"
1999           // diagnostic depends on getsparam returning None afterwards.
2000           // c:config.h:48 DEFAULT_READNULLCMD "more" — the parity
2001           // floor agrees: scrubbed-env Homebrew zsh 5.9.1 -fc reports
2002           // READNULLCMD=more (probed; the previous macOS arm's "less"
2003           // guess came from the USER's env exporting READNULLCMD=less
2004           // — zpwr sets it). This block runs AFTER the env import, so
2005           // these are DEFAULT seeds only: an env-imported value must
2006           // win (C seeds before the import loop, c:854-885 vs c:893+).
2007        if getsparam("NULLCMD").map_or(true, |v| v.is_empty()) {
2008            setsparam("NULLCMD", "cat");
2009        }
2010        if getsparam("READNULLCMD").map_or(true, |v| v.is_empty()) {
2011            setsparam("READNULLCMD", crate::ported::config_h::DEFAULT_READNULLCMD);
2012        }
2013        // c:Src/params.c:873-876 — `gethostname(hostnam, 256);
2014        //                            setsparam("HOST", ztrdup_metafy(hostnam));`
2015        // Seeded HERE, before the import loop, exactly like C; it used
2016        // to run at the very end of this constructor, which put HOST
2017        // ahead of same-bucket specials (PROMPT) that C creates first.
2018        // The env value is resolved up front for the same reason as
2019        // TMPPREFIX above (C's import would overwrite it).
2020        let mut host_buf = [0u8; 256];
2021        let host_rc = unsafe { libc::gethostname(host_buf.as_mut_ptr() as *mut libc::c_char, 256) }; // c:874
2022        let hostname = if host_rc == 0 {
2023            std::ffi::CStr::from_bytes_until_nul(&host_buf)
2024                .ok()
2025                .and_then(|c| c.to_str().ok())
2026                .unwrap_or("")
2027                .to_string()
2028        } else {
2029            String::new()
2030        };
2031        setsparam("HOST", env_at_entry("HOST").as_deref().unwrap_or(&hostname)); // c:875
2032                                                                                 // c:Src/params.c:878-882 — `setsparam("LOGNAME", (str = getlogin())
2033                                                                                 //     && *str ? ztrdup_metafy(str) : ztrdup(cached_username));`
2034                                                                                 // Also pre-import in C (c:878 vs c:893+); creating it during the
2035                                                                                 // import instead put LOGNAME behind the environment variables
2036                                                                                 // sharing its bucket.
2037        let logname_default = {
2038            let from_getlogin = unsafe {
2039                let p = libc::getlogin(); // c:880
2040                if p.is_null() {
2041                    String::new()
2042                } else {
2043                    std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
2044                }
2045            };
2046            if from_getlogin.is_empty() {
2047                crate::ported::utils::get_username() // c:882 cached_username
2048            } else {
2049                from_getlogin
2050            }
2051        };
2052        setsparam(
2053            "LOGNAME",
2054            env_at_entry("LOGNAME")
2055                .as_deref()
2056                .unwrap_or(&logname_default),
2057        ); // c:878
2058           // c:Src/init.c:1186-1193 — default prompt strings. zsh sets
2059           // PS4 to "+%N:%i> " for ZSH emulation ("+ " for KSH/SH).
2060           // Without seeding, PS4 reads empty and `set -x` output has
2061           // no prefix at all. Bug #92 in docs/BUGS.md.
2062           //
2063           // C zsh runs createparamtable's env-import loop (c:893-924)
2064           // BEFORE init.c:1186 fires, so an exported $PS4 in the parent
2065           // env wins over the default seed. zshrs's env import happens
2066           // further down in ShellExecutor::new() (at the createparamtable
2067           // call site), so getsparam() reads None here even when env has
2068           // a value, and the default would clobber the user's PS4.
2069           //
2070           // Additional wrinkle: C zsh's PROMPT / PROMPT2 / PROMPT3 /
2071           // PROMPT4 params are ALIASES for PS1..PS4 (Src/params.c:381,
2072           // 415-421 — both IPDEF7R entries bind to the same `prompt*`
2073           // global). So `export PROMPT4=...` in the parent env sets the
2074           // shared global, and `$PS4` reads the same string. The user's
2075           // interactive shell exports PROMPT4 (the form zsh's prompt
2076           // theme system uses), so when zshrs -x runs, PROMPT4 is in
2077           // env but PS4 is not. Without aliasing in the env-probe step,
2078           // zshrs seeds default PS4 and ignores the user's customised
2079           // prefix.
2080           //
2081           // Probe env::var directly for the name AND its alias; first
2082           // non-empty wins. Only fall through to the default seed when
2083           // every candidate is empty. Mirrors C zsh's behavior without
2084           // reshuffling the rest of new(). Bug: `zshrs -x` ignored the
2085           // user's custom PS4/PROMPT4 unless re-forwarded with
2086           // `PS4=$PROMPT4 zshrs -x`.
2087        let seed_prompt = |name: &str, alias: Option<&str>, default: &str| {
2088            let cur = crate::ported::params::getsparam(name);
2089            let have_param = cur.as_deref().map_or(false, |s| !s.is_empty());
2090            if have_param {
2091                return;
2092            }
2093            // Probe primary name first, then the C-side alias.
2094            // An EMPTY exported value counts: C's env import (c:893-924)
2095            // assigns whatever `environ` holds, empty string included, and
2096            // it runs before the c:1196 defaults, so `export PS1=` yields
2097            // an empty prompt rather than `%m%# `. Testing only for a
2098            // NON-empty value skipped that case and re-seeded the default.
2099            for candidate in std::iter::once(name).chain(alias.into_iter()) {
2100                if let Ok(env_val) = std::env::var(candidate) {
2101                    setsparam(name, &env_val);
2102                    return;
2103                }
2104            }
2105            setsparam(name, default);
2106        };
2107        seed_prompt("PS4", Some("PROMPT4"), "+%N:%i> ");
2108        // c:Src/init.c:1181-1190 —
2109        //     if(unset(INTERACTIVE)) {
2110        //         prompt = ztrdup("");
2111        //         prompt2 = ztrdup("");
2112        //     } else ... {
2113        //         prompt  = ztrdup("%m%# ");
2114        //         prompt2 = ztrdup("%_> ");
2115        //     }
2116        // Non-interactive shells get EMPTY primary/secondary prompts
2117        // — `zsh -fc 'typeset'` lists PS1='' — while interactive ones
2118        // get the %m%# defaults. PS3/PS4/SPROMPT are seeded
2119        // unconditionally in C (c:1191-1194). PS1 may be reset by the
2120        // prompt-theme layer; only seed when the slot is empty so any
2121        // prior theme write wins.
2122        let interactive = crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE);
2123        seed_prompt(
2124            "PS1",
2125            Some("PROMPT"),
2126            if interactive { "%m%# " } else { "" },
2127        );
2128        seed_prompt(
2129            "PS2",
2130            Some("PROMPT2"),
2131            if interactive { "%_> " } else { "" },
2132        );
2133        // c:Src/init.c:1191 — `prompt3 = ztrdup("?# ");`
2134        seed_prompt("PS3", Some("PROMPT3"), "?# ");
2135        // c:Src/init.c:1194 — `sprompt = ztrdup("zsh: correct '%R'
2136        // to '%r' [nyae]? ");` — spelling-correction prompt.
2137        seed_prompt("SPROMPT", None, "zsh: correct '%R' to '%r' [nyae]? ");
2138        // c:Src/params.c:417-422 — `PROMPT*` aliases for `PS*`.
2139        // C zsh's IPDEF7("PROMPT", &prompt), IPDEF7("PROMPT2",
2140        // &prompt2), IPDEF7("PROMPT3", &prompt3), IPDEF7("PROMPT4",
2141        // &prompt4) all point to the same C globals as the matching
2142        // IPDEF7("PS{1..4}", ...) entries — they're aliases in C,
2143        // sharing storage. zshrs's paramtab keeps them as separate
2144        // entries; mirror the alias by mirroring the value here.
2145        // Bug #274 in docs/BUGS.md (PROMPT3 was the visible report;
2146        // PROMPT/PROMPT2/PROMPT4 had the same gap silently).
2147        for (alias, source) in &[
2148            ("PROMPT", "PS1"),
2149            ("PROMPT2", "PS2"),
2150            ("PROMPT3", "PS3"),
2151            ("PROMPT4", "PS4"),
2152        ] {
2153            if crate::ported::params::getsparam(alias).map_or(true, |s| s.is_empty()) {
2154                if let Some(v) = crate::ported::params::getsparam(source) {
2155                    setsparam(alias, &v);
2156                }
2157            }
2158        }
2159        // c:params.c:858-860 — standard non-special param defaults.
2160        // C uses `setiparam(...)` (PM_INTEGER) for these so
2161        // `(t)MAILCHECK` etc. report `integer`. zshrs previously
2162        // routed through `setsparam` (PM_SCALAR) — the value worked
2163        // but the type bit was wrong, breaking
2164        // `case "${(t)LISTMAX}" in *integer*)` and any path that
2165        // gates on arithmetic-typed semantics. Bug #268 in
2166        // docs/BUGS.md.
2167        crate::ported::params::setiparam("MAILCHECK", 60); // c:858
2168        crate::ported::params::setiparam("KEYTIMEOUT", 40); // c:859
2169        crate::ported::params::setiparam("LISTMAX", 100); // c:860
2170                                                          // c:config.h:1004 — MAX_FUNCTION_DEPTH=500. Advisory cap;
2171                                                          // dispatch_function_call enforces against this.
2172        crate::ported::params::setiparam("FUNCNEST", 500);
2173
2174        // Run setlocale(LC_ALL, "") so nl_langinfo() (used by the
2175        // `langinfo` module) returns the host's actual locale instead
2176        // of the C/POSIX default ("US-ASCII"). Direct port of zsh's
2177        // Src/init.c:1208 setlocale call. unsafe { } around libc is
2178        // standard for this exact use-case — setlocale is process-
2179        // global and must run once at startup.
2180        unsafe {
2181            libc::setlocale(libc::LC_ALL, c"".as_ptr());
2182        }
2183
2184        // c:hashtable.c:1206 createaliastables() — seeds aliastab with
2185        // the `run-help` / `which-command` defaults. Run once at shell
2186        // init so the canonical port owns the default-alias set; the
2187        // Executor's `aliases` HashMap then mirrors aliastab.
2188        crate::ported::hashtable::createaliastables();
2189        // Build the initial $path tied array as a local — fans out
2190        // to paramtab below; no ShellExecutor mirror anymore.
2191        let mut arrays: HashMap<String, Vec<String>> = HashMap::new();
2192        let path_dirs: Vec<String> = env::var("PATH")
2193            .unwrap_or_default()
2194            .split(':')
2195            .map(|s| s.to_string())
2196            .collect();
2197        arrays.insert("path".to_string(), path_dirs);
2198        let mut exec = Self {
2199            // c:Src/init.c:479 — `-c` mode: scriptname = scriptfilename
2200            // = ztrdup("zsh"). Both start at the literal "zsh".
2201            // dispatch_function_call overrides scriptname per c:5903;
2202            // scriptfilename stays at the outer file.
2203            scriptname: Some("zsh".to_string()),
2204            scriptfilename: Some("zsh".to_string()),
2205            subshell_snapshots: Vec::new(),
2206            inline_env_stack: Vec::new(),
2207            current_command_glob_failed: std::cell::Cell::new(false),
2208            jobs: JobTable::new(),
2209            fpath,
2210            history,
2211            completions: HashMap::new(),
2212            process_sub_counter: 0,
2213            zstyles: Vec::new(),
2214            local_scope_depth: 0,
2215            pending_underscore: None,
2216            in_dq_context: 0,
2217            in_scalar_assign: 0,
2218            profiling_enabled: false,
2219            compsys_cache: std::cell::OnceCell::new(),
2220            compinit_pending: None, // (receiver, start_time)
2221            plugin_cache: {
2222                let pc_path = crate::plugin_cache::default_cache_path();
2223                if let Some(parent) = pc_path.parent() {
2224                    let _ = fs::create_dir_all(parent);
2225                }
2226                match crate::plugin_cache::PluginCache::open(&pc_path) {
2227                    Ok(pc) => {
2228                        let (plugins, functions) = pc.stats();
2229                        tracing::info!(
2230                            plugins,
2231                            cached_functions = functions,
2232                            path = %pc_path.display(),
2233                            "plugin_cache: sqlite opened"
2234                        );
2235                        Some(pc)
2236                    }
2237                    Err(e) => {
2238                        // A corrupt cache file is not a permanent condition:
2239                        // `plugins.db` is derived data, rebuilt by the next
2240                        // plugin scan. SQLite answers a clobbered header with
2241                        // SQLITE_NOTADB ("file is not a database"), and the
2242                        // old behaviour logged that and moved on — so the
2243                        // cache stayed dead for every future shell too, with
2244                        // nothing but a log line to say why plugin lookups
2245                        // were slow. Discard the file and open once more;
2246                        // this is the same "drop it and rebuild silently"
2247                        // rule the shard cache already applies to a version
2248                        // mismatch. A second failure keeps the old
2249                        // behaviour, so a permissions problem still degrades
2250                        // instead of looping.
2251                        let corrupt = matches!(
2252                            e.sqlite_error_code(),
2253                            Some(rusqlite::ErrorCode::NotADatabase)
2254                                | Some(rusqlite::ErrorCode::DatabaseCorrupt)
2255                        );
2256                        if corrupt {
2257                            tracing::warn!(
2258                                error = %e,
2259                                path = %pc_path.display(),
2260                                "plugin_cache: corrupt — discarding and rebuilding"
2261                            );
2262                            let _ = fs::remove_file(&pc_path);
2263                            // The -wal/-shm side files belong to the database
2264                            // that just went away; leaving them makes the
2265                            // fresh open inherit a journal for a file that no
2266                            // longer exists.
2267                            for side in ["-wal", "-shm"] {
2268                                let mut p = pc_path.clone().into_os_string();
2269                                p.push(side);
2270                                let _ = fs::remove_file(PathBuf::from(p));
2271                            }
2272                            match crate::plugin_cache::PluginCache::open(&pc_path) {
2273                                Ok(pc) => {
2274                                    tracing::info!(
2275                                        path = %pc_path.display(),
2276                                        "plugin_cache: rebuilt after corruption"
2277                                    );
2278                                    Some(pc)
2279                                }
2280                                Err(e2) => {
2281                                    tracing::warn!(error = %e2, "plugin_cache: reopen after discard failed");
2282                                    None
2283                                }
2284                            }
2285                        } else {
2286                            tracing::warn!(error = %e, "plugin_cache: failed to open");
2287                            None
2288                        }
2289                    }
2290                }
2291            },
2292            deferred_compdefs: Vec::new(),
2293            returning: None,
2294            zsh_compat: false,
2295            bash_compat: false,
2296            posix_mode: false,
2297            worker_pool: {
2298                let config = crate::config::load();
2299                let pool_size = crate::config::resolve_pool_size(&config.worker_pool);
2300                std::sync::Arc::new(crate::worker::WorkerPool::new(pool_size))
2301            },
2302            intercepts: Vec::new(),
2303            async_jobs: HashMap::new(),
2304            next_async_id: 1,
2305            redirect_scope_stack: Vec::new(),
2306            multios_scope_stack: Vec::new(),
2307            exec_redirs_permanent: false,
2308            pipe_output_pending: false,
2309            pipe_output_scope: None,
2310            redirect_failed: false,
2311            functions_compiled: HashMap::new(),
2312            function_source: HashMap::new(),
2313            function_line_base: HashMap::new(),
2314            function_def_file: HashMap::new(),
2315            prompt_funcstack: Vec::new(),
2316            tied_array_to_scalar: HashMap::new(),
2317            ztest_pass_count: std::sync::atomic::AtomicUsize::new(0),
2318            ztest_fail_count: std::sync::atomic::AtomicUsize::new(0),
2319            ztest_skip_count: std::sync::atomic::AtomicUsize::new(0),
2320            ztest_pass_total: std::sync::atomic::AtomicUsize::new(0),
2321            ztest_fail_total: std::sync::atomic::AtomicUsize::new(0),
2322            ztest_skip_total: std::sync::atomic::AtomicUsize::new(0),
2323            ztest_run_failed: std::sync::atomic::AtomicBool::new(false),
2324            ztest_suppress_stdout: false,
2325        };
2326        // Publish the session worker pool so preprompt-time async hooks
2327        // (async_precmd) can reach it without an entered executor context.
2328        crate::async_precmd::set_session_pool(std::sync::Arc::clone(&exec.worker_pool));
2329        // Mirror env-derived path arrays into the `arrays` table so
2330        // user-level `fpath` / `path` array reads see the inherited
2331        // entries. zsh: `fpath+=…` should append to the inherited
2332        // 43-entry array, not replace it. Same for `path` (PATH).
2333        let fpath_arr: Vec<String> = exec
2334            .fpath
2335            .iter()
2336            .map(|p| p.to_string_lossy().to_string())
2337            .collect();
2338        if !fpath_arr.is_empty() {
2339            exec.set_array("fpath".to_string(), fpath_arr);
2340        }
2341        if let Ok(path) = env::var("PATH") {
2342            let path_arr: Vec<String> = path
2343                .split(':')
2344                .filter(|s| !s.is_empty())
2345                .map(String::from)
2346                .collect();
2347            if !path_arr.is_empty() {
2348                exec.set_array("path".to_string(), path_arr);
2349            }
2350        }
2351        // Register the standard tied path-family pairs so `path+=` /
2352        // `fpath+=` / etc. mirror through the array→scalar sync hook
2353        // in BUILTIN_APPEND_ARRAY (and the SET_ARRAY tied path).
2354        // Direct port of the implicit ties that zsh wires up at
2355        // startup for PATH/path, FPATH/fpath, etc. Source-of-truth
2356        // for the pairs is Src/init.c's `setupvals()` PM_TIED entries.
2357        // c:Src/params.c:395-422 IPDEF8 — full PM_TIED colonarr list:
2358        // CDPATH, FIGNORE, FPATH, MAILPATH, PATH, PSVAR, MODULE_PATH,
2359        // MANPATH (ZSH_EVAL_CONTEXT is readonly-special, excluded).
2360        for (scalar, arr) in [
2361            ("PATH", "path"),
2362            ("FPATH", "fpath"),
2363            ("MANPATH", "manpath"),
2364            ("CDPATH", "cdpath"),
2365            ("MODULE_PATH", "module_path"),
2366            ("PSVAR", "psvar"),
2367            ("FIGNORE", "fignore"),
2368            ("MAILPATH", "mailpath"),
2369        ] {
2370            exec.tied_array_to_scalar
2371                .insert(arr.to_string(), (scalar.to_string(), ":".to_string()));
2372        }
2373
2374        // Pour `path` (from env PATH split) into paramtab. The IPDEF9
2375        // flag set was stamped by the c:838-847 pass above and survives
2376        // the assignment (`assignaparam` keeps PM_DONTIMPORT on a
2377        // PM_SPECIAL node — c:3374 + params.rs), so no re-stamp is
2378        // needed here.
2379        for (k, v) in &arrays {
2380            setaparam(k, v.clone()); // c:params.c:3595
2381        }
2382
2383        // c:Src/params.c:893-924 — the environment import runs AFTER the
2384        // specials table (moved above, c:838-847) and after the c:854-885
2385        // non-special seeds, exactly as `createparamtable` sequences them.
2386        {
2387            use crate::ported::params::paramtab;
2388            if let Ok(mut tab) = paramtab().write() {
2389                use crate::ported::zsh_h::{param, PM_UNSET};
2390                // c:Src/params.c:893-924 environment-import loop —
2391                // every env var gets either a fresh exported paramtab
2392                // entry OR (when the entry pre-exists from
2393                // special_params) PM_EXPORTED OR'd onto its flags.
2394                // Without this, `declare -p PATH` printed `typeset -T
2395                // PATH=''` and `declare -p USER` printed nothing at
2396                // all because USER was never in paramtab.
2397                use crate::ported::zsh_h::hashnode as _hn;
2398                use crate::ported::zsh_h::{PM_EXPORTED, PM_SCALAR};
2399                // c:Src/params.c:4329-4342 colonarrsetfn — assigning a
2400                // tied IPDEF8 scalar (MANPATH, CDPATH, MODULE_PATH, …)
2401                // colonsplit()s the value into the partner array,
2402                // preserving empty components. The env import below
2403                // bypasses the GSU setfn, so collect the tied pairs
2404                // here and pour them through setaparam after the
2405                // paramtab lock drops. PATH→path / FPATH→fpath are
2406                // seeded earlier (vm_helper ~1160-1199) and skipped.
2407                let mut tied_env_arrays: Vec<(String, Vec<String>)> = Vec::new();
2408                // c:Src/params.c:893 — walk the process-entry environ
2409                // snapshot, not the live env (frameworks can mutate it
2410                // before init — see params.rs `environ` static).
2411                let environ_vars: Vec<(String, String)> = crate::ported::params::environ
2412                    .get()
2413                    .cloned()
2414                    .unwrap_or_else(|| std::env::vars().collect());
2415                for (env_name, env_value) in environ_vars {
2416                    if env_name.is_empty() || env_name.contains('[') {
2417                        continue;
2418                    }
2419                    if env_name.as_bytes()[0].is_ascii_digit() {
2420                        continue;
2421                    }
2422                    if !crate::ported::params::isident(&env_name) {
2423                        continue;
2424                    }
2425                    if let Some(pm) = tab.get_mut(&env_name) {
2426                        // c:Src/params.c:902-906 — the import loop runs
2427                        // `dontimport(pm->node.flags)` BEFORE doing
2428                        // anything to the entry; PM_DONTIMPORT names
2429                        // (`_`, IFS, GID/EGID, KEYBOARD_HACK — the
2430                        // IPDEF7/IPDEF2 rows, c:796-800) are skipped
2431                        // ENTIRELY: no PM_EXPORTED stamp, no value
2432                        // seed. zshrs previously OR'd PM_EXPORTED
2433                        // first, so an inherited env `_` made the
2434                        // special `_` exported and it leaked into
2435                        // `typeset +x -r` / `export -p` listings where
2436                        // zsh shows nothing.
2437                        if (pm.node.flags as u32 & crate::ported::zsh_h::PM_DONTIMPORT) != 0 {
2438                            continue; // c:905 `continue;`
2439                        }
2440                        pm.node.flags |= PM_EXPORTED as i32;
2441                        // c:Src/params.c:2769-2776 — assignstrvalue's
2442                        // PM_INTEGER arm, which C's env import reaches via
2443                        // `assignsparam(..., ASSPM_ENV_IMPORT)` (c:907-908;
2444                        // assignsparam forwards its `flags` verbatim at
2445                        // c:params.c assignstrvalue(v, val, flags)):
2446                        //     if (flags & ASSPM_ENV_IMPORT) {
2447                        //         char *ptr;
2448                        //         ival = zstrtol_underscore(val, &ptr, 0, 1);
2449                        //     } else
2450                        //         ival = mathevali(val);
2451                        //     v->pm->gsu.i->setfn(v->pm, ival);
2452                        // An integer param keeps its value in `u.val`, NOT in
2453                        // the scalar slot, so the `pm.u_str = env_value` seed
2454                        // below stored the digits somewhere no integer reader
2455                        // ever looks and EVERY pre-existing PM_INTEGER param
2456                        // silently ignored the environment: COLUMNS/LINES
2457                        // (IPDEF5, c:355-356) read back 0, while HISTSIZE,
2458                        // SAVEHIST, LISTMAX, MAILCHECK, KEYTIMEOUT and
2459                        // FUNCNEST kept their built-in defaults — i.e.
2460                        // `HISTSIZE=5000 zshrs -c ...` was a no-op.
2461                        //
2462                        // Base 0 + underscore=1 is not incidental: it is what
2463                        // makes `COLUMNS=0x10` 16, `COLUMNS=0b101` 5,
2464                        // `COLUMNS=010` 8 (octal — c:utils.c:2452-2461 takes
2465                        // the leading `0` then falls to `base = 8`),
2466                        // `COLUMNS=1_0` 10, and a trailing-garbage value like
2467                        // `9abc` a silent 9. mathevali would instead ERROR on
2468                        // `9abc`, which is precisely why C splits the two
2469                        // paths — importing a hostile environment must not
2470                        // abort the shell (upstream 546203a770, "33276: safer
2471                        // import of numerical variables from environment").
2472                        if (pm.node.flags as u32 & crate::ported::zsh_h::PM_INTEGER) != 0 {
2473                            let (ival, _) =
2474                                crate::ported::utils::zstrtol_underscore(&env_value, 0, true); // c:2773
2475                                                                                               // c:3660 — any assignstrvalue write clears PM_UNSET.
2476                            pm.node.flags &= !(PM_UNSET as i32);
2477                            // c:2774 — `v->pm->gsu.i->setfn(v->pm, ival)`.
2478                            // intsetfn is this port's stand-in for the gsu_i
2479                            // vtable: it name-dispatches the specials whose
2480                            // setter has side effects (SECONDS, RANDOM,
2481                            // HISTSIZE, …) and writes u.val otherwise.
2482                            crate::ported::params::intsetfn(pm.as_mut(), ival);
2483                            pm.env = Some(format!("{env_name}={env_value}"));
2484                            continue;
2485                        }
2486                        // c:Src/params.c:893-924 — C's env-import calls
2487                        // `assignsparam(..., ASSPM_ENV_IMPORT)` which
2488                        // routes through the param's GSU setfn. For
2489                        // SPECIAL scalars with cached storage (HOME,
2490                        // USERNAME, TERM, WORDCHARS, TERMINFO,
2491                        // TERMINFO_DIRS, KEYBOARD_HACK, histchars) the
2492                        // setfn writes to a separate `*_lock` global
2493                        // (e.g. home_lock). Just OR'ing PM_EXPORTED
2494                        // leaves those globals empty, so `$HOME` reads
2495                        // back "" even though HOME is in env. Mirror
2496                        // C by copying the env value into pm.u_str and
2497                        // (for cached specials) the matching global.
2498                        // Only seed cached state when the param was
2499                        // still marked PM_UNSET — i.e. nothing has set
2500                        // it yet. ShellExecutor::new's earlier init
2501                        // block (vm_helper line 837+) already ran
2502                        // setsparam for a few names (ZSH_ARGZERO,
2503                        // WORDCHARS, SHLVL with the +1 increment, IFS,
2504                        // OPTIND, …); those calls clear PM_UNSET so we
2505                        // must not overwrite them with the raw env
2506                        // value here. The PM_UNSET-still-set case is
2507                        // the "C zsh would have called
2508                        // assignsparam(...,ASSPM_ENV_IMPORT) and ours
2509                        // didn't yet" gap that bug #599 (HOME=` `) and
2510                        // %~ prompt expansion need.
2511                        let still_unset =
2512                            (pm.node.flags as u32 & crate::ported::zsh_h::PM_UNSET) != 0;
2513                        if still_unset {
2514                            pm.u_str = Some(env_value.clone());
2515                            pm.env = Some(format!("{}={}", env_name, env_value));
2516                            // c:Src/params.c:3660 — `assignstrvalue`
2517                            // clears PM_UNSET on any write. HOME / TERM
2518                            // / TERMINFO / TERMINFO_DIRS / WORDCHARS
2519                            // start life with PM_UNSET in
2520                            // `special_params` (params.rs SPECIAL_PARAMS
2521                            // table) so `lookup_special_var` skips the
2522                            // getfn for uninitialized specials; env
2523                            // import is the canonical "now it's set"
2524                            // event, so clear the bit.
2525                            pm.node.flags &= !(PM_UNSET as i32);
2526                            // Cached-state specials: route through
2527                            // the matching setfn so the global cache
2528                            // (home_lock / wordchars_lock / etc.)
2529                            // reflects the env value. Each setfn
2530                            // ignores its `pm` arg (matches C's
2531                            // UNUSED(Param pm)), so passing the
2532                            // borrowed paramtab entry is safe.
2533                            match env_name.as_str() {
2534                                "HOME" => {
2535                                    crate::ported::params::homesetfn(pm.as_mut(), env_value.clone())
2536                                }
2537                                "USERNAME" => crate::ported::params::usernamesetfn(
2538                                    pm.as_mut(),
2539                                    env_value.clone(),
2540                                ),
2541                                "TERM" => {
2542                                    crate::ported::params::termsetfn(pm.as_mut(), env_value.clone())
2543                                }
2544                                "WORDCHARS" => crate::ported::params::wordcharssetfn(
2545                                    pm.as_mut(),
2546                                    env_value.clone(),
2547                                ),
2548                                "TERMINFO" => crate::ported::params::terminfosetfn(
2549                                    pm.as_mut(),
2550                                    env_value.clone(),
2551                                ),
2552                                "TERMINFO_DIRS" => crate::ported::params::terminfodirssetfn(
2553                                    pm.as_mut(),
2554                                    env_value.clone(),
2555                                ),
2556                                _ => {}
2557                            }
2558                        }
2559                        // c:Src/params.c:907-908 — env import always
2560                        // assigns through the GSU setfn; for tied
2561                        // IPDEF8 scalars that is colonarrsetfn
2562                        // (c:4329-4342), which colonsplit()s the value
2563                        // into the partner array, empties preserved.
2564                        // Not gated on still_unset: C re-assigns on
2565                        // import regardless.
2566                        if (pm.node.flags as u32 & crate::ported::zsh_h::PM_TIED) != 0 {
2567                            if let Some(ref peer) = pm.ename {
2568                                if peer != "path" && peer != "fpath" {
2569                                    tied_env_arrays.push((
2570                                        peer.clone(),
2571                                        env_value.split(':').map(String::from).collect(), // c:4339 colonsplit
2572                                    ));
2573                                }
2574                            }
2575                        }
2576                    } else {
2577                        // Fresh entry — PM_SCALAR + PM_EXPORTED, value
2578                        // taken from env. Mirrors C zsh's c:907-908
2579                        // `assignsparam(..., ASSPM_ENV_IMPORT)` for
2580                        // names not already in the special table.
2581                        let pm: crate::ported::zsh_h::Param = Box::new(param {
2582                            node: _hn {
2583                                next: None,
2584                                nam: env_name.clone(),
2585                                flags: (PM_SCALAR | PM_EXPORTED) as i32,
2586                            },
2587                            u_data: 0,
2588                            u_tied: None,
2589                            u_arr: None,
2590                            u_str: Some(env_value.clone()),
2591                            u_val: 0,
2592                            u_dval: 0.0,
2593                            u_hash: None,
2594                            gsu_s: None,
2595                            gsu_i: None,
2596                            gsu_f: None,
2597                            gsu_a: None,
2598                            gsu_h: None,
2599                            base: 0,
2600                            width: 0,
2601                            env: Some(format!("{}={}", env_name, env_value)),
2602                            ename: None,
2603                            old: None,
2604                            level: 0,
2605                        });
2606                        tab.insert(env_name, pm);
2607                    }
2608                }
2609                // Apply the collected tied-pair splits after the env
2610                // walk. setaparam (the canonical store) needs the
2611                // same paramtab write lock held here, so write u_arr
2612                // directly on the peer entry — array reads route
2613                // through paramtab so this is the single store.
2614                for (peer, parts) in tied_env_arrays {
2615                    if let Some(apm) = tab.get_mut(peer.as_str()) {
2616                        apm.u_arr = Some(parts); // c:4339 — `*dptr = colonsplit(x, …)`
2617                        apm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
2618                    }
2619                }
2620                // c:Src/params.c:948-951 — runs AFTER the import loop:
2621                //     pm = (Param) paramtab->getnode(paramtab, "SHLVL");
2622                //     sprintf(buf, "%d", (int)++shlvl);
2623                //     /* shlvl value in environment needs updating unconditionally */
2624                //     addenv(pm, buf);
2625                // SHLVL is `IPDEF5("SHLVL", &shlvl, varinteger_gsu)` (c:358), so
2626                // the loop above has already parsed any inherited value into it
2627                // with zstrtol_underscore; C then increments THAT, in place.
2628                // Ordering is the whole point: the increment must observe the
2629                // imported value, and the import must not clobber the
2630                // increment. When SHLVL is absent from the environment the
2631                // param is still 0 here, so ++ yields 1 — matching C, whose
2632                // `shlvl` global starts at 0.
2633                //
2634                // addenv also exports the INCREMENTED value, which is why a
2635                // forked child sees 6 for `SHLVL=5 zsh -fc 'printenv SHLVL; true'`.
2636                // (A bare `printenv SHLVL` shows 5 because zsh exec's the last
2637                // command in place and backs the increment out — the shell is
2638                // being replaced, not nested. That is a separate mechanism.)
2639                if let Some(pm) = tab.get_mut("SHLVL") {
2640                    let next = pm.u_val + 1; // c:949 `++shlvl`
2641                    crate::ported::params::intsetfn(pm.as_mut(), next); // c:949
2642                    pm.node.flags &= !(PM_UNSET as i32);
2643                }
2644            }
2645        }
2646
2647        // c:Src/params.c:960-965 — HOME wiring, which C runs right
2648        // after the environment-import loop:
2649        //     pm = (Param) realparamtab->getnode2(realparamtab, "HOME");
2650        //     if (EMULATION(EMULATE_ZSH))
2651        //     {
2652        //         pm->node.flags &= ~PM_UNSET;
2653        //         if (!(pm->node.flags & PM_EXPORTED))
2654        //             addenv(pm, home);
2655        //     } else if (!home)
2656        //         pm->node.flags |= PM_UNSET;
2657        // `home` itself was synthesised from the password database
2658        // back in setupvals (c:Src/init.c:1237-1250) BEFORE the import
2659        // loop, so an inherited $HOME wins: the import calls
2660        // `homesetfn` and overwrites the synthesised value.
2661        //
2662        // zshrs reaches neither of those C sites from this bin entry,
2663        // so `$HOME` was whatever the environment supplied and nothing
2664        // else — a scrubbed launch (cron, launchd/systemd unit,
2665        // container entrypoint, `env -i`) got no $HOME at all, and
2666        // every `~`, rc-file path and cache path derived from it
2667        // silently resolved to "" (`~/x` expanded to `/x`).
2668        //
2669        // Ordering is preserved by only synthesising when the import
2670        // produced nothing: `var_os` is the exact "was it in the
2671        // environment" test C's loop keys off, so an explicitly empty
2672        // `HOME=` still stays empty (reference binary: `env -i … HOME=
2673        // zsh -f -c 'print -r -- "[${HOME-UNSET}]"'` prints `[]`).
2674        //
2675        // `home` itself comes from c:Src/init.c:1237-1250, inlined here
2676        // because C has no function to port — it is straight-line code
2677        // inside `setupvals()`:
2678        //     #ifdef USE_GETPWUID
2679        //         if ((pswd = getpwuid(cached_uid))) {
2680        //             if (EMULATION(EMULATE_ZSH))
2681        //                 home = ztrdup_metafy(pswd->pw_dir);
2682        //             cached_username = ztrdup_metafy(pswd->pw_name);
2683        //         }
2684        //         else
2685        //     #endif /* USE_GETPWUID */
2686        //         {
2687        //             if (EMULATION(EMULATE_ZSH))
2688        //                 home = ztrdup("/");
2689        //             cached_username = ztrdup("");
2690        //         }
2691        // Both arms are guarded on EMULATE_ZSH: under sh/ksh emulation
2692        // the C global stays NULL and `$HOME` can only come from the
2693        // environment. Reference binary agrees — `env -i TERM=dumb
2694        // PATH=/usr/bin:/bin zsh --emulate sh -f -c 'echo
2695        // "HOME=${HOME-UNSET}"'` prints `HOME=UNSET`, while the same
2696        // command without `--emulate sh` prints the password-database
2697        // home. `cached_username` is seeded separately (see the
2698        // getlogin() block below), so only the `home` half is here.
2699        if std::env::var_os("HOME").is_none()
2700            && crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_ZSH)
2701        {
2702            // c:Src/init.c:1239 — `getpwuid(cached_uid)`, cached_uid
2703            // being `getuid()` from c:1235.
2704            let pswd = unsafe { libc::getpwuid(libc::getuid()) };
2705            let pw_dir = if pswd.is_null() {
2706                std::ptr::null()
2707            } else {
2708                unsafe { (*pswd).pw_dir }
2709            };
2710            let h = if pw_dir.is_null() {
2711                // c:1248 — password lookup failed: `home = ztrdup("/")`.
2712                // A NULL `pw_dir` on a present entry is the same
2713                // "no usable home" case.
2714                "/".to_string()
2715            } else {
2716                // c:1241 — `home = ztrdup_metafy(pswd->pw_dir)`.
2717                crate::ported::utils::metafy(
2718                    &unsafe { std::ffi::CStr::from_ptr(pw_dir) }.to_string_lossy(),
2719                )
2720            };
2721            // Routes through `homesetfn`, so the `home` global and the
2722            // paramtab entry agree (c:Src/params.c:5118).
2723            crate::ported::params::setsparam("HOME", &h);
2724            // c:964-965 — the param is not PM_EXPORTED (it was not
2725            // imported), so C addenv's it. The reference binary
2726            // confirms the synthesised value reaches children:
2727            // `env -i TERM=dumb PATH=/usr/bin:/bin zsh -f -c
2728            // '/usr/bin/env'` lists `HOME=/Users/…`.
2729            crate::ported::params::addenv("HOME", &h);
2730        }
2731
2732        // NOT DONE HERE: c:Src/params.c:951 `addenv(pm, buf)`, which zputenv's
2733        // the INCREMENTED SHLVL into the process environment so a forked child
2734        // sees 6 for `SHLVL=5 zsh -fc 'printenv SHLVL; true'`. zshrs still
2735        // exports the inherited 5 there.
2736        //
2737        // Adding the addenv alone makes parity WORSE, not better, because it
2738        // is only half of a pair. C hands an exec'd command the DECREMENTED
2739        // value (c:Src/exec.c:4276-4281 — "for either implicit or explicit
2740        // exec, decrease $SHLVL as we're now done as a shell", guarded by
2741        // `!subsh && !forked`), which is why a bare `SHLVL=5 zsh -fc 'printenv
2742        // SHLVL'` prints 5 while `'printenv SHLVL; true'` prints 6 — the first
2743        // is exec'd in place, the second forked. Exporting 6 without that
2744        // decrement turns one divergence into four: the exec'd cases and every
2745        // nested-shell count start reading one too high.
2746        //
2747        // The decrement IS ported, at exec.rs:11195-11199, but on the
2748        // `ported::exec` path — not the fusevm path that actually runs `-c`.
2749        // Wiring both belongs in one change, with the exec side first.
2750        // c:Src/init.c:1907-1909 — `SHTTY = -1; init_io(cmd); setupvals(...)`.
2751        // zsh_main runs those three in that order, and this constructor stands
2752        // in for setupvals's param setup on the drivers that never reach
2753        // zsh_main: `zshrs -c CODE` dispatches at bins/zshrs.rs:1716 (after
2754        // --zsh/-f are stripped) straight into ShellExecutor::new + exit. So
2755        // init_io has to happen here, or SHTTY is still -1 and the winsize
2756        // probe below silently no-ops (adjustwinsize early-returns at
2757        // c:1900-1901). setupvals's own adjustwinsize(0) covers the zsh_main
2758        // path; init_io is idempotent (c:615-618 closes and reopens SHTTY), so
2759        // that path just re-establishes it a moment later.
2760        crate::ported::init::init_io(None); // c:1908
2761
2762        // c:Src/init.c:1274-1276 — `adjustwinsize(0)`, the first thing after
2763        // createparamtable (c:1270). Probes the tty via TIOCGWINSZ and
2764        // publishes the geometry to $COLUMNS/$LINES.
2765        //
2766        // Ordering is C's, and load-bearing in both directions: it must FOLLOW
2767        // init_io (which sets SHTTY) and it must FOLLOW the environ import
2768        // above, because the tty geometry OVERRIDES an inherited COLUMNS — a
2769        // 97-column terminal reports 97 even when COLUMNS=10 was exported in.
2770        // (C gets that via the c:1906-1907 "Signal missed while a job owned the
2771        // tty?" promotion of from=0 to from=1, which makes adjustcolumns take
2772        // the signalled path and overwrite zterm_columns with ws_col.)
2773        //
2774        // With no terminal at all, SHTTY stays -1 and the imported value (or 0)
2775        // survives untouched — which is what C's zterm_columns does there, and
2776        // why a piped `COLUMNS=20 zshrs -c` still reports 20. Note SHTTY does
2777        // NOT require stdin/stdout to be a tty: init_io's last resort is
2778        // `open("/dev/tty")` (c:667-670), so a piped-but-still-attached shell
2779        // gets the real width, matching `zsh -fc 'print $COLUMNS | cat'`.
2780        let _ = crate::ported::utils::adjustwinsize(0); // c:1276
2781
2782        // c:Src/params.c:955 — `set_pwd_env();` runs AFTER the environ
2783        // import loop, overwriting the imported $PWD/$OLDPWD paramtab
2784        // entries with the ispwd()-validated values computed above
2785        // (c:Src/init.c:1242-1259). Without this, a stale inherited
2786        // $PWD (env-import snapshot taken at process entry) survives
2787        // in paramtab even though the live env was corrected.
2788        crate::ported::builtin::set_pwd_env();
2789
2790        // c:Src/params.c:975-992 — host/arch identification params:
2791        // CPUTYPE / MACHTYPE / OSTYPE / VENDOR. C zsh reads from
2792        // compile-time `#define`s (set by ./configure) for MACHTYPE /
2793        // OSTYPE / VENDOR, and from uname().machine at runtime for
2794        // CPUTYPE.
2795        //
2796        // Rust port: probe uname() at startup for CPUTYPE, and use
2797        // const strings parameterized by build-target for the
2798        // others. Match homebrew zsh's values where possible.
2799        let mut uname_buf: libc::utsname = unsafe { std::mem::zeroed() };
2800        let _ = unsafe { libc::uname(&mut uname_buf) };
2801        let to_str = |b: &[libc::c_char]| -> String {
2802            // c-string → owned String, truncated at first NUL.
2803            let bytes: Vec<u8> = b
2804                .iter()
2805                .take_while(|&&c| c != 0)
2806                .map(|&c| c as u8)
2807                .collect();
2808            String::from_utf8_lossy(&bytes).into_owned()
2809        };
2810        let cputype = to_str(&uname_buf.machine);
2811        crate::ported::params::setsparam("CPUTYPE", &cputype); // c:961
2812                                                               // OSTYPE: configure's `$host_os`, resolved on the build host and
2813                                                               // frozen into config.h — C never re-derives it from uname() at
2814                                                               // startup. Deriving it here made the two writers disagree, so the
2815                                                               // same binary answered `darwin25.5.0` under -c and `darwin23.6.0`
2816                                                               // under -i. Single source of truth: config_h::OSTYPE, exactly as
2817                                                               // MACHTYPE below.
2818        crate::ported::params::setsparam("OSTYPE", crate::ported::config_h::OSTYPE); // c:990
2819                                                                                     // MACHTYPE: configure's `$host_cpu`, i.e. the config.guess
2820                                                                                     // canonical arch name — NOT uname's `machine`. The two differ
2821                                                                                     // on Apple Silicon (uname says `arm64`, config.guess says
2822                                                                                     // `aarch64`), and zsh reports the latter. Single source of
2823                                                                                     // truth: config_h::MACHTYPE (= build target arch).
2824        crate::ported::params::setsparam("MACHTYPE", crate::ported::config_h::MACHTYPE); // c:967
2825                                                                                         // VENDOR: configure's `$host_vendor`. Deriving it from uname's
2826                                                                                         // `sysname` here was a second, non-C writer that disagreed with
2827                                                                                         // `config_h::VENDOR` off Darwin: config.guess emits `pc` for x86_64
2828                                                                                         // Linux (config.guess:1222) and `unknown` for aarch64 Linux
2829                                                                                         // (config.guess:1009), a distinction `sysname` cannot make. Single
2830                                                                                         // source of truth, exactly as OSTYPE/MACHTYPE above.
2831        crate::ported::params::setsparam("VENDOR", crate::ported::config_h::VENDOR); // c:992
2832
2833        // c:Src/init.c:963 — `setsparam("TTY", ttyname(0) ?: "")`, which
2834        // C reaches at c:969 in the createparamtable tail. Even a
2835        // non-interactive -fc shell creates the param.
2836        let tty_str = unsafe {
2837            let p = libc::ttyname(0);
2838            if p.is_null() {
2839                String::new()
2840            } else {
2841                std::ffi::CStr::from_ptr(p)
2842                    .to_str()
2843                    .unwrap_or("")
2844                    .to_string()
2845            }
2846        };
2847        crate::ported::params::setsparam("TTY", &tty_str); // c:969
2848                                                           // c:Src/params.c:971 — `setsparam("ZSH_ARGZERO", ztrdup(posixzero))`:
2849                                                           // the kernel-supplied argv[0] of THIS binary, in --zsh parity mode
2850                                                           // too. The bin entrypoint overrides this with the script path for
2851                                                           // -c / runscript invocations. (A previous revision probed the
2852                                                           // system zsh install path and reported THAT as ZSH_ARGZERO for
2853                                                           // byte-parity — faking the shell's identity. Parity tests that
2854                                                           // compare the value must normalize the machine-specific binary
2855                                                           // path in the test row instead.)
2856        let argzero_default = env::args().next().unwrap_or_else(|| "zsh".to_string());
2857        crate::ported::params::setsparam("ZSH_ARGZERO", &argzero_default); // c:971
2858                                                                           // c:Src/params.c:972 — ZSH_VERSION. `zsh_version::ZSH_VERSION`
2859                                                                           // (emitted by build.rs from the vendored `Config/version.mk`) is
2860                                                                           // the development snapshot tag `5.9.0.3-test`; shipped zsh
2861                                                                           // binaries report the clean release form (`5.9`). Bug #73 in
2862                                                                           // docs/BUGS.md — cross-shell scripts that gate on
2863                                                                           // `[[ $ZSH_VERSION = 5.9 ]]` or split on `.` expecting
2864                                                                           // MAJOR.MINOR break on the `-test` suffix. Use the cleaned
2865                                                                           // `patchlevel::ZSH_VERSION` here ("5.9") and surface the full
2866                                                                           // snapshot tag as `$ZSHRS_VERSION` for zshrs identity checks.
2867        crate::ported::params::setsparam("ZSH_VERSION", crate::ported::patchlevel::ZSH_VERSION); // c:972
2868                                                                                                 // c:Src/params.c:973 + Src/patchlevel.h — `ZSH_PATCHLEVEL` is a
2869                                                                                                 // git-describe-style identifier (`zsh-MAJOR.MINOR-N-gHASH`) of
2870                                                                                                 // the upstream commit zshrs targets. `build.rs` emits "unknown"
2871                                                                                                 // because the vendored zsh tarball ships no CUSTOM_PATCHLEVEL
2872                                                                                                 // define; use the canonical const in `patchlevel.rs` instead.
2873                                                                                                 // Bug #90 in docs/BUGS.md — scripts that fingerprint by
2874                                                                                                 // $ZSH_PATCHLEVEL fell to the wildcard arm under "unknown".
2875        crate::ported::params::setsparam(
2876            "ZSH_PATCHLEVEL",
2877            crate::ported::patchlevel::ZSH_PATCHLEVEL,
2878        ); // c:973
2879           // Skip ZSHRS_VERSION whenever the zsh-compatible namespace must
2880           // stay free of zshrs-original names, so `${(k)parameters}`
2881           // doesn't carry a name zsh doesn't ship — same predicate and
2882           // reasoning as the guard in `ported::params::createparamtable`.
2883           // `hide_ext_builtins()` is `--zsh` OR `ZSHRS_HIDE_EXT_BUILTINS`
2884           // (the parity harnesses' knob). Scripts can still detect zshrs
2885           // via `$ZSH_VERSION`, which carries a `-test` suffix.
2886        if !crate::ext_builtins::hide_ext_builtins() {
2887            crate::ported::params::setsparam(
2888                "ZSHRS_VERSION",
2889                crate::ported::patchlevel::ZSHRS_VERSION,
2890            );
2891        }
2892        // c:Src/params.c:974-979 — `setaparam("signals", …)`.
2893        {
2894            use crate::ported::signals_h::SIGS;
2895            // c:signames.c sigs[] (generated) — index 0 is "EXIT",
2896            // entries 1..=SIGCOUNT are in PLATFORM SIGNAL-NUMBER
2897            // order, tail is "ZERR", "DEBUG" (zsh.h SIGZERR/SIGDEBUG).
2898            // SIGS is declared in Linux textual order, so sort by the
2899            // libc number to reproduce the generated table's order on
2900            // every platform. Same construction as params.rs — keep
2901            // in sync.
2902            let mut by_num: Vec<(&str, i32)> = SIGS.to_vec();
2903            by_num.sort_by_key(|&(_, n)| n);
2904            let mut signals_arr: Vec<String> = Vec::with_capacity(by_num.len() + 3);
2905            signals_arr.push("EXIT".to_string()); // c:sigs[0]
2906            signals_arr.extend(by_num.iter().map(|(n, _)| n.to_string()));
2907            signals_arr.push("ZERR".to_string()); // c:sigs tail
2908            signals_arr.push("DEBUG".to_string()); // c:sigs tail
2909            crate::ported::params::setaparam("signals", signals_arr); // c:974
2910        }
2911        // c:Src/init.c:1364 — `setsparam("ZSH_NAME", ztrdup(zsh_name))`,
2912        // which setupvals runs AFTER createparamtable, so the node lands
2913        // ahead of the imported environment in its bucket chain.
2914        crate::ported::params::setsparam("ZSH_NAME", "zsh"); // c:Src/init.c:1364
2915                                                             // LOGNAME is seeded pre-import now (c:878) — see the block by the
2916                                                             // TMPPREFIX/HOST seeds above.
2917                                                             //
2918                                                             // DO NOT setsparam("USERNAME", ...) anywhere in init. `$USERNAME`
2919                                                             // is a special parameter whose SETTER (`usernamesetfn` in
2920                                                             // params.rs) performs setgid(2) + setuid(2) to actually change
2921                                                             // the effective user — a deliberate upstream zsh feature for
2922                                                             // `USERNAME=other-user cmd`. Calling it at init seeds the value
2923                                                             // AND tries to change uid/gid; when the resolved pwd's pw_uid
2924                                                             // differs from `getuid()` (sudo launches, macOS Keychain-helper
2925                                                             // inherited env, container entry points, etc.) the setgid call
2926                                                             // fails with EPERM and emits `zsh:1: failed to change group ID:
2927                                                             // Operation not permitted`. Upstream seeds `$USERNAME` via the
2928                                                             // GETTER path (`usernamegetfn` reads through `cached_username`
2929                                                             // populated by `inittyptab` → `get_username`), no setter call.
2930
2931        // c:Src/init.c:1176 — `module_path = mkarray(MODULE_DIR)`.
2932        // The canonical init lives in `init::setupvals` (port of
2933        // `Src/init.c:setupvals`); the bin entry skips setupvals (per
2934        // the init_bltinmods comment above), so call the lightweight
2935        // module_path bootstrap exposed by init.rs from here. This
2936        // mirrors the HOST gethostname seeding pattern above:
2937        // duplicated init that should collapse into a full setupvals
2938
2939        // c:Src/init.c:1945 init_bltinmods — runs right after setupvals
2940        // (c:1942), i.e. after createparamtable's import, so the module
2941        // autoload stubs (`WATCH`, `watch`, …) are created HERE. The bin
2942        // entry skips zsh_main → init_bltinmods, so run it from
2943        // ShellExecutor::new for the same effect. Bug #270.
2944        crate::ported::init::init_bltinmods(); // c:Src/init.c:1945
2945
2946        // Populate paramtab with PM_SPECIAL Params for every PARTAB /
2947        // PARTAB_ARRAY magic-assoc name. Mirrors what C's zsh/parameter
2948        // module boot_ → handlefeatures chain does — which happens when
2949        // the module LOADS, after init_bltinmods planted its autoload
2950        // stubs, and `addparamdef` unsets the stub before creating the
2951        // real param (c:Src/module.c addparamdef → unsetparam_pm +
2952        // createparam), so these names take a FRESH chain slot ahead of
2953        // the stubs. Running this before init_bltinmods put `usergroups`
2954        // and friends behind `WATCH` in `${(k)parameters}`.
2955        init_partab_params(); // c:Src/Modules/parameter.c:2341 boot_/enables_ chain
2956
2957        // HOST is seeded pre-import now (c:875) — see the block next to
2958        // the TMPPREFIX/LOGNAME seeds above.
2959        // bash startup delta: bash defines TERM itself when the
2960        // environment does not carry one, and exports it. zsh leaves
2961        // TERM unset in that case, so `zshrs --bash` inherited zsh's
2962        // behavior and diverged from the reference shell:
2963        //
2964        //   $ env -u TERM /bin/bash -c 'printf "%s\n" "${TERM+set}"'
2965        //   set
2966        //   $ env -u TERM /bin/bash -c 'echo "$TERM"'
2967        //   dumb
2968        //   $ env -u TERM /bin/zsh -f -c 'printf "%s\n" "${TERM+set}"'
2969        //                                  (empty — zsh leaves it unset)
2970        //
2971        // Same on bash 3.2.57 (macOS /bin/bash) and 5.3.15, so it is
2972        // not a version artifact. Only the bare `--bash` drop-in takes
2973        // it: `--bash --zsh` asks for zsh-STYLE emulation, where zsh's
2974        // leave-it-unset behavior is the correct answer. Guarded on the
2975        // environment so an inherited TERM always wins.
2976        if crate::extensions::dash_mode::bash_mode() && std::env::var_os("TERM").is_none() {
2977            crate::ported::params::setsparam("TERM", "dumb");
2978            // bash exports it (`declare -x TERM` shows up in `export -p`);
2979            // addenv stamps PM_EXPORTED and pushes it into the child env.
2980            crate::ported::params::addenv("TERM", "dumb");
2981        }
2982
2983        // c:Src/init.c:479 — `-c` mode: scriptname = scriptfilename
2984        // = ztrdup("zsh"). Both globals start as the literal "zsh"
2985        // (not the binary path) so PS4's %x / %N print "zsh" not
2986        // "/path/to/zshrs" at the top level. Function dispatch
2987        // overrides scriptname per c:5903; scriptfilename stays.
2988        crate::ported::utils::set_scriptname(Some("zsh".to_string()));
2989        // c:Src/init.c:470-479 — `scriptname = scriptfilename =
2990        // ztrdup("zsh")` sits INSIDE the `-c` branch of the option parse.
2991        // An interactive shell (or one running a script file) leaves
2992        // `scriptfilename` NULL, and exec.c:5383 copies it onto every
2993        // Shfunc it defines — which is why zsh reports an EMPTY
2994        // `$functions_source[f]` for a function typed at the prompt.
2995        // Stamping "zsh" unconditionally made zshrs answer "zsh" there.
2996        let dash_c = std::env::args()
2997            .skip(1)
2998            .any(|a| a.starts_with('-') && !a.starts_with("--") && a.contains('c'));
2999        if dash_c {
3000            crate::ported::utils::set_scriptfilename(Some("zsh".to_string())); // c:479
3001        }
3002
3003        // call once that port is complete.
3004        crate::ported::init::module_path_init();
3005
3006        exec
3007    }
3008
3009    /// Execute a script file with bytecode caching — skips lex+parse+compile on cache hit.
3010    /// Bytecode is stored in rkyv keyed by (path, mtime).
3011    pub fn execute_script_file(&mut self, file_path: &str) -> Result<i32, String> {
3012        let path = Path::new(file_path);
3013        let abs_path = path
3014            .canonicalize()
3015            .unwrap_or_else(|_| path.to_path_buf())
3016            .to_string_lossy()
3017            .to_string();
3018
3019        // Try bytecode cache first — rkyv shard at ~/.zshrs/scripts.rkyv.
3020        // The cache validates path + mtime + zshrs binary mtime; on any
3021        // miss we fall through to lex/parse/compile. Cached path uses
3022        // `run_chunk` (the shared VM-execution helper); script-eval
3023        // path delegates to `execute_script_zsh_pipeline` so the
3024        // full parse/compile/cache-save/run flow stays in one place.
3025        if let Some(bc_blob) = crate::script_cache::try_load_bytes(path) {
3026            if let Ok(chunk) = bincode::deserialize::<fusevm::Chunk>(&bc_blob) {
3027                if !chunk.ops.is_empty() {
3028                    tracing::trace!(
3029                        path = %abs_path,
3030                        ops = chunk.ops.len(),
3031                        "execute_script_file: bytecode cache hit"
3032                    );
3033                    return self.run_chunk(chunk, &format!("execute_script_file:cache:{abs_path}"));
3034                }
3035            }
3036        }
3037
3038        // Cache miss — read, parse, compile via execute_script_zsh_pipeline,
3039        // then snapshot the resulting chunk into the cache for next
3040        // time. Direct port of Src/init.c source() which calls
3041        // `lex_init_buf` / `loop()` without engaging the history layer.
3042        // (zsh fires `!` history sub only on interactive input, so
3043        // sourced files run verbatim.)
3044        let content = fs::read_to_string(file_path).map_err(|e| format!("{}: {}", file_path, e))?;
3045        let status = self.execute_script_zsh_pipeline(&content)?;
3046
3047        // Best-effort cache save — failures don't block execution.
3048        // Re-parse/-compile here instead of trying to thread the chunk
3049        // back out of execute_script_zsh_pipeline; the cost is one extra
3050        // compile per CACHE MISS, paid back on every subsequent run.
3051        let saved_errflag = errflag.load(Ordering::Relaxed);
3052        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
3053        // Context-isolated parse (c:Src/exec.c:283 parse_string) — this
3054        // post-exec re-parse for the bytecode cache also runs mid-stream
3055        // under the single-event reader; isolate it from the outer SHIN.
3056        let program = parse_isolated(&content);
3057        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
3058        errflag.store(saved_errflag, Ordering::Relaxed);
3059        if !parse_failed {
3060            let compiler = crate::compile_zsh::ZshCompiler::new();
3061            let chunk = compiler.compile(&program);
3062            if let Ok(blob) = bincode::serialize(&chunk) {
3063                let _ = crate::script_cache::try_save_bytes(path, &blob);
3064                tracing::trace!(
3065                    path = %abs_path,
3066                    bytes = blob.len(),
3067                    "execute_script_file: bytecode cached"
3068                );
3069            }
3070        }
3071
3072        Ok(status)
3073    }
3074
3075    /// Run a compiled `fusevm::Chunk` to completion inside this
3076    /// executor's context. Shared by `execute_script_zsh_pipeline`,
3077    /// `execute_script_file`'s bytecode-cache hit path, and the
3078    /// function-dispatch body_runner. Centralises the VM setup so
3079    /// `register_builtins` and `ExecutorContext::enter` invariants
3080    /// stay in lockstep.
3081    fn run_chunk(&mut self, chunk: fusevm::Chunk, label: &str) -> Result<i32, String> {
3082        if chunk.ops.is_empty() {
3083            return Ok(self.last_status());
3084        }
3085        crate::fusevm_disasm::maybe_print_stdout(label, &chunk);
3086        let mut vm = crate::vm_pool::acquire(chunk);
3087        // Seed vm.last_status with the executor's current LASTVAL so
3088        // sub-VMs (EXIT trap bodies, eval, source) see the inherited
3089        // `$?` from the caller's last command — matching C zsh where
3090        // lastval is a process global. Without this, the new VM
3091        // started at 0 and BUILTIN_GET_VAR's sync_status would write
3092        // 0 back into LASTVAL on the first `$?` read.
3093        vm.last_status = self.last_status();
3094        let _ctx = ExecutorContext::enter(self);
3095        // c:Src/loop.c — `loops` is bracketed by the C interpreter's own
3096        // recursion, so a `return` or an errflag abort out of a loop
3097        // unwinds it for free. A compiled chunk instead jumps straight to
3098        // its end, skipping the loop's `loops--`. Restoring the count the
3099        // chunk started with makes that structurally impossible to leak:
3100        // whatever loops this chunk opened are closed when it finishes.
3101        let loops_entry = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
3102        let result = vm.run();
3103        crate::ported::builtin::LOOPS.store(loops_entry, Ordering::Relaxed);
3104        match result {
3105            fusevm::VMResult::Ok(_) | fusevm::VMResult::Halted => {
3106                self.set_last_status(vm.last_status);
3107            }
3108            fusevm::VMResult::Error(e) => return Err(format!("VM error: {}", e)),
3109        }
3110        Ok(self.last_status())
3111    }
3112
3113    /// Execute via the lex+parse free ported + ZshCompiler pipeline.
3114    /// This is the only execution path; `execute_script` delegates here.
3115    /// Parse + compile `script` in an isolated lexer context, without
3116    /// running it.
3117    ///
3118    /// Split out of [`ShellExecutor::execute_script_zsh_pipeline`] so the
3119    /// autoload loader can get its hands on the compiled chunk: that chunk
3120    /// is what lands in `~/.zshrs/autoloads.rkyv`, so the next process can
3121    /// install the same function without re-parsing the definition file.
3122    fn compile_script_isolated(&mut self, script: &str) -> Result<fusevm::Chunk, String> {
3123        // Skip history expansion for non-interactive script execution
3124        // (`zsh -c '…'`, internal eval, sourced files). zsh's `!`
3125        // history sub only fires on the REPL command line, never on
3126        // a pre-parsed script body. The interactive REPL has its
3127        // own dedicated path that calls expand_history before
3128        // dispatching here.
3129        // Save & clear errflag around the parse so a fresh syntax
3130        // error is distinguishable from one already in flight. Mirrors
3131        // Src/init.c loop()'s pre-parse `errflag &= ~ERRFLAG_ERROR;`.
3132        let saved_errflag = errflag.load(Ordering::Relaxed);
3133        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
3134        // Context-isolated parse (c:Src/exec.c:283 parse_string). eval /
3135        // source / autoload-register / trap bodies all reach here and run
3136        // DURING execution; on the faithful single-event loop()/parse_event
3137        // reader, a bare parse_init/lex_init would steal the outer's next
3138        // SHIN line into this nested program (e.g. `eval "x=5"` swallowed the
3139        // following `echo $x` off stdin). parse_isolated sets `strin` so the
3140        // string drains to EOF; execution below stays in the current shell.
3141        let program = parse_isolated(script);
3142        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
3143        errflag.store(saved_errflag, Ordering::Relaxed);
3144        if parse_failed {
3145            // c:Src/init.c — when the parser fires `zerr(...)`, the C
3146            // shell's `loop()` body skips the eval pass and continues;
3147            // there's no second "parse error" diagnostic. The Rust
3148            // binary's call sites print `zshrs: <e>` on Err, doubling
3149            // up on the message the parser already emitted via zerr.
3150            // Use a `__SILENCED__` sentinel that the binary's
3151            // execute_script wrapper recognizes as "already reported,
3152            // exit silently". Bug #142 in docs/BUGS.md (double-print
3153            // half).
3154            return Err("__SILENCED__".to_string());
3155        }
3156
3157        let compiler = crate::compile_zsh::ZshCompiler::new();
3158        Ok(compiler.compile(&program))
3159    }
3160
3161    /// Run an already-compiled top-level chunk, then fire the end-of-script
3162    /// hooks (`EXIT` trap, `TRAPEXIT`, `zshexit` + `zshexit_functions`) the
3163    /// script pipeline owes them.
3164    fn run_chunk_with_exit_hooks(
3165        &mut self,
3166        chunk: fusevm::Chunk,
3167        label: &str,
3168    ) -> Result<i32, String> {
3169        let status = self.run_chunk(chunk, label)?;
3170
3171        // Fire EXIT trap if set. Two storage paths:
3172        //   (a) `trap 'cmd' EXIT` writes the body text into
3173        //       `traps_table` via bin_trap (Src/builtin.c) — fire
3174        //       directly via execute_script.
3175        //   (b) `TRAPEXIT() { ... }` function-named form goes
3176        //       through settrap(SIGEXIT, None, ZSIG_FUNC) at
3177        //       funcdef time (fusevm_bridge.rs BUILTIN_REGISTER_COMPILED_FN
3178        //       arm) and lives in shfunctab + sigtrapped — fire
3179        //       via dotrap(SIGEXIT) which dispatches the named
3180        //       shfunc. Bug #157 in docs/BUGS.md.
3181        // Remove the trap from `traps_table` first to prevent
3182        // infinite recursion of `(a)`; `(b)`'s sigtrapped flag
3183        // is cleared by dotrap's own intrap guard.
3184        let exit_body = crate::ported::builtin::traps_table()
3185            .lock()
3186            .ok()
3187            .and_then(|mut t| t.remove("EXIT"));
3188        if let Some(action) = exit_body {
3189            tracing::debug!("firing EXIT trap (new pipeline)");
3190            // c:Src/signals.c — the EXIT trap body sees $? at the
3191            // value the script left off (so `trap 'echo $?' EXIT;
3192            // (exit 7)` prints 7), but the SHELL's final exit code
3193            // is still the pre-trap value (running `echo` inside
3194            // the trap doesn't reset the script's exit status).
3195            // Preserve `status` and re-apply it after the trap
3196            // body returns.
3197            //
3198            // c:Src/signals.c:1123/1236 — `intrap++` … `intrap--` bracket a
3199            // trap body, and while intrap the EXIT, DEBUG and ZERR traps are
3200            // suppressed (c:1112-1119, the guard in dotrap). This path runs
3201            // the EXIT body through the script pipeline rather than dotrap,
3202            // so nothing raised intrap and the body's own failure re-entered
3203            // the ERR trap: `trap 'print err' ERR; trap 'true; false' EXIT`
3204            // printed err where zsh prints nothing.
3205            //
3206            // A counter, not a flag, and paired with dotrap's SELECTIVE
3207            // guard: signals zsh does deliver from inside a trap body (e.g.
3208            // `trap 'kill -USR1 $$' EXIT`) must still dispatch.
3209            crate::ported::signals::intrap.fetch_add(1, Ordering::SeqCst); // c:1123
3210            let _ = self.execute_script_zsh_pipeline(&action);
3211            crate::ported::signals::intrap.fetch_sub(1, Ordering::SeqCst); // c:1236
3212            self.set_last_status(status);
3213        }
3214        // c:Src/signals.c::dotrap(SIGEXIT) — fire TRAPEXIT() shfunc
3215        // if installed via the function-name path. The TRAPEXIT()
3216        // form goes through settrap(SIGEXIT, None, ZSIG_FUNC) at
3217        // funcdef time (sets sigtrapped[SIGEXIT] |= ZSIG_FUNC).
3218        // Dispatching from here AFTER run_chunk returns means we're
3219        // outside the VM context — dotrap can't safely re-enter
3220        // via dispatch_function_call (which uses with_executor).
3221        // Route through execute_script_zsh_pipeline which sets up
3222        // a fresh VM context — invoke the function by name.
3223        let trapped = crate::ported::signals::sigtrapped
3224            .lock()
3225            .ok()
3226            .and_then(|g| g.get(crate::signals_h::SIGEXIT as usize).copied())
3227            .unwrap_or(0);
3228        // c:Src/signals.c:1112-1119 — `if (intrap) { switch (sig) { case
3229        // SIGEXIT: … return; } }`, and c:Src/signals.c:892 `if (!intrap &&
3230        // …)` in endtrapscope. An EXIT trap never fires from inside another
3231        // trap body. This site is a Rust-only end-of-pipeline hook (every
3232        // `eval` / `source` / trap body runs its own pipeline and reaches
3233        // here), and it dispatches TRAPEXIT by NAME without going through
3234        // dotrap — so nothing consulted `intrap` and nothing cleared
3235        // sigtrapped. Once `endtrapscope` started restoring a saved
3236        // ZSIG_FUNC EXIT trap (the c:929-931 arm), the TRAPEXIT body's own
3237        // nested pipeline re-entered this hook with the flag still set and
3238        // recursed without bound:
3239        //   f() { eval 'TRAPEXIT() { echo T; }' }; f
3240        // The `intrap++ … intrap--` bracket is the same one the string-form
3241        // branch above already carries (c:1123 / c:1236).
3242        // c:Src/signals.c:744-752 — `sigtrapped[sig] |= (locallevel <<
3243        // ZSIG_SHIFT)`: a trap installed inside a function carries its
3244        // scope's locallevel, and `endtrapscope` (c:892-903/945-956) is what
3245        // fires THAT one, at the scope exit. Only an untagged (locallevel 0)
3246        // EXIT trap belongs to the shell-exit path this hook stands in for.
3247        // Without the test, `f() { TRAPEXIT() { echo T } }; f` fired twice —
3248        // once from f's endtrapscope and once more from the pipeline hook.
3249        let exit_trap_locallevel = trapped >> crate::ported::zsh_h::ZSIG_SHIFT;
3250        if (trapped & crate::ported::zsh_h::ZSIG_FUNC as i32) != 0
3251            && exit_trap_locallevel == 0
3252            && crate::ported::signals::intrap.load(Ordering::SeqCst) == 0
3253        {
3254            // The TRAP<SIG> function is stored in shfunctab as
3255            // "TRAPEXIT"; calling it by name re-enters
3256            // execute_script_zsh_pipeline with a fresh VM context.
3257            crate::ported::signals::intrap.fetch_add(1, Ordering::SeqCst); // c:1123
3258            let _ = self.execute_script_zsh_pipeline("TRAPEXIT");
3259            crate::ported::signals::intrap.fetch_sub(1, Ordering::SeqCst); // c:1236
3260        }
3261        // c:Src/init.c::zexit — `callhookfunc("zshexit", NULL, 1, NULL)`.
3262        // Fire the `zshexit` shfunc + walk `zshexit_functions` array.
3263        // Routed through execute_script_zsh_pipeline calls because
3264        // we're outside the VM context here (post-run_chunk). Iterate
3265        // the array directly + call zshexit by name. Bug #215 in
3266        // docs/BUGS.md.
3267        //
3268        // Re-entry guard: each call to execute_script_zsh_pipeline
3269        // (whether top-level script or the named-fn dispatch below)
3270        // hits this code at its tail. Without a guard, the zshexit
3271        // hook recurses infinitely (calls itself at end via this
3272        // path). Use a thread-local depth counter and skip the
3273        // dispatch when depth > 0.
3274        thread_local! {
3275            static ZSHEXIT_HOOK_DEPTH: std::cell::Cell<u32> = const {
3276                std::cell::Cell::new(0)
3277            };
3278        }
3279        let hook_depth = ZSHEXIT_HOOK_DEPTH.with(|c| c.get());
3280        if hook_depth == 0 {
3281            ZSHEXIT_HOOK_DEPTH.with(|c| c.set(hook_depth + 1));
3282            if crate::ported::hashtable::shfunctab_lock()
3283                .read()
3284                .ok()
3285                .map(|t| t.contains_key("zshexit"))
3286                .unwrap_or(false)
3287            {
3288                let _ = self.execute_script_zsh_pipeline("zshexit");
3289            }
3290            let exit_arr = crate::ported::params::paramtab()
3291                .read()
3292                .ok()
3293                .and_then(|t| t.get("zshexit_functions").and_then(|p| p.u_arr.clone()))
3294                .unwrap_or_default();
3295            for fn_name in exit_arr {
3296                let exists = crate::ported::hashtable::shfunctab_lock()
3297                    .read()
3298                    .ok()
3299                    .map(|t| t.contains_key(&fn_name))
3300                    .unwrap_or(false);
3301                if exists {
3302                    let _ = self.execute_script_zsh_pipeline(&fn_name);
3303                }
3304            }
3305            ZSHEXIT_HOOK_DEPTH.with(|c| c.set(hook_depth));
3306        }
3307        // Preserve script status; trap body shouldn't override it.
3308        self.set_last_status(status);
3309
3310        let _ = status;
3311        Ok(self.last_status())
3312    }
3313    /// zshrs's script entry: lex + parse + compile + run, then the
3314    /// end-of-script hooks. `eval`, `source`, trap bodies and autoload
3315    /// registration all funnel through here.
3316    pub fn execute_script_zsh_pipeline(&mut self, script: &str) -> Result<i32, String> {
3317        let chunk = self.compile_script_isolated(script)?;
3318        self.run_chunk_with_exit_hooks(chunk, "execute_script_zsh_pipeline")
3319    }
3320
3321    /// Run the TEXT that `getpermtext` reconstructed from an already-compiled
3322    /// `.zwc` program.
3323    ///
3324    /// c:Src/init.c:1618-1622 — the compiled arm of `source()` is
3325    /// `execode(prog, 1, 0, "filecode")`. The wordcode runs as it stands and
3326    /// NOTHING is lexed; a `.zwc` is quote-resolved once, at `zcompile` time.
3327    ///
3328    /// !!! WARNING: RUST-ONLY HELPER !!!
3329    /// zshrs has no execute-the-wordcode path — it deparses the program back
3330    /// to source (`getpermtext`) and lexes it again — so the round trip is
3331    /// lossless only while the lexer reads quotes the way the deparse writes
3332    /// them. `untokenize` (c:Src/exec.c:2134) renders EVERY quote null through
3333    /// `ztokens[Snull - Pound]`, and that entry is a bare single quote
3334    /// (c:Src/lex.c:38), so a closing null followed by an opening one comes
3335    /// back out as two adjacent quotes. Under RCQUOTES the lexer reads that
3336    /// pair inside a quoted word as one LITERAL quote (c:Src/lex.c:1328)
3337    /// instead of as two delimiters, so the openshift-aliases plugin's
3338    /// `alias opodr='oc …=''{…}'''` — which `zcompile` resolved with no
3339    /// literal quotes at all — re-lexed with two of them. The deparse
3340    /// spelling is by construction the DEFAULT-option spelling, so the option
3341    /// is cleared for the compile to restore C's "not lexed at all" property.
3342    ///
3343    /// It is cleared for the COMPILE ONLY. A `.zwc` that does `setopt
3344    /// rcquotes` (zsh-expand's plugin entry does, at its line 39) must still
3345    /// set the option for real, and that setting must outlive the source — so
3346    /// the previous value is restored before the chunk RUNS, not after. The
3347    /// same split applies to alias expansion: a function or `eval` body the
3348    /// program runs is lexed at RUNTIME and must see the live alias table.
3349    pub fn execute_zwc_program(&mut self, script: &str) -> Result<i32, String> {
3350        let chunk = {
3351            let _relex = ZwcRelexGuard::enter();
3352            self.compile_script_isolated(script)
3353        };
3354        self.run_chunk_with_exit_hooks(chunk?, "execute_zwc_program")
3355    }
3356
3357    /// Run `script` the way C runs a PLAIN sourced file: parse ONE event,
3358    /// execute it, parse the next — so lexer-time state that one line
3359    /// establishes is in force when the next line is lexed.
3360    ///
3361    /// c:Src/init.c:1618-1641 — `source()` has two arms. A file that was
3362    /// already compiled (`try_source_file` found a `.zwc`) runs whole, as one
3363    /// program: `execode(prog, 1, 0, "filecode")` (c:1621). A plain file runs
3364    /// through the per-command loop: `/* loop through the file to be sourced
3365    /// */ switch (loop(0, 0))` (c:1626-1627), whose body is `lexinit();
3366    /// parse_event(ENDINPUT); … execode(prog, 0, 0, "file")` (c:155-220).
3367    /// This is the second arm.
3368    ///
3369    /// The difference is observable whenever a line changes something the
3370    /// LEXER consults, because a whole-file compile lexes every line with the
3371    /// state the file STARTED with:
3372    ///
3373    /// ```text
3374    /// alias greet='print -r -- hello'   # takes effect at execution time
3375    /// greet                             # …but this line is lexed after it
3376    /// ```
3377    ///
3378    /// Same for `setopt rcquotes` (c:Src/lex.c:1326), `unsetopt aliases`, and
3379    /// a syntax error late in the file (C has already run the good lines).
3380    ///
3381    /// **Re-entrancy.** Every nested context — `$(source f)`, `` `source f` ``,
3382    /// `eval "source f"`, a pipe stage, a `( … )` subshell, a `source` inside
3383    /// a sourced file — reaches here through the normal builtin path, so this
3384    /// must be safe to enter while an outer instance of itself is parked
3385    /// mid-file. Two properties make it so, and both are deliberate:
3386    ///
3387    ///   * It never touches the shell's INPUT STACK. C's `source` points
3388    ///     `SHIN` at the file (c:1584) and lets `loop`'s `ingetc` pull from
3389    ///     it; doing that here would fight the outer reader for the one
3390    ///     global. Instead the file body is installed as the lexer's own
3391    ///     `LEX_INPUT` window under `strinbeg` — the exact parking
3392    ///     [`parse_isolated`] uses for a command-substitution body — and the
3393    ///     outer window is saved on the Rust stack and restored on the way
3394    ///     out. Nesting is then just stack discipline.
3395    ///   * It never dispatches through `execode`. `execode`
3396    ///     (`src/ported/exec.rs`) runs its program on the installed SESSION
3397    ///     executor, which is the right one only for the top-level REPL; from
3398    ///     inside a command substitution the live executor is the sub-VM that
3399    ///     owns the capture. Each event is compiled and run here on `self` —
3400    ///     the same executor `execute_script` would have used — via
3401    ///     [`Self::run_chunk`]. `$(source f)` therefore captures exactly what
3402    ///     `$(…)` captures from any other builtin.
3403    ///
3404    /// Returns the file's `$?`. `Err` only for a VM error, as
3405    /// [`Self::run_chunk`] reports it.
3406    pub fn execute_script_per_command(&mut self, script: &str) -> Result<i32, String> {
3407        use crate::ported::lex::{
3408            tok, ENDINPUT, LEXERR, LEX_FILE_WINDOW_STRIN, LEX_INPUT, LEX_LINENO, LEX_POS,
3409            LEX_UNGET_BUF,
3410        };
3411
3412        // Inline Rust FFI blocks are rewritten before the lexer sees them,
3413        // as on every other source-string entry (see `parse_isolated`).
3414        let ffi_desugared = script
3415            .contains("rust")
3416            .then(|| crate::rust_ffi::desugar(script));
3417        let script: &str = ffi_desugared.as_deref().unwrap_or(script);
3418
3419        // c:Src/init.c:121 — `if (!toplevel) zcontext_save();`, plus the
3420        // zshrs-only lexer window that `zcontext` doesn't cover (identical
3421        // list to `parse_isolated`, which parks it for the same reason).
3422        crate::ported::context::zcontext_save(); // c:121
3423        let saved_input = LEX_INPUT.with_borrow(|s| s.clone());
3424        let saved_pos = LEX_POS.get();
3425        let saved_unget = LEX_UNGET_BUF.with_borrow(|b| b.clone());
3426        let saved_lineno = LEX_LINENO.get();
3427        let saved_in_lexstop = crate::ported::input::lexstop.with(|c| c.get());
3428        let saved_file_window = LEX_FILE_WINDOW_STRIN.get();
3429
3430        // `strin` makes a drained window report EOF instead of falling
3431        // through to `inputline()` and STEALING the outer reader's next line
3432        // (input.rs:391). Without it a sourced file's last event swallows the
3433        // caller's next stdin line.
3434        crate::ported::hist::strinbeg(0);
3435        // c:1588 — `lineno = 1;`. `lex_init` (called by `parse_init`)
3436        // installs the body as the window and resets the line counter.
3437        crate::ported::parse::parse_init(script);
3438        // C reads this file through `inputline` (c:Src/input.c:366) with
3439        // `strin == 0`: ONE LINE per buffer, which is what makes each line
3440        // its own event (c:Src/lex.c:310 / c:Src/parse.c:657), and every
3441        // newline counted (c:Src/input.c:330). zshrs installs the body as
3442        // one window under one `strinbeg`, so the file kind is declared
3443        // here, tagged with THIS strinbeg's depth — a nested string push
3444        // inside the file is deeper and stays a string.
3445        LEX_FILE_WINDOW_STRIN.set(crate::ported::input::strin.with(|s| s.get()));
3446
3447        // c:116 — `int err, non_empty = 0;`
3448        let mut non_empty = false;
3449        let mut vm_error: Option<String> = None;
3450
3451        loop {
3452            // c:155 — `lexinit();` Resets `tok` and BOTH `lexstop` copies;
3453            // it does NOT move the window, so the next event resumes where
3454            // the last one stopped.
3455            crate::ported::lex::lexinit(); // c:155
3456            let prog = crate::ported::parse::parse_event(ENDINPUT); // c:156
3457            let Some(prog) = prog else {
3458                // c:159-174 — no event this pass. Break on clean EOF or on a
3459                // parse error (`!toplevel` makes C's LEXERR arm
3460                // unconditional here); a bare separator just goes round
3461                // again, which is C's `continue` at c:174.
3462                let tok_v = tok(); // c:159
3463                let errflag_v = errflag.load(Ordering::Relaxed);
3464                if (tok_v == ENDINPUT && errflag_v == 0) || tok_v == LEXERR {
3465                    // c:159-162
3466                    if tok_v == LEXERR
3467                        && crate::ported::builtin::LASTVAL.load(Ordering::Relaxed) == 0
3468                    {
3469                        crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:173
3470                    }
3471                    break;
3472                }
3473                if tok_v == ENDINPUT || errflag_v != 0 {
3474                    // Drained (or aborted) with a flag set — nothing left to
3475                    // read, so looping again would spin.
3476                    break;
3477                }
3478                continue; // c:174
3479            };
3480            non_empty = true; // c:179
3481
3482            // c:220 — `execode(prog, 0, 0, "file")`. The eval-context entry
3483            // C's `execode` pushes for this arm is already on the stack:
3484            // `bin_dot` pushes it once around the whole file (builtin.rs), so
3485            // pushing it per event would report `file:file:file:…`.
3486            //
3487            // `LEX_LINENO` is saved across execution for the same reason
3488            // `loop()` does it (init.rs, c:Src/exec.c:1376/1640): each
3489            // statement's `SET_LINENO` overwrites the counter while it runs,
3490            // and the NEXT event must be lexed from the line the file is
3491            // really on.
3492            let chunk = crate::compile_zsh::ZshCompiler::new().compile(&prog);
3493            let saved_lex_lineno = LEX_LINENO.get();
3494            let run = self.run_chunk(chunk, "source");
3495            LEX_LINENO.set(saved_lex_lineno);
3496            if let Err(e) = run {
3497                vm_error = Some(e);
3498                break;
3499            }
3500
3501            // c:234 — `if (((!interact || sourcelevel) && errflag) || retflag)
3502            // break;`. A sourced file always runs with `sourcelevel` bumped
3503            // (bin_dot, c:1606), so the errflag half is unconditional here.
3504            if (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0
3505                || crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) != 0
3506            {
3507                break; // c:235
3508            }
3509            // C's `exit` inside a sourced file calls `zexit` → `realexit()`
3510            // and the process is gone, so C never reaches the next event.
3511            // zshrs defers the exit (EXIT_PENDING plus a jump to chunk end)
3512            // and lets the caller unwind, so the loop has to stop on its own.
3513            if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) != 0 {
3514                break;
3515            }
3516        }
3517
3518        // c:245 — `err = errflag;` is read BEFORE the context is restored,
3519        // because `zcontext_restore` → `parse_context_restore` ends with
3520        // `errflag &= ~ERRFLAG_ERROR` (c:Src/parse.c:354). C carries the
3521        // answer out as the `LOOP_ERROR` return value; zshrs's `bin_dot`
3522        // reads the flag itself (its c:1623-1624 + c:1663 block), so the bit
3523        // is put back after the restore instead.
3524        let err = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0; // c:245
3525
3526        // c:246-249 — leave the loop's context exactly as it was found.
3527        crate::ported::hist::strinend();
3528        LEX_INPUT.with_borrow_mut(|s| *s = saved_input);
3529        LEX_POS.set(saved_pos);
3530        LEX_UNGET_BUF.with_borrow_mut(|b| *b = saved_unget);
3531        LEX_LINENO.set(saved_lineno);
3532        crate::ported::input::lexstop.with(|c| c.set(saved_in_lexstop));
3533        LEX_FILE_WINDOW_STRIN.set(saved_file_window);
3534        crate::ported::context::zcontext_restore(); // c:247
3535        if err {
3536            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed); // see the c:245 note
3537        }
3538
3539        if let Some(e) = vm_error {
3540            return Err(e);
3541        }
3542        // c:1633-1636 — `case LOOP_EMPTY: /* Empty code resets status */
3543        // lastval = 0;`. `source /dev/null` (or a comments-only file) clears
3544        // `$?` rather than leaving the caller's.
3545        if !non_empty {
3546            self.set_last_status(0); // c:1635
3547        }
3548        Ok(self.last_status())
3549    }
3550
3551    /// Install an autoloaded function by running its definition program,
3552    /// reusing the rkyv-cached chunk when the cache can PROVE the chunk
3553    /// was compiled from this same definition text by this same binary.
3554    ///
3555    /// `registered` is what `autoload_register_source` produced: either
3556    /// `name() { <file body> }` or, for a file that already contains the
3557    /// definition, the body verbatim. Running it installs the function;
3558    /// the compiled chunk for it is exactly what the cache stores, so a hit
3559    /// skips lex+parse+compile of the whole file. For `_git` that is 424 KB
3560    /// of shell — the dominant cost of the first `git <tab>`.
3561    ///
3562    /// Two conditions gate caching, because outside them the chunk is not a
3563    /// function of the definition text alone:
3564    ///   * ksh-style autoload (`KSHAUTOLOAD` / `PM_KSHSTORED`) runs the file
3565    ///     at top level instead of wrapping it, so the same bytes produce a
3566    ///     different program depending on a runtime option;
3567    ///   * without `PM_UNALIASED` (`autoload` without `-U`) the body is
3568    ///     parsed WITH alias expansion, so the chunk depends on the alias
3569    ///     table too. Every compsys / plugin autoload uses `-Uz`.
3570    ///
3571    /// A hit that runs without defining `name` is treated as a corrupt
3572    /// entry, not as a failed load: the entry is dropped and the real
3573    /// source compiled. Installing a function is the one thing this
3574    /// function exists to do, so "it ran and the function is not there"
3575    /// is a fact the loader can check for itself rather than leaving the
3576    /// caller to report `function not defined by file` for what is
3577    /// actually a bad cache line.
3578    ///
3579    /// `from_wordcode` says the text is a `.zwc` deparse rather than file
3580    /// bytes. C never re-lexes such a body at all — `shf->funcdef =
3581    /// stripkshdef(prog, …)` (c:Src/exec.c:5753-5755) installs the dump's own
3582    /// wordcode and the ksh arm runs it with `execode(prog, 1, 0,
3583    /// "evalautofunc")` (c:5795) — so the compile here, which is zshrs's
3584    /// stand-in for that install, runs under [`ZwcRelexGuard`]. The guard
3585    /// covers the COMPILE only: a ksh-style body executes real user code, and
3586    /// an `eval` or function body IT reaches is lexed at runtime against the
3587    /// live alias table and the live RCQUOTES, exactly as in C.
3588    fn run_autoload_definition(
3589        &mut self,
3590        name: &str,
3591        registered: &str,
3592        ksh_style: bool,
3593        from_wordcode: bool,
3594    ) -> Result<i32, String> {
3595        let unaliased = crate::ported::utils::getshfunc(name)
3596            .map(|f| (f.node.flags as u32 & crate::ported::zsh_h::PM_UNALIASED) != 0)
3597            .unwrap_or(false);
3598        let key = if ksh_style || !unaliased {
3599            None
3600        } else {
3601            autoload_source_key(name, registered, from_wordcode)
3602        };
3603        if let Some((dir, sha)) = key.as_ref() {
3604            if let Some(blob) = crate::autoload_cache::try_load_for_source(name, dir, sha) {
3605                match bincode::deserialize::<fusevm::Chunk>(&blob) {
3606                    Ok(chunk) if !chunk.ops.is_empty() => {
3607                        tracing::debug!(
3608                            name,
3609                            ops = chunk.ops.len(),
3610                            "autoload: rkyv chunk hit, skipping parse+compile"
3611                        );
3612                        let status = self.run_chunk_with_exit_hooks(chunk, "autoload:cached");
3613                        if self.functions_compiled.contains_key(name) {
3614                            return status;
3615                        }
3616                        // The chunk ran and `name` is still undefined, so
3617                        // it is not this function's definition program
3618                        // whatever the key said. Drop it and fall through
3619                        // to a real compile — a wrong answer here costs
3620                        // every completion on the shell.
3621                        tracing::warn!(
3622                            name,
3623                            "autoload: cached chunk did not define the function; \
3624                             dropping the entry and recompiling"
3625                        );
3626                        crate::autoload_cache::try_remove(name);
3627                    }
3628                    _ => {}
3629                }
3630            }
3631        }
3632        let chunk = {
3633            // c:Src/exec.c:5753-5755 / c:5795 — the dump's wordcode is
3634            // installed/executed as it stands, so nothing about the live
3635            // option or alias state can reach it. zshrs lexes the deparse
3636            // instead; pin the lexer to the spelling the deparse was written
3637            // in for the duration of that compile, and only that compile.
3638            let _relex = from_wordcode.then(ZwcRelexGuard::enter);
3639            self.compile_script_isolated(registered)?
3640        };
3641        if let Some((dir, sha)) = key.as_ref() {
3642            match bincode::serialize(&chunk) {
3643                Ok(blob) => {
3644                    if let Err(e) = crate::autoload_cache::try_save_one(name, &blob, dir, *sha) {
3645                        tracing::warn!(name, error = %e, "autoload: rkyv chunk save failed");
3646                    }
3647                }
3648                Err(e) => tracing::warn!(name, error = %e, "autoload: chunk serialize failed"),
3649            }
3650        }
3651        self.run_chunk_with_exit_hooks(chunk, "autoload:compiled")
3652    }
3653
3654    /// `execute_script` — see implementation.
3655    #[tracing::instrument(skip(self, script), fields(len = script.len()))]
3656    pub fn execute_script(&mut self, script: &str) -> Result<i32, String> {
3657        // lex+parse free ported + ZshCompiler is the only execution path.
3658        self.execute_script_zsh_pipeline(script)
3659    }
3660
3661    /// Run `script` with stdout AND stderr captured, returning `(exit status,
3662    /// output)` — the entry point for an embedder that owns the terminal (a
3663    /// TUI), where a stray `echo` corrupts the display.
3664    ///
3665    /// A shell cannot capture its output into an in-process buffer the way a
3666    /// single-runtime language can: a forked child writes fd 1 directly and
3667    /// knows nothing about the parent's buffers. The capture is therefore at fd
3668    /// level, and it differs from `$(…)` in the one way that matters to an
3669    /// embedder: [`Self::run_command_substitution`] runs on a sub-VM, as a
3670    /// subshell must, so a variable it sets is gone afterwards. This runs the
3671    /// script on THIS VM, so state persists across captured runs exactly as it
3672    /// does across ordinary [`Self::execute_script`] calls.
3673    ///
3674    /// The saved fds go through `movefd` to land at fd >= 10 and marked
3675    /// `FDT_INTERNAL`, per zsh's invariant that shell-internal fds never live
3676    /// below 10 — otherwise a script doing `exec 9>&-` closes the capture's own
3677    /// bookkeeping. A temp file, not a pipe, receives the output: with no
3678    /// concurrent reader, a pipe deadlocks the moment a script writes past the
3679    /// 64 KiB buffer.
3680    ///
3681    /// # Concurrency contract
3682    ///
3683    /// **While a capture is in flight, no other thread in the process may write
3684    /// fd 1 or fd 2.** POSIX has no per-thread fd table, so pointing fd 1 at the
3685    /// capture points it there for every thread at once; any byte another thread
3686    /// writes during the window lands in the returned `String` instead of on the
3687    /// terminal. The `CAPTURE_LOCK` below excludes a second *capture*, which is
3688    /// all a lock can do — a thread that never calls this function (a logger, a
3689    /// progress meter, a test harness's own reporter) is not excluded by
3690    /// anything, and its output is silently absorbed.
3691    ///
3692    /// This is not a gap that a different capture mechanism closes. C zsh dodges
3693    /// it for `$(…)` by forking: `getoutput` (`Src/exec.c:4816`) calls `zfork`
3694    /// and only the child does `redup(pipes[1], 1)` (`Src/exec.c:4837`), so the
3695    /// parent's fd 1 is never touched — but the child then runs `entersubsh`
3696    /// (`Src/exec.c:4838`) and a variable it sets is gone. Forking here would
3697    /// throw away the one property this call exists to provide (state persists
3698    /// on THIS VM across captured runs), so the cost is paid as a contract
3699    /// instead: **capture from one thread, and quiesce the rest.**
3700    pub fn execute_script_captured(&mut self, script: &str) -> (i32, String) {
3701        use std::io::{Read, Seek, SeekFrom};
3702        use std::os::unix::io::AsRawFd;
3703
3704        /// Serializes the redirect/restore window. fd 1 belongs to the process,
3705        /// not to a `ShellExecutor`, so two threads capturing at once would
3706        /// restore each other's fds mid-run and each would read back an empty
3707        /// file. An embedder that evaluates on one thread never contends here.
3708        /// It excludes another *capture* and nothing else: see the concurrency
3709        /// contract on this function for what remains the caller's problem.
3710        static CAPTURE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3711        let _guard = CAPTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3712
3713        let Ok(mut tmp) = tempfile::tempfile() else {
3714            // No temp file, no capture: run it anyway rather than silently
3715            // dropping the script, and report nothing captured.
3716            let status = self.execute_script(script).unwrap_or(1);
3717            return (status, String::new());
3718        };
3719
3720        // Flush Rust's buffered stdout against the REAL fd 1 before the swap,
3721        // or bytes written before this call drain into the capture instead
3722        // (the same ordering bug `run_command_substitution` documents).
3723        let _ = io::stdout().flush();
3724
3725        /// Puts fds 1 and 2 back on the way out, including when the run
3726        /// unwinds. A panic anywhere under `execute_script` would otherwise
3727        /// leave the whole PROCESS writing into a temp file that is already
3728        /// unlinked — every later write vanishes, starting with the one
3729        /// reporting the panic, which turns a localized bug into a silent one.
3730        struct RestoreFds {
3731            saved_out: i32,
3732            saved_err: i32,
3733        }
3734        impl Drop for RestoreFds {
3735            fn drop(&mut self) {
3736                let _ = io::stdout().flush();
3737                unsafe {
3738                    libc::dup2(self.saved_out, libc::STDOUT_FILENO);
3739                    libc::dup2(self.saved_err, libc::STDERR_FILENO);
3740                }
3741                crate::ported::utils::zclose(self.saved_out);
3742                crate::ported::utils::zclose(self.saved_err);
3743            }
3744        }
3745
3746        let saved_out = crate::ported::utils::movefd(unsafe { libc::dup(libc::STDOUT_FILENO) });
3747        let saved_err = crate::ported::utils::movefd(unsafe { libc::dup(libc::STDERR_FILENO) });
3748        unsafe {
3749            libc::dup2(tmp.as_raw_fd(), libc::STDOUT_FILENO);
3750            libc::dup2(tmp.as_raw_fd(), libc::STDERR_FILENO);
3751        }
3752        let restore = RestoreFds {
3753            saved_out,
3754            saved_err,
3755        };
3756
3757        let status = self.execute_script(script);
3758
3759        // Explicit, not end-of-scope: the temp file must be read back only
3760        // after the real fds are restored, or a diagnostic emitted while
3761        // reading would land in the very buffer being read.
3762        drop(restore);
3763
3764        let mut output = String::new();
3765        let _ = tmp.seek(SeekFrom::Start(0));
3766        let mut bytes = Vec::new();
3767        if tmp.read_to_end(&mut bytes).is_ok() {
3768            output = String::from_utf8_lossy(&bytes).into_owned();
3769        }
3770        // Match `$(…)`: one trailing newline is an artifact of the last `echo`,
3771        // not part of the output.
3772        while output.ends_with('\n') {
3773            output.pop();
3774        }
3775
3776        (status.unwrap_or_else(|_| self.last_status()), output)
3777    }
3778
3779    /// Run an ALREADY-PARSED program (the back half of
3780    /// `execute_script_zsh_pipeline`): compile the `ZshProgram` to a
3781    /// fusevm Chunk and run it. Used by the ported `loop()` REPL
3782    /// (Src/init.c:220 `execode`), which parses via `parse_event` and
3783    /// hands the program here through the `execute_program` exec hook.
3784    /// Returns the resulting `$?` (1 on a compile/run error).
3785    pub fn execute_program(&mut self, program: &crate::parse::ZshProgram) -> i32 {
3786        let chunk = crate::compile_zsh::ZshCompiler::new().compile(program);
3787        match self.run_chunk(chunk, "loop") {
3788            Ok(status) => status,
3789            Err(_) => 1,
3790        }
3791    }
3792
3793    /// Whether `name` is a known function. Checks the compiled-functions
3794    /// table and the autoload-pending registry — `autoload foo` should
3795    /// make `whence foo`/`type foo`/`functions foo` recognize `foo` as
3796    /// a function before it's actually loaded. Doesn't trigger autoload
3797    /// itself; use `maybe_autoload` first if you need to load before
3798    /// introspecting.
3799    pub fn function_exists(&self, name: &str) -> bool {
3800        // Either compiled (already loaded) or shfunctab has an
3801        // autoload stub with PM_UNDEFINED set (pending). Matches C's
3802        // `lookupshfunc(name)` semantics at `Src/exec.c:5215`.
3803        if self.functions_compiled.contains_key(name) {
3804            return true;
3805        }
3806        crate::ported::hashtable::shfunctab_lock()
3807            .read()
3808            .ok()
3809            .map(|t| t.get(name).is_some())
3810            .unwrap_or(false)
3811    }
3812
3813    /// Sorted list of every known function name (union of compiled + source).
3814    pub fn function_names(&self) -> Vec<String> {
3815        let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
3816        for k in self.functions_compiled.keys() {
3817            set.insert(k.clone());
3818        }
3819        for k in self.function_source.keys() {
3820            set.insert(k.clone());
3821        }
3822        set.into_iter().collect()
3823    }
3824
3825    /// Dispatch a function by name. Thin passthru — autoload-materialize
3826    /// the body if needed, build a synthetic `shfunc`, and hand off to
3827    /// the canonical `doshfunc` port (`Src/exec.c:5823` →
3828    /// `src/ported/exec.rs::doshfunc`). doshfunc owns ALL scope
3829    /// management (starttrapscope/endtrapscope, startparamscope/
3830    /// endparamscope, funcdepth bump, pipestats save/restore, scriptname
3831    /// snapshot, BREAKS/CONTFLAG/LOOPS/RETFLAG snapshot+restore, `$0`
3832    /// override via FUNCTIONARGZERO, etc.). The body run itself is the
3833    /// Rust-only adaptation passed via the `body_runner` closure because
3834    /// zshrs runs function bodies through fusevm bytecode (not C zsh's
3835    /// wordcode walker via `runshfunc`).
3836    ///
3837    /// Returns `None` when the name isn't a known function so the caller
3838    /// can fall through to external dispatch.
3839    /// Body-only counterpart to [`dispatch_function_call`] — runs
3840    /// the function body WITHOUT wrapping in `doshfunc`. Used as the
3841    /// `body_runner` closure target by `src/ported/` callers that
3842    /// already wrap their own `crate::ported::exec::doshfunc(...)`
3843    /// call (so going back through `dispatch_function_call` would
3844    /// double-wrap the scope). Mirrors C's `runshfunc(prog, wrappers,
3845    /// name)` at `exec.c:6042` from doshfunc's perspective.
3846    pub fn run_function_body_only(&mut self, name: &str, args: &[String]) -> Option<i32> {
3847        // Held for the WHOLE call, not just the load: an autoloaded function is
3848        // registered TWICE — once when its file's text defines it, and again
3849        // (unchanged) when its chunk is compiled at call time — and the second
3850        // stamp would otherwise relabel it with the caller's scriptfilename.
3851        // See the AUTOLOAD_DEF_FILE consumer in fusevm_bridge.
3852        let mut _autoload_file_guard: Option<AutoloadFileGuard> = None;
3853        // Same Rust-port short-circuit as dispatch_function_call,
3854        // sans the doshfunc wrap.
3855        if let Some(rc) = crate::compsys::router::dispatch_compsys(name, args) {
3856            // Plugin override (ABI v4) wins over the built-in Rust port.
3857            return Some(rc);
3858        }
3859        // Bug #657 gap #2 — `_regex_arguments`-generated completion functions
3860        // live in a runtime registry, not the static router table (a plain
3861        // `fn` ptr can't carry the dynamic name). Consult that registry here
3862        // so `compdef mycmd` → `_comps[cmd]=mycmd` → this call routes to the
3863        // compiled regex state machine.
3864        if let Some(rc) = crate::compsys::ported::_regex_arguments::dispatch_if_registered(name) {
3865            return Some(rc);
3866        }
3867        // c:Src/exec.c:5626 — see the twin site in
3868        // `dispatch_function_call`: a body loaded on THIS call runs one
3869        // `zsh_eval_context` frame deeper ("loadautofunc") than the
3870        // caller's "shfunc".
3871        let mut did_autoload = false;
3872        // Autoload prelude (same as dispatch_function_call's).
3873        if !self.functions_compiled.contains_key(name) {
3874            // On-demand $fpath autoload for `_`-prefixed compsys helpers that
3875            // compinit didn't register as autoload stubs — see the fuller
3876            // note in dispatch_function_call.
3877            if name.starts_with('_') && crate::ported::utils::getshfunc(name).is_none() {
3878                // c:6219 getfpfunc — gate the stub on the definition file
3879                // actually existing in $fpath, mirroring zsh's `compdef -na`
3880                // (only autoloads `_`-names present in fpath). Without this a
3881                // `_`-name with no file (e.g. a fasd completer trigger absent
3882                // from this fpath) got a phantom PM_UNDEFINED stub, and
3883                // loadautofn then leaked "function definition file not found"
3884                // to the terminal during completion. test_only=1 is a pure
3885                // probe; dump_out is preserved so .zwc-dump autoloads resolve.
3886                let mut _dir: Option<String> = None;
3887                let mut _dump = None;
3888                if crate::ported::exec::getfpfunc(name, &mut _dir, None, 1, &mut _dump).is_some() {
3889                    let _ = self.execute_script_zsh_pipeline(&format!("autoload -rUz -- {name}"));
3890                }
3891            }
3892            if let Some(stub) = crate::ported::utils::getshfunc(name) {
3893                // c:Src/exec.c:5684-5704 (loadautofn) —
3894                //     int noalias = noaliases;
3895                //     noaliases = (shf->node.flags & PM_UNALIASED);
3896                //     prog = getfpfunc(...);        /* parses the file */
3897                //     noaliases = noalias;
3898                // `autoload -U` records PM_UNALIASED (c:3354-3357), and its ONLY
3899                // effect is that the autoloaded body is PARSED with alias
3900                // expansion disabled. zshrs recorded the bit but never consulted
3901                // it, so a body calling `helper` picked up a caller-defined
3902                // `alias helper=...` — exactly what -U exists to prevent.
3903                let unaliased = (stub.node.flags as u32 & crate::ported::zsh_h::PM_UNALIASED) != 0;
3904                let noalias_save = crate::ported::lex::noaliases(); // c:5684
3905                crate::ported::lex::set_noaliases(unaliased); // c:5697
3906                let _restore_noaliases = NoAliasesRestore(noalias_save); // c:5704
3907                if (stub.node.flags as u32 & PM_UNDEFINED) != 0 {
3908                    did_autoload = true; // c:5626 — body runs as "loadautofunc"
3909                    let boxed = Box::new(stub.clone());
3910                    let ptr = Box::into_raw(boxed);
3911                    let load_rc = crate::ported::exec::loadautofn(ptr, 0, 0, 0);
3912                    unsafe {
3913                        let _ = Box::from_raw(ptr);
3914                    }
3915                    // c:Src/exec.c:5713-5719 — `if (prog == &dummy_eprog) {
3916                    //     zwarn("%s: function definition file not found",
3917                    //           shf->node.nam); … return NULL; }`, and
3918                    // c:5635-5644 execautofn: `if (!loadautofn(...)) return 1;`
3919                    // A failed load is TERMINAL: C has already replaced
3920                    // shf->funcdef with the mkautofn trampoline (c:3180), so
3921                    // nothing of the old stub body survives to be re-run.
3922                    // zshrs keeps the stub's TEXT on the shfunc node, and the
3923                    // `if let Some(body)` arm below would hand that text back
3924                    // to run_autoload_definition — re-executing the very
3925                    // `autoload -X` that triggered this load. `cod() {
3926                    // autoload -XUz }; cod` recursed until FUNCNEST and
3927                    // printed the diagnostic 500 times (C04funcdef:38,39,40).
3928                    if load_rc != 0 {
3929                        return Some(1); // c:5719 NULL → c:5644 `return 1`
3930                    }
3931                    // c:5657 — preserve the fpath dir + PM_LOADDIR across the
3932                    // funcdef re-register (which stamps filename="zsh"), so
3933                    // `whence -v` reports the source. See the twin site below.
3934                    let loaded_dir = crate::ported::utils::getshfunc(name)
3935                        .and_then(|f| f.filename)
3936                        .filter(|d| d != "zsh");
3937                    // c:Src/exec.c:5682-5760 — C loads the body IN PLACE on the
3938                    // existing Shfunc, so every stub flag except PM_UNDEFINED
3939                    // survives. See the twin site below for why PM_ABSPATH_USED
3940                    // in particular has to come back.
3941                    let abspath_used =
3942                        (stub.node.flags as u32 & crate::ported::zsh_h::PM_ABSPATH_USED) != 0;
3943                    let ksh_style = autoload_is_ksh_style(name); // c:5781 (pre-registration)
3944                    if let Some(body) = crate::ported::utils::getshfunc(name).and_then(|f| f.body) {
3945                        _autoload_file_guard = Some(AutoloadFileGuard::enter(name));
3946                        let (registered, from_wordcode) = autoload_register_source(name, &body);
3947                        {
3948                            // c:Src/exec.c:5735-5760 — C INSTALLS the parsed Eprog
3949                            // as the function body; it executes nothing at load
3950                            // time, so the global `lineno` still holds the line the
3951                            // CALL was made on when doshfunc records
3952                            // `funcsave->fstack.lineno = lineno` (c:6013). zshrs
3953                            // installs the body by RUNNING `name() { … }` through
3954                            // the pipeline, which walks the counter to the file's
3955                            // last line — so the very first call of an autoloaded
3956                            // function reported its caller's line as that instead:
3957                            // `$functrace` read `script.zsh:1` where zsh reads
3958                            // `script.zsh:4`, and inside completion `_subscript:0`
3959                            // where zsh reads `_subscript:125`. Every LATER call
3960                            // was already correct, because the load only happens
3961                            // once.
3962                            let caller_lineno = crate::ported::lex::lineno();
3963                            // c:5384-5388 assigns `shf->lineno` only when a
3964                            // `name() { … }` STATEMENT defines the function. An
3965                            // autoload stub's Shfunc keeps the 0 it was created
3966                            // with, and loadautofn replaces only `funcdef`, so zsh
3967                            // reports `funcsourcetrace` as `<file>:0`. Running a
3968                            // synthesized wrapper here stamps line 1 instead, so
3969                            // put the stub's value back when the wrapper was ours.
3970                            let synthesized = registered != body;
3971                            let _ = self.run_autoload_definition(
3972                                name,
3973                                &registered,
3974                                ksh_style,
3975                                from_wordcode,
3976                            );
3977                            crate::ported::lex::set_lineno(caller_lineno);
3978                            if synthesized {
3979                                // c:5384-5388 sets `shf->lineno` only where a
3980                                // `name() { … }` STATEMENT defines the function; an
3981                                // autoload stub keeps the 0 it was created with and
3982                                // loadautofn replaces only `funcdef`, so
3983                                // `funcsourcetrace` reads `<file>:0`. Executing our
3984                                // synthesized wrapper records a line base of 1
3985                                // instead. -1 marks "autoload-installed" so the
3986                                // call-time clamp below can tell that apart from an
3987                                // INLINE `f() { … }`, whose base underflows to 0 but
3988                                // whose def line really is >= 1.
3989                                self.function_line_base.insert(name.to_string(), -1);
3990                            }
3991                        }
3992                    }
3993                    if let Some(dir) = loaded_dir.as_deref() {
3994                        restore_loaddir(name, dir, abspath_used, ksh_style);
3995                    }
3996                } else if let Some(body) = stub.body.clone() {
3997                    // c:Src/builtin.c:3180 (eval_autoload) — `autoload +X NAME`
3998                    // loads the body EAGERLY through `loadautofn`, which sets
3999                    // `body` + `filename`/PM_LOADDIR and clears PM_UNDEFINED
4000                    // but leaves no compiled chunk behind. The first CALL of
4001                    // such a function therefore lands in THIS arm, never the
4002                    // PM_UNDEFINED arm above, so it needs the same
4003                    // post-registration restore — otherwise `autoload +X
4004                    // /abs/dir/NAME` lost the pair before the body ran and a
4005                    // sibling `autoload -Uz SIB` inside it failed with
4006                    // "function definition file not found" where zsh loads
4007                    // /abs/dir/SIB. `functions[name]=body` (parameter.c
4008                    // setpmfunction) reaches this arm too and simply has no
4009                    // PM_LOADDIR, so the restore is skipped for it.
4010                    let loaded_dir = ((stub.node.flags as u32 & crate::ported::zsh_h::PM_LOADDIR)
4011                        != 0)
4012                        .then(|| stub.filename.clone())
4013                        .flatten();
4014                    let abspath_used =
4015                        (stub.node.flags as u32 & crate::ported::zsh_h::PM_ABSPATH_USED) != 0;
4016                    let ksh_style = autoload_is_ksh_style(name); // c:5781 (pre-registration)
4017                    _autoload_file_guard = Some(AutoloadFileGuard::enter(name));
4018                    let (registered, from_wordcode) = autoload_register_source(name, &body);
4019                    {
4020                        // c:Src/exec.c:5735-5760 — C INSTALLS the parsed Eprog
4021                        // as the function body; it executes nothing at load
4022                        // time, so the global `lineno` still holds the line the
4023                        // CALL was made on when doshfunc records
4024                        // `funcsave->fstack.lineno = lineno` (c:6013). zshrs
4025                        // installs the body by RUNNING `name() { … }` through
4026                        // the pipeline, which walks the counter to the file's
4027                        // last line — so the very first call of an autoloaded
4028                        // function reported its caller's line as that instead:
4029                        // `$functrace` read `script.zsh:1` where zsh reads
4030                        // `script.zsh:4`, and inside completion `_subscript:0`
4031                        // where zsh reads `_subscript:125`. Every LATER call
4032                        // was already correct, because the load only happens
4033                        // once.
4034                        let caller_lineno = crate::ported::lex::lineno();
4035                        // c:5384-5388 assigns `shf->lineno` only when a
4036                        // `name() { … }` STATEMENT defines the function. An
4037                        // autoload stub's Shfunc keeps the 0 it was created
4038                        // with, and loadautofn replaces only `funcdef`, so zsh
4039                        // reports `funcsourcetrace` as `<file>:0`. Running a
4040                        // synthesized wrapper here stamps line 1 instead, so
4041                        // put the stub's value back when the wrapper was ours.
4042                        let synthesized = registered != body;
4043                        let _ = self.run_autoload_definition(
4044                            name,
4045                            &registered,
4046                            ksh_style,
4047                            from_wordcode,
4048                        );
4049                        crate::ported::lex::set_lineno(caller_lineno);
4050                        if synthesized {
4051                            // c:5384-5388 sets `shf->lineno` only where a
4052                            // `name() { … }` STATEMENT defines the function; an
4053                            // autoload stub keeps the 0 it was created with and
4054                            // loadautofn replaces only `funcdef`, so
4055                            // `funcsourcetrace` reads `<file>:0`. Executing our
4056                            // synthesized wrapper records a line base of 1
4057                            // instead. -1 marks "autoload-installed" so the
4058                            // call-time clamp below can tell that apart from an
4059                            // INLINE `f() { … }`, whose base underflows to 0 but
4060                            // whose def line really is >= 1.
4061                            self.function_line_base.insert(name.to_string(), -1);
4062                        }
4063                    }
4064                    if let Some(dir) = loaded_dir.as_deref() {
4065                        restore_loaddir(name, dir, abspath_used, ksh_style);
4066                    }
4067                }
4068            }
4069        }
4070        let chunk = self.functions_compiled.get(name).cloned()?;
4071        // c:5626 — `execode(shf->funcdef, 1, 0, "loadautofunc")`. Held
4072        // across the body run and dropped with the VM below.
4073        let _load_ctx =
4074            did_autoload.then(|| crate::ported::exec::EvalContextFrame::push("loadautofunc"));
4075        let seed_status = self.last_status();
4076        let _ = args; // fusevm body reads $1..$N from PPARAMS
4077                      // Reuse a VM from the per-thread pool instead of building one from
4078                      // scratch every call. `register_builtins` installs ~hundreds of
4079                      // fn-pointer handlers into the VM's builtin_table; the table is
4080                      // identical for every VM, so re-running it per function call was
4081                      // pure waste (~130 profile samples in a tight call loop, the #2 hot
4082                      // spot after option lookups). `VM::reset(chunk)` clears execution
4083                      // state but PRESERVES builtin_table / host / JIT wiring, so a
4084                      // recycled VM is call-ready without re-registration. Fresh VMs pay
4085                      // the registration once. Nested calls simply check out additional
4086                      // VMs; the pool grows to the max call depth. Re-entrant and
4087                      // panic-safe: the VM is returned on the normal path below.
4088                      // c:Src/exec.c:4364 — a `return` out of a redirected compound
4089                      // command still runs `fixfds(save)`. See
4090                      // `unwind_redirect_scopes_to`.
4091        let redir_depth = self.redirect_scope_stack.len();
4092        let mut vm = crate::vm_pool::acquire(chunk);
4093        vm.last_status = seed_status;
4094        let _ = vm.run();
4095        let status = vm.last_status;
4096        drop(vm);
4097        self.unwind_redirect_scopes_to(redir_depth);
4098        Some(status)
4099    }
4100
4101    pub fn dispatch_function_call(&mut self, name: &str, args: &[String]) -> Option<i32> {
4102        // Held for the WHOLE call, not just the load: an autoloaded function is
4103        // registered TWICE — once when its file's text defines it, and again
4104        // (unchanged) when its chunk is compiled at call time — and the second
4105        // stamp would otherwise relabel it with the caller's scriptfilename.
4106        // See the AUTOLOAD_DEF_FILE consumer in fusevm_bridge.
4107        let mut _autoload_file_guard: Option<AutoloadFileGuard> = None;
4108        // Nested scope for `>(cmd)` fd ownership — builtins running
4109        // inside the function body must not close the CALLER's
4110        // pending psub fds (`myfn >(cmd)` keeps /dev/fd/N alive for
4111        // the whole function, like C's per-job filelist). See
4112        // PSUB_SCOPE_DEPTH in fusevm_bridge.rs.
4113        let _psub_scope = crate::fusevm_bridge::PsubScope::enter();
4114        // c:Src/exec.c — `disable -f NAME` flips the DISABLED flag on
4115        // the shfunctab entry. `lookupshfunc` (which dispatch consults)
4116        // returns NULL for DISABLED entries, falling through to PATH
4117        // lookup → "command not found". zshrs keeps the compiled body
4118        // in functions_compiled independently of the flag, so check
4119        // shfunctab and short-circuit when DISABLED is set. Bug #221
4120        // in docs/BUGS.md.
4121        let is_disabled = crate::ported::hashtable::shfunctab_lock()
4122            .read()
4123            .ok()
4124            .and_then(|t| {
4125                let entry = t.get_including_disabled(name)?;
4126                Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
4127            })
4128            .unwrap_or(false);
4129        if is_disabled {
4130            return None;
4131        }
4132        // `_regex_arguments NAME …` (e.g. `_regex_arguments _sed_expressions …`
4133        // in `_sed`) eval-defines a real shell function NAME in zsh. This port
4134        // stores it in a runtime registry keyed by NAME (a static router fn-ptr
4135        // can't carry a dynamic name). `run_function_body_only` already consults
4136        // that registry, but `dispatch_function_call` — the path an `_arguments`
4137        // action (`:sed script:_sed_expressions`) or any by-name caller takes —
4138        // did not, so the call fell through to the autoload prelude and errored
4139        // "function definition file not found" (`sed -<TAB>`). Consult the
4140        // registry here too, before autoload. Returned directly (like
4141        // run_function_body_only) — the regex body drives compsys globals, not
4142        // function locals, so it needs no doshfunc scope wrap.
4143        if let Some(rc) = crate::compsys::ported::_regex_arguments::dispatch_if_registered(name) {
4144            return Some(rc);
4145        }
4146        // zshrs-original: `[compsys] backend = "rust"` short-circuit.
4147        // When a `_NAME` has a Rust port AND the user opted into the
4148        // rust backend, run the Rust fn directly here — but still
4149        // through the canonical doshfunc scope-management path below
4150        // (we synthesize a body_runner from the fn pointer). Router
4151        // returns None for names without a Rust port → graceful
4152        // fallback to the shfunc autoload path.
4153        //
4154        // Note: `compcore::callcompfunc` (the compsys entry hit by
4155        // Tab) wraps doshfunc itself per C `compcore.c:835`, so the
4156        // Rust _main_complete dispatch lands HERE only when called
4157        // from a non-compcore caller (e.g. a user shell script
4158        // directly invoking `_main_complete`). The doshfunc scope
4159        // wrap below applies uniformly to both.
4160        let direct_rust_fn: Option<fn(&[String]) -> i32> =
4161            crate::compsys::router::try_rust_dispatch(name);
4162        // A plugin-registered override (ABI v4, `zmodload -R`) also
4163        // intercepts natively: it supplies the body, so no shell autoload
4164        // or compiled chunk is needed — same as a built-in Rust port.
4165        let has_plugin_override = crate::extensions::plugin_host::compfn_override(name).is_some();
4166        // c:Src/exec.c:5626 — the body of a function loaded on THIS call
4167        // runs through `execode(shf->funcdef, 1, 0, "loadautofunc")`
4168        // (execautofn_basic), nested inside runshfunc's "shfunc" frame.
4169        // zshrs performs the load here, before `doshfunc`, so the flag
4170        // carries the fact into the body_runner that pushes the frame.
4171        let mut did_autoload = false;
4172        // Autoload prelude skipped when a Rust port OR plugin override wins
4173        // — no upstream shell function to load.
4174        if direct_rust_fn.is_none()
4175            && !has_plugin_override
4176            && !self.functions_compiled.contains_key(name)
4177        {
4178            // compinit bulk-loads $_comps from the dump/cache but (unlike
4179            // zsh's `compdef -na`, which `autoload -rUz`s every completer)
4180            // does NOT register the completer functions as autoload stubs.
4181            // So a shell completer WITHOUT a Rust port (e.g. `_cat`, or the
4182            // helpers it calls: `_pick_variant`, `_arguments`…) had no
4183            // shfunctab entry — getshfunc returned None, nothing compiled,
4184            // dispatch returned None, and the command's completion silently
4185            // produced nothing. Register `_`-prefixed helpers from $fpath on
4186            // demand (mirrors a fresh `autoload -Uz NAME`) so getshfunc finds
4187            // the stub below and loadautofn reads the file. Gated to `_`
4188            // names so ordinary commands still fall through to PATH.
4189            if name.starts_with('_') && crate::ported::utils::getshfunc(name).is_none() {
4190                // c:6219 getfpfunc — gate the stub on the definition file
4191                // actually existing in $fpath, mirroring zsh's `compdef -na`
4192                // (only autoloads `_`-names present in fpath). Without this a
4193                // `_`-name with no file (e.g. a fasd completer trigger absent
4194                // from this fpath) got a phantom PM_UNDEFINED stub, and
4195                // loadautofn then leaked "function definition file not found"
4196                // to the terminal during completion. test_only=1 is a pure
4197                // probe; dump_out is preserved so .zwc-dump autoloads resolve.
4198                let mut _dir: Option<String> = None;
4199                let mut _dump = None;
4200                if crate::ported::exec::getfpfunc(name, &mut _dir, None, 1, &mut _dump).is_some() {
4201                    let _ = self.execute_script_zsh_pipeline(&format!("autoload -rUz -- {name}"));
4202                }
4203            }
4204            if let Some(stub) = crate::ported::utils::getshfunc(name) {
4205                // c:Src/exec.c:5684-5704 (loadautofn) — `autoload -U` records
4206                // PM_UNALIASED, whose ONLY effect is that the autoloaded body is
4207                // PARSED with alias expansion disabled:
4208                //     int noalias = noaliases;
4209                //     noaliases = (shf->node.flags & PM_UNALIASED);
4210                //     prog = getfpfunc(...);        /* parses the file */
4211                //     noaliases = noalias;
4212                let unaliased = (stub.node.flags as u32 & crate::ported::zsh_h::PM_UNALIASED) != 0;
4213                let noalias_save = crate::ported::lex::noaliases(); // c:5684
4214                crate::ported::lex::set_noaliases(unaliased); // c:5697
4215                                                              // c:5704 — restored on EVERY exit from this block, including the
4216                                                              // early `return Some(1)` paths below.
4217                let _restore_noaliases = NoAliasesRestore(noalias_save);
4218                if (stub.node.flags as u32 & PM_UNDEFINED) != 0 {
4219                    did_autoload = true; // c:5626 — body runs as "loadautofunc"
4220                    let boxed = Box::new(stub.clone());
4221                    let ptr = Box::into_raw(boxed);
4222                    let load_rc = crate::ported::exec::loadautofn(ptr, 0, 0, 0);
4223                    unsafe {
4224                        let _ = Box::from_raw(ptr);
4225                    }
4226                    // c:Src/exec.c:5713-5719 / 5635-5644 — a failed load is
4227                    // TERMINAL: `loadautofn` already emitted "function
4228                    // definition file not found" and C's `execautofn` returns
4229                    // 1 without ever touching a body (C replaced shf->funcdef
4230                    // with the mkautofn trampoline at c:3180). zshrs still has
4231                    // the stub's TEXT on the shfunc node, and the `if let
4232                    // Some(body)` arm below would re-run it — for an
4233                    // `autoload -X` stub that means re-entering the autoload
4234                    // path, which recursed to FUNCNEST and printed the
4235                    // diagnostic 500 times (C04funcdef:38,39,40). The
4236                    // `else if load_rc != 0` arm below stays as the (now
4237                    // unreachable) faithful mirror of the same C line.
4238                    if load_rc != 0 {
4239                        return Some(1); // c:5719 NULL → c:5644 `return 1`
4240                    }
4241                    // c:Src/exec.c:5657 loadautofnsetfile — capture the fpath
4242                    // directory loadautofn wrote so it can be restored (as an
4243                    // absolutized path with PM_LOADDIR) after the funcdef pipeline
4244                    // below clobbers `filename` to scriptfilename ("zsh"). Without
4245                    // this, `whence -v <autoloaded>` printed "from zsh".
4246                    let loaded_dir = crate::ported::utils::getshfunc(name)
4247                        .and_then(|f| f.filename)
4248                        .filter(|d| d != "zsh");
4249                    // c:Src/exec.c:5682-5760 — C loads the body IN PLACE on the
4250                    // existing Shfunc: `shf->node.flags &= ~PM_UNDEFINED`
4251                    // (c:5751) is the only flag C clears, so PM_ABSPATH_USED —
4252                    // stamped by `autoload -Uz /abs/dir/NAME`
4253                    // (`add_autoload_function`, Src/builtin.c:3290-3291) —
4254                    // survives the load. zshrs re-registers the body through the
4255                    // funcdef pipeline, which builds a FRESH node and drops the
4256                    // whole flag word; `loadautofnsetfile` below puts filename +
4257                    // PM_LOADDIR back, and PM_ABSPATH_USED has to come back with
4258                    // them. It is read by `add_autoload_function`'s sibling arm
4259                    // (Src/builtin.c:3310-3323): when a function loaded by
4260                    // absolute path autoloads a sibling with a bare name, C
4261                    // inherits the CALLER's load directory — `if ((shf2 = ...
4262                    // getnode2(shfunctab, calling_f)) && (shf2->node.flags &
4263                    // PM_LOADDIR) && (shf2->node.flags & PM_ABSPATH_USED) && ...`
4264                    // Without the restore that test never fired, so
4265                    // `autoload -Uz $D/wrapper; wrapper` → `autoload -Uz sibling`
4266                    // failed with "function definition file not found" (zsh runs
4267                    // $D/sibling), and compsys `_`-names fell through to the Rust
4268                    // port instead of the user's file.
4269                    let abspath_used =
4270                        (stub.node.flags as u32 & crate::ported::zsh_h::PM_ABSPATH_USED) != 0;
4271                    if let Some(body) = crate::ported::utils::getshfunc(name).and_then(|f| f.body) {
4272                        let ksh_style = autoload_is_ksh_style(name); // c:5781 (pre-registration)
4273                        _autoload_file_guard = Some(AutoloadFileGuard::enter(name));
4274                        let (registered, from_wordcode) = autoload_register_source(name, &body);
4275                        // c:Src/exec.c:5739 — the ksh-autoload body runs via
4276                        // `execode(prog, 1, 0, "evalautofunc")` at the function
4277                        // invocation's locallevel, so a `return`/`break`/
4278                        // `continue` inside the file body is CONTAINED to the
4279                        // autoload call. add-zle-hook-widget's first line is
4280                        // `zmodload -e zsh/zle || return 1`; when a plugin has
4281                        // leaked `ksh_autoload` on (e.g. a bare `emulate sh`),
4282                        // that `return` must NOT propagate out and abort the
4283                        // caller's precmd/shell (zsh warns "not defined by file"
4284                        // and CONTINUES). Save & restore the control-flow flags
4285                        // around the body run to reinstate that boundary.
4286                        {
4287                            use crate::ported::builtin::{
4288                                BREAKS, EXIT_PENDING, EXIT_VAL, RETFLAG, SHELL_EXITING,
4289                            };
4290                            use std::sync::atomic::Ordering::Relaxed;
4291                            // c:Src/exec.c:5739 — `execode(prog, 1, 0,
4292                            // "evalautofunc")` runs the file body as part of the
4293                            // autoload invocation. add-zle-hook-widget's
4294                            // `zmodload -e zsh/zle || return 1` sits at the
4295                            // file's TOP LEVEL (above its anon-func wrapper); at
4296                            // script scope a top-level `return` is a shell EXIT,
4297                            // so running the body as a plain script aborted the
4298                            // caller's precmd/shell. `return` is contained when
4299                            // `locallevel || sourcelevel` (bin_return, c:5840) —
4300                            // raise SOURCELEVEL (the file-source counter, which
4301                            // unlike locallevel does NOT open a local scope, so
4302                            // the body's global assignments still land globally)
4303                            // so the top-level `return` returns from the load
4304                            // instead of exiting. Save/restore the control-flow
4305                            // flags so nothing leaks — matching zsh's
4306                            // warn-and-continue.
4307                            use crate::ported::init::sourcelevel;
4308                            let saved_retflag = RETFLAG.swap(0, Relaxed);
4309                            let saved_breaks = BREAKS.swap(0, Relaxed);
4310                            let saved_exit_pending = EXIT_PENDING.swap(0, Relaxed);
4311                            let saved_exit_val = EXIT_VAL.swap(0, Relaxed);
4312                            let saved_shell_exiting = SHELL_EXITING.swap(0, Relaxed);
4313                            sourcelevel.fetch_add(1, Relaxed);
4314                            {
4315                                // c:Src/exec.c:5739 — the ksh-autoload branch
4316                                // runs the file through
4317                                // `execode(prog, 1, 0, "evalautofunc")`, so
4318                                // that label is on `zsh_eval_context` while
4319                                // the file body executes.
4320                                let _ctx =
4321                                    crate::ported::exec::EvalContextFrame::push("evalautofunc");
4322                                {
4323                                    // c:Src/exec.c:5735-5760 — C INSTALLS the parsed Eprog
4324                                    // as the function body; it executes nothing at load
4325                                    // time, so the global `lineno` still holds the line the
4326                                    // CALL was made on when doshfunc records
4327                                    // `funcsave->fstack.lineno = lineno` (c:6013). zshrs
4328                                    // installs the body by RUNNING `name() { … }` through
4329                                    // the pipeline, which walks the counter to the file's
4330                                    // last line — so the very first call of an autoloaded
4331                                    // function reported its caller's line as that instead:
4332                                    // `$functrace` read `script.zsh:1` where zsh reads
4333                                    // `script.zsh:4`, and inside completion `_subscript:0`
4334                                    // where zsh reads `_subscript:125`. Every LATER call
4335                                    // was already correct, because the load only happens
4336                                    // once.
4337                                    let caller_lineno = crate::ported::lex::lineno();
4338                                    // c:5384-5388 assigns `shf->lineno` only when a
4339                                    // `name() { … }` STATEMENT defines the function. An
4340                                    // autoload stub's Shfunc keeps the 0 it was created
4341                                    // with, and loadautofn replaces only `funcdef`, so zsh
4342                                    // reports `funcsourcetrace` as `<file>:0`. Running a
4343                                    // synthesized wrapper here stamps line 1 instead, so
4344                                    // put the stub's value back when the wrapper was ours.
4345                                    let synthesized = registered != body;
4346                                    let _ = self.run_autoload_definition(
4347                                        name,
4348                                        &registered,
4349                                        ksh_style,
4350                                        from_wordcode,
4351                                    );
4352                                    crate::ported::lex::set_lineno(caller_lineno);
4353                                    if synthesized {
4354                                        // c:5384-5388 sets `shf->lineno` only where a
4355                                        // `name() { … }` STATEMENT defines the function; an
4356                                        // autoload stub keeps the 0 it was created with and
4357                                        // loadautofn replaces only `funcdef`, so
4358                                        // `funcsourcetrace` reads `<file>:0`. Executing our
4359                                        // synthesized wrapper records a line base of 1
4360                                        // instead. -1 marks "autoload-installed" so the
4361                                        // call-time clamp below can tell that apart from an
4362                                        // INLINE `f() { … }`, whose base underflows to 0 but
4363                                        // whose def line really is >= 1.
4364                                        self.function_line_base.insert(name.to_string(), -1);
4365                                    }
4366                                }
4367                            }
4368                            sourcelevel.fetch_sub(1, Relaxed);
4369                            RETFLAG.store(saved_retflag, Relaxed);
4370                            BREAKS.store(saved_breaks, Relaxed);
4371                            EXIT_PENDING.store(saved_exit_pending, Relaxed);
4372                            EXIT_VAL.store(saved_exit_val, Relaxed);
4373                            SHELL_EXITING.store(saved_shell_exiting, Relaxed);
4374                        }
4375                        if let Some(dir) = loaded_dir.as_deref() {
4376                            restore_loaddir(name, dir, abspath_used, ksh_style);
4377                        }
4378                        if !self.functions_compiled.contains_key(name) {
4379                            // c:Src/exec.c:5742-5745 — ksh-style load ran
4380                            // the file (`execode`, "evalautofunc") but it
4381                            // didn't define NAME:
4382                            //   `zwarn("%s: function not defined by file", n);`
4383                            // The wrap/strip zsh-style paths always define
4384                            // NAME, so reaching here means the verbatim run
4385                            // failed to — same condition as C.
4386                            crate::ported::utils::zwarn(&format!(
4387                                "{}: function not defined by file",
4388                                name
4389                            ));
4390                            return Some(1);
4391                        }
4392                    } else if load_rc != 0 {
4393                        // c:Src/exec.c:5713-5719 / 5635-5644 —
4394                        // `execautofn`'s `if (!loadautofn(...)) return 1`
4395                        // propagates the loadautofn failure as the
4396                        // command's exit status. zshrs's previous
4397                        // path returned None here, falling through to
4398                        // execute_external which emitted a SECOND
4399                        // diagnostic (`command not found: NAME`) on
4400                        // top of loadautofn's `function definition
4401                        // file not found`. Mirror C: when load failed
4402                        // AND the stub still has no body, surface
4403                        // status=1 so the caller does NOT fall back
4404                        // to PATH search.
4405                        return Some(1);
4406                    }
4407                } else if let Some(body) = stub.body.clone() {
4408                    // c:Src/Modules/parameter.c::setpmfunction — function
4409                    // registered via `functions[name]=body` lives in
4410                    // shfunctab with `body` set but `functions_compiled`
4411                    // empty (the canonical port stores the parsed eprog,
4412                    // not a fusevm Chunk). Lazy-compile here by feeding
4413                    // the body through the standard funcdef pipeline so
4414                    // the next CallFunction op finds the chunk.
4415                    //
4416                    // c:Src/builtin.c:3180 (eval_autoload) — `autoload +X NAME`
4417                    // reaches this arm as well: it loads the body EAGERLY via
4418                    // `loadautofn`, which sets `body` + `filename`/PM_LOADDIR
4419                    // and clears PM_UNDEFINED but leaves no compiled chunk, so
4420                    // the first CALL never sees the PM_UNDEFINED arm above.
4421                    // Restore the load directory after re-registration exactly
4422                    // as that arm does — otherwise `autoload +X /abs/dir/NAME`
4423                    // dropped PM_LOADDIR|PM_ABSPATH_USED before the body ran
4424                    // and a sibling `autoload -Uz SIB` inside it failed with
4425                    // "function definition file not found" where zsh loads
4426                    // /abs/dir/SIB. The `functions[name]=body` case has no
4427                    // PM_LOADDIR, so the restore is skipped for it.
4428                    let loaded_dir = ((stub.node.flags as u32 & crate::ported::zsh_h::PM_LOADDIR)
4429                        != 0)
4430                        .then(|| stub.filename.clone())
4431                        .flatten();
4432                    let abspath_used =
4433                        (stub.node.flags as u32 & crate::ported::zsh_h::PM_ABSPATH_USED) != 0;
4434                    let ksh_style = autoload_is_ksh_style(name); // c:5781 (pre-registration)
4435                    _autoload_file_guard = Some(AutoloadFileGuard::enter(name));
4436                    let (registered, from_wordcode) = autoload_register_source(name, &body);
4437                    {
4438                        // c:Src/exec.c:5735-5760 — C INSTALLS the parsed Eprog
4439                        // as the function body; it executes nothing at load
4440                        // time, so the global `lineno` still holds the line the
4441                        // CALL was made on when doshfunc records
4442                        // `funcsave->fstack.lineno = lineno` (c:6013). zshrs
4443                        // installs the body by RUNNING `name() { … }` through
4444                        // the pipeline, which walks the counter to the file's
4445                        // last line — so the very first call of an autoloaded
4446                        // function reported its caller's line as that instead:
4447                        // `$functrace` read `script.zsh:1` where zsh reads
4448                        // `script.zsh:4`, and inside completion `_subscript:0`
4449                        // where zsh reads `_subscript:125`. Every LATER call
4450                        // was already correct, because the load only happens
4451                        // once.
4452                        let caller_lineno = crate::ported::lex::lineno();
4453                        // c:5384-5388 assigns `shf->lineno` only when a
4454                        // `name() { … }` STATEMENT defines the function. An
4455                        // autoload stub's Shfunc keeps the 0 it was created
4456                        // with, and loadautofn replaces only `funcdef`, so zsh
4457                        // reports `funcsourcetrace` as `<file>:0`. Running a
4458                        // synthesized wrapper here stamps line 1 instead, so
4459                        // put the stub's value back when the wrapper was ours.
4460                        let synthesized = registered != body;
4461                        let _ = self.run_autoload_definition(
4462                            name,
4463                            &registered,
4464                            ksh_style,
4465                            from_wordcode,
4466                        );
4467                        crate::ported::lex::set_lineno(caller_lineno);
4468                        if synthesized {
4469                            // c:5384-5388 sets `shf->lineno` only where a
4470                            // `name() { … }` STATEMENT defines the function; an
4471                            // autoload stub keeps the 0 it was created with and
4472                            // loadautofn replaces only `funcdef`, so
4473                            // `funcsourcetrace` reads `<file>:0`. Executing our
4474                            // synthesized wrapper records a line base of 1
4475                            // instead. -1 marks "autoload-installed" so the
4476                            // call-time clamp below can tell that apart from an
4477                            // INLINE `f() { … }`, whose base underflows to 0 but
4478                            // whose def line really is >= 1.
4479                            self.function_line_base.insert(name.to_string(), -1);
4480                        }
4481                    }
4482                    if let Some(dir) = loaded_dir.as_deref() {
4483                        restore_loaddir(name, dir, abspath_used, ksh_style);
4484                    }
4485                }
4486            }
4487        }
4488        // When a Rust port is registered, skip the fusevm Chunk
4489        // lookup entirely — the body_runner closure below will run
4490        // the Rust fn pointer directly. Otherwise require a compiled
4491        // chunk for the autoloaded body.
4492        let chunk_opt = if direct_rust_fn.is_some() || has_plugin_override {
4493            None
4494        } else {
4495            Some(self.functions_compiled.get(name).cloned()?)
4496        };
4497
4498        // zshrs-specific bookkeeping that doshfunc doesn't own:
4499        // - prompt_funcstack (PS4 trace) push/pop
4500        // - local_scope_depth FUNCNEST guard
4501        //
4502        // c:Src/exec.c::funcnest_check — C zsh allows FUNCNEST=500 by
4503        // default. zshrs's per-call stack usage is heavier (vm_helper
4504        // state, fusevm closures, parse buffers), so on the default 8MB
4505        // stack a deep recursion overflowed around depth ~80-120 and
4506        // crashed. That is now fixed at the source: the shell runs on a
4507        // 512MB-stack thread (see bins/zshrs.rs::main), which comfortably
4508        // fits FUNCNEST (500) nested heavy frames. So the effective limit
4509        // is the user's FUNCNEST (default 500), matching zsh — no premature
4510        // clamp — with a generous hard ceiling as a last-resort backstop
4511        // that stays well under the big stack's capacity. Bug #519 (the
4512        // crash) / #643 (the false-positive clamp at 80 that broke
4513        // legitimately deep recursion). The authoritative FUNCNEST error
4514        // is also enforced in doshfunc (exec.rs) on the FS_FUNC depth.
4515        const FUNCNEST_RUST_CEILING: usize = 6000;
4516        let funcnest_user: usize = self
4517            .scalar("FUNCNEST")
4518            .and_then(|s| s.parse().ok())
4519            .unwrap_or(500);
4520        let funcnest_limit = funcnest_user.min(FUNCNEST_RUST_CEILING);
4521        if self.local_scope_depth >= funcnest_limit {
4522            // c:Src/exec.c:6060-6063 —
4523            //     zerr("maximum nested function level reached; increase FUNCNEST?");
4524            //     lastval = 1;
4525            //     goto undoshfunc;
4526            // `zerr` is what makes this FATAL: it raises errflag, so the
4527            // enclosing list stops and a non-interactive shell exits.
4528            // zsh 5.9:
4529            //     zsh -fc 'FUNCNEST=2; f() { f; }; f; printf after'
4530            // prints only the diagnostic and exits 1 — no `after`. bash
4531            // agrees ("Function invocations that exceed this nesting level
4532            // cause the current command to abort", bash(1) FUNCNEST), and
4533            // its own message is likewise followed by exit 1.
4534            //
4535            // This guard printed with a bare `eprintln!` and returned 1
4536            // WITHOUT raising errflag, so the runaway recursion stopped but
4537            // the script kept running — `printf after` ran and the shell
4538            // exited 0. The ported check in exec.rs::doshfunc already does
4539            // the C trio, but this one fires first (it is the zshrs-only
4540            // stack backstop, evaluated before dispatch reaches doshfunc),
4541            // so it has to carry the same side effects.
4542            //
4543            // The message is written here rather than through `zerr`
4544            // because C's prefix is the *function* name — `scriptname` is
4545            // the running function inside doshfunc — and this guard runs
4546            // before that switch; going through zerr would print the outer
4547            // script name instead. Byte-compared against zsh 5.9.
4548            // c:Src/utils.c zerr → zerrmsg prints `scriptname:lineno: msg`
4549            // whenever `scriptname` is set (which it is here: c:5963
4550            // `scriptname = dupstring(name)` runs BEFORE the c:6060 check).
4551            // The `:lineno` half was missing, so
4552            //   ( FUNCNEST=0; fn() { true; }; fn )
4553            // printed `fn: maximum …` where zsh prints `fn:4: maximum …`
4554            // (C04funcdef:46).
4555            eprintln!(
4556                "{}:{}: maximum nested function level reached; increase FUNCNEST?",
4557                name,
4558                crate::ported::lex::lineno()
4559            );
4560            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed); // c:6061 (zerr)
4561            crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:6062
4562            return Some(1);
4563        }
4564        let display_name = if name.starts_with("_zshrs_anon_") {
4565            "(anon)".to_string()
4566        } else {
4567            name.to_string()
4568        };
4569        let line_base = self.function_line_base.get(name).copied().unwrap_or(0);
4570        let def_file = self.function_def_file.get(name).cloned().flatten();
4571        self.prompt_funcstack
4572            .push((name.to_string(), line_base, def_file));
4573        self.local_scope_depth += 1;
4574
4575        // Synthetic shfunc for doshfunc — carries the name + def-file
4576        // info so funcstack push gets a proper filename. funcdef/body
4577        // stay None because the wordcode body is irrelevant on this
4578        // path (body_runner runs the fusevm Chunk directly).
4579        // c:Src/exec.c:5390-5410 — execfuncdef records the
4580        // current `scriptfilename` on the shfunc at definition
4581        // time so funcsourcetrace can show file:line of the
4582        // function's source. The function_def_file map stores
4583        // this; fall back to the live scriptfilename so dynamic
4584        // / non-`compile_funcdef`-routed definitions still get a
4585        // sensible filename. Without the fallback, the synth_shf
4586        // saw None and the funcstack push at exec.rs:5719
4587        // defaulted to an empty string, which the funcsourcetrace
4588        // getfn rendered as `:N` (or worse, picked up the
4589        // function name from a parallel field). Bug #515.
4590        // c:Src/exec.c:5620/5625 — the source file is `getshfuncfile(shf)`,
4591        // which reads the shfunc's own `filename` (authoritative: set by
4592        // execfuncdef for a normally-defined function, and by loadautofn — as
4593        // the fpath dir with PM_LOADDIR — for an AUTOLOADED one). Prefer it.
4594        // `function_def_file` is a zshrs-only side map that, for an autoloaded
4595        // function, was stamped with the OUTER `scriptfilename` ("zsh") at
4596        // compile time, not the fpath file — so it must NOT override
4597        // getshfuncfile. Consulting it second still covers functions whose
4598        // shfunc `filename` wasn't recorded (compile_funcdef-routed defs);
4599        // scriptfilename is the final fallback. Without getshfuncfile winning,
4600        // funcsourcetrace reported "zsh" for every autoloaded completer, which
4601        // broke `_git`: its first git-completion.bash search path is
4602        // `"$(dirname ${funcsourcetrace[1]%:*})"/git-completion.bash` — "zsh"
4603        // resolved to `./git-completion.bash`, found nothing, and
4604        // `. "$script"` errored (`_git:.:48: no such file or directory`).
4605        let synth_filename = crate::ported::hashtable::getshfuncfile(name)
4606            .or_else(|| self.function_def_file.get(name).cloned().flatten())
4607            .or_else(|| self.scriptfilename.clone());
4608        // c:Src/exec.c:5409 — `shf->lineno = lineno;` (def line).
4609        // `function_line_base[name]` carries compile_funcdef's
4610        // `lineno_offset = first_body_line - 1` — equals the def line
4611        // for multi-line `f() {\n body }` but underflows to 0 for
4612        // INLINE `f() { body }` (def and body share a line). zsh's
4613        // funcsourcetrace reports the def line as 1-based, so clamp
4614        // to >= 1 to handle the inline case without rebuilding
4615        // line tracking through the parser. Bug #396.
4616        let synth_lineno = {
4617            let base = self.function_line_base.get(name).copied().unwrap_or(0);
4618            if base < 0 {
4619                // Autoload-installed (see the -1 marker at the install
4620                // site): c:5384 never runs for it, so the def line is 0.
4621                0
4622            } else {
4623                std::cmp::max(1i64, base)
4624            }
4625        };
4626        // Carry the REAL function's attribute flags over from shfunctab.
4627        // `functions -t/-T/-W` store PM_TAGGED / PM_TAGGED_LOCAL /
4628        // PM_WARNNESTED on the shfunctab node (builtin.rs c:3719), and
4629        // doshfunc turns PM_TAGGED* into XTRACE for the duration of the call
4630        // (exec.c:5954-5960). Hardcoding 0 here severed that link: the flags
4631        // were parsed and stored correctly, but the synthesized shfunc handed
4632        // to doshfunc always claimed "no attributes", so `functions -t f; f`
4633        // ran silently while `setopt xtrace` (a global option, not routed
4634        // through this struct) traced normally. Bug #1058.
4635        // The shfunctab key is the REGISTRATION name, which for an
4636        // anonymous function is the generated `_zshrs_anon_*` (only the
4637        // DISPLAY name is `(anon)` — c:Src/exec.c:5492 sets
4638        // `shf->node.nam = ANONYMOUS_FUNCTION_NAME` on the same struct
4639        // that already carries `tracing_flags` from c:5437). Looking the
4640        // flags up under the display name missed every anonymous
4641        // function, so `function -T { … }` ran untraced (E02xtrace:7,9).
4642        let synth_flags = crate::ported::hashtable::shfunctab_lock()
4643            .read()
4644            .ok()
4645            .and_then(|t| {
4646                t.get(name)
4647                    .or_else(|| t.get(display_name.as_str()))
4648                    .map(|s| s.node.flags)
4649            })
4650            .unwrap_or(0);
4651        // c:Src/exec.c:5978 — `if (sticky_emulation_differs(shfunc->sticky))`
4652        // reads the STORED per-function sticky snapshot that
4653        // `shfunc_set_sticky` (c:5402) stamped at definition time. The
4654        // synthesized shfunc hardcoded `sticky: None`, so a function
4655        // defined under `emulate sh -c '...'` never re-entered its
4656        // emulation when called (B07emulate.ztst:6,7,8,12,13,14).
4657        // Carry it over from shfunctab like `synth_flags` above.
4658        let synth_sticky = crate::ported::hashtable::shfunctab_lock()
4659            .read()
4660            .ok()
4661            .and_then(|t| {
4662                t.get(name)
4663                    .or_else(|| t.get(display_name.as_str()))
4664                    .and_then(|s| {
4665                        s.sticky
4666                            .as_deref()
4667                            .map(|b| crate::ported::exec::sticky_emulation_dup(b, 0))
4668                    })
4669            });
4670        let mut synth_shf = crate::ported::zsh_h::shfunc {
4671            node: crate::ported::zsh_h::hashnode {
4672                next: None,
4673                nam: display_name.clone(),
4674                flags: synth_flags,
4675            },
4676            filename: synth_filename,
4677            lineno: synth_lineno,
4678            funcdef: None,
4679            redir: None,
4680            sticky: synth_sticky,
4681            body: None,
4682            redir_text: None,
4683        };
4684        // doshargs: C convention — argv[0] = function name (for
4685        // FUNCTIONARGZERO `$0`), argv[1..] = real positional args.
4686        let mut doshargs: Vec<String> = vec![display_name.clone()];
4687        doshargs.extend(args.iter().cloned());
4688
4689        // Seed `$?` with the parent's last status — C zsh's
4690        // doshfunc inherits lastval automatically because it's a
4691        // process-global; the fusevm VM creates a fresh
4692        // `vm.last_status = 0` per call, so we mirror the inherit
4693        // explicitly. Without this, a function reading `$?` BEFORE
4694        // running any command sees 0 instead of the caller's status.
4695        let seed_status = self.last_status();
4696        let body_args: Vec<String> = args.to_vec();
4697        let name_owned = name.to_string();
4698        let body_runner = move || -> i32 {
4699            // c:Src/exec.c:5626 — `execode(shf->funcdef, 1, 0,
4700            // "loadautofunc")`. On the call that autoloaded the function,
4701            // C runs its body one `zsh_eval_context` frame deeper than
4702            // runshfunc's "shfunc", which is why zsh reports
4703            // `shfunc:loadautofunc:…` down a chain of freshly autoloaded
4704            // completers where zshrs reported a flat `shfunc:shfunc:…`.
4705            let _load_ctx =
4706                did_autoload.then(|| crate::ported::exec::EvalContextFrame::push("loadautofunc"));
4707            // Branch: plugin override (ABI v4) → built-in Rust port →
4708            // fusevm Chunk (autoloaded shell body). All run INSIDE
4709            // doshfunc's scope so prologue/epilogue applies identically.
4710            if let Some(rc) =
4711                crate::extensions::plugin_host::dispatch_compfn(&name_owned, &body_args)
4712            {
4713                return rc;
4714            }
4715            if let Some(f) = direct_rust_fn {
4716                return f(&body_args);
4717            }
4718            let chunk = chunk_opt
4719                .as_ref()
4720                .expect("chunk_opt must be Some when direct_rust_fn is None");
4721            crate::fusevm_disasm::maybe_print_stdout(
4722                &format!(
4723                    "function:{}",
4724                    body_args.first().map(|s| s.as_str()).unwrap_or("")
4725                ),
4726                chunk,
4727            );
4728            let mut vm = crate::vm_pool::acquire(chunk.clone());
4729            vm.last_status = seed_status;
4730            let _ = vm.run();
4731            vm.last_status
4732        };
4733
4734        // Enter executor context BEFORE doshfunc so the body_runner's
4735        // VM builtins can `with_executor(...)` to reach this state.
4736        // c:Src/exec.c:5572-5585 — execshfunc swaps in a FRESH, EMPTY cmdstack
4737        // for the duration of a shell-function call and restores the caller's
4738        // afterwards:
4739        //     ocs = cmdstack; ocsp = cmdsp;
4740        //     cmdstack = zalloc(CMDSTACKSZ); cmdsp = 0;
4741        //     doshfunc(shf, args, 0);
4742        //     free(cmdstack); cmdstack = ocs; cmdsp = ocsp;
4743        // The cmdstack is what `%_` renders, so without the swap a function
4744        // body inherits the CALLER's parser context: `f(){ print -rP "[%_]" }`
4745        // printed `[cursh]` inside `{ f }`, `[then]` inside an `if`, `[for]`
4746        // inside a loop and `[case]` inside a case arm, where zsh prints `[]`
4747        // in every one. Most visible under xtrace, whose default PS4 ends in
4748        // `%_`, so every traced line inside a called function carried a stale
4749        // field. `( f )` was already correct only because the subshell forks.
4750        // Bug #1059.
4751        let saved_cmdstack: Vec<u8> =
4752            crate::ported::prompt::CMDSTACK.with(|s| std::mem::take(&mut *s.borrow_mut()));
4753        // c:Src/exec.c:4364 — a `return` out of a redirected compound
4754        // command still runs `fixfds(save)`. See
4755        // `unwind_redirect_scopes_to`.
4756        let redir_depth = self.redirect_scope_stack.len();
4757        let _ctx = ExecutorContext::enter(self);
4758        let status = crate::ported::exec::doshfunc(&mut synth_shf, doshargs, false, body_runner);
4759        drop(_ctx);
4760        self.unwind_redirect_scopes_to(redir_depth);
4761        crate::ported::prompt::CMDSTACK.with(|s| *s.borrow_mut() = saved_cmdstack);
4762
4763        self.prompt_funcstack.pop();
4764        self.local_scope_depth -= 1;
4765
4766        // Honor explicit `return N` from inside the function body.
4767        if let Some(ret) = self.returning.take() {
4768            self.set_last_status(ret);
4769            Some(ret)
4770        } else {
4771            self.set_last_status(status);
4772            Some(status)
4773        }
4774    }
4775
4776    pub(crate) fn execute_external(
4777        &mut self,
4778        cmd: &str,
4779        args: &[String],
4780        redirects: &[Redirect],
4781    ) -> Result<i32, String> {
4782        // FORK_EVENTS is bumped at the real spawn site inside
4783        // execute_external_bg — this entry is only ONE of several
4784        // callers of that spawn (the common static-head command path
4785        // calls execute_external_bg directly), so counting here would
4786        // miss `time sleep 0` while double-counting this path.
4787        self.execute_external_bg(cmd, args, redirects, false)
4788    }
4789
4790    fn execute_external_bg(
4791        &mut self,
4792        cmd: &str,
4793        args: &[String],
4794        _redirects: &[Redirect],
4795        background: bool,
4796    ) -> Result<i32, String> {
4797        tracing::trace!(cmd, bg = background, "exec external");
4798        // c:Src/exec.c:3545-3547 — `setunderscore((args && nonempty(args)) ?
4799        // ((char *) getdata(lastnode(args))) : "")`. execcmd_exec sets `$_`
4800        // to the last word of the command it is about to run, in the PARENT,
4801        // before any builtin/plugin resolution or fork — so `cat /dev/null;
4802        // print $_` reports `/dev/null` and a pipeline's stages each leave
4803        // their own last word behind. This is the single funnel every
4804        // external spawn reaches (the static-head command path calls it
4805        // directly and bypasses ZshrsHost::exec / host_exec_external), so
4806        // the write belongs here. C's `args` list carries argv[0], hence the
4807        // fallback to `cmd` for a bare command.
4808        {
4809            let last = args.last().cloned().unwrap_or_else(|| cmd.to_string());
4810            crate::ported::params::set_zunderscore(std::slice::from_ref(&last));
4811            // c:3546
4812        }
4813        // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
4814        // Native (Rust) plugin builtins registered via `zmodload -R`
4815        // (src/extensions/plugin_host.rs). fusevm compiles unknown
4816        // names into external execution, so a plugin command arrives
4817        // here as an "external". Resolve it BEFORE the PATH-unset guard
4818        // and the process spawn — plugin builtins are in-process and
4819        // need no PATH. This is the analog of C's `resolvebuiltin`
4820        // slot (Src/exec.c:2700), which likewise runs before the fork.
4821        // Bare names only: a `/`-qualified token is always a filesystem
4822        // path, never a plugin command name. Runs synchronously even
4823        // when backgrounded — zshrs is non-forking and an in-process
4824        // builtin has nothing to background.
4825        if !cmd.contains('/') {
4826            if let Some(status) = crate::plugin_host::dispatch(cmd, args) {
4827                return Ok(status);
4828            }
4829        }
4830        // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
4831        // Host-registered native commands (`extensions/native_cmds.rs`) — the
4832        // sibling runtimes a fat binary links into the shell's address space:
4833        // `git` (zvcs), `arb` (arblang) and `stryke` (strykelang) in the
4834        // zshrs-native build. Same slot and same reason as the plugin-builtin
4835        // dispatch directly above: the compiler has never heard of these names,
4836        // so it lowered them to external execution and they arrive here — this
4837        // is where they must be caught, BEFORE the PATH guard and before the
4838        // spawn, because an in-process builtin needs no PATH and no process.
4839        //
4840        // Two escape hatches to the binary on disk stay open, and both are
4841        // checked here. A `/`-qualified token (`/usr/bin/git`) is a filesystem
4842        // path the user named, never a registry key. And `command git`
4843        // explicitly asks past the in-process one — the `command` handler
4844        // raises `native_cmds::force_external` around this call, exactly as
4845        // `command cat` already escapes the coreutils shadow.
4846        //
4847        // The registry's contract is full argv (argv[0] = the name as
4848        // invoked), which zvcs reads for its `git-<verb>` dashed form.
4849        //
4850        // Empty in the thin shell: one map lookup that always misses.
4851        if !cmd.contains('/')
4852            && !crate::native_cmds::is_forced_external()
4853            && crate::native_cmds::is_enabled(cmd)
4854        {
4855            let full: Vec<String> = std::iter::once(cmd.to_string())
4856                .chain(args.iter().cloned())
4857                .collect();
4858            if let Some(status) = crate::native_cmds::dispatch(cmd, &full) {
4859                return Ok(status);
4860            }
4861        }
4862        // c:Src/exec.c:824-876 — when arg0 has no `/`, C zsh requires
4863        // a PATH search. With PATH unset, the search yields no hit
4864        // and C emits `command not found: <cmd>`. Rust's
4865        // `Command::new(name)` delegates to libc `execvp`, which on
4866        // many platforms falls back to a built-in default PATH when
4867        // the env entry is missing — so `unset PATH; ls` still finds
4868        // `/bin/ls` and runs it, breaking the security boundary the
4869        // unset is supposed to establish (#416). Gate explicitly:
4870        // when cmd is a bare name (no `/`) and zshrs's own PATH
4871        // param is unset OR empty, emit the canonical
4872        // "command not found" diagnostic and return 127 BEFORE
4873        // touching libc.
4874        if !cmd.contains('/') {
4875            let path_set_and_nonempty = crate::ported::params::getsparam("PATH")
4876                .map(|p| !p.is_empty())
4877                .unwrap_or(false);
4878            if !path_set_and_nonempty {
4879                let sn =
4880                    crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
4881                // c:Src/exec.c:811 `zerr("command not found: %s", arg0)`
4882                // — the diagnostic carries the CURRENT line, not a
4883                // hardcoded 1. `lineno()` is the same counter zwarning
4884                // (utils.rs:179) uses and is live during VM execution
4885                // (verified: read-only / div-by-zero errors already
4886                // report the right line). Emitted directly (not via
4887                // zerr) to avoid setting errflag — command-not-found is
4888                // non-fatal and the script must continue.
4889                // Inline Rust FFI export: needs no PATH, so run it here rather
4890                // than reporting not-found when PATH is unset/empty.
4891                if let Some(rc) = self.try_registered_ffi_command(cmd, args) {
4892                    return Ok(rc);
4893                }
4894                eprintln!("{}: command not found: {}", zerr_prefix(&sn), cmd);
4895                return Ok(127);
4896            }
4897        }
4898        // c:Src/exec.c:2700-2724 resolvebuiltin — names registered via
4899        // `zmodload -ab MOD NAME` resolve through builtintab BEFORE
4900        // PATH search in C (execcmd's builtin lookup precedes the
4901        // external fork). Names the compiler didn't know as builtins
4902        // land here; consult the autoload ledger, load the module,
4903        // and re-dispatch through the builtin chokepoint. Without
4904        // this, `zmodload -ab zsh/bogus mybltn; mybltn` skipped the
4905        // C autoload-fire entirely (PATH miss → 127 instead of the
4906        // load_module diagnostic → 1).
4907        if !cmd.contains('/') {
4908            if let Some(rc) = crate::ported::module::resolvebuiltin(cmd) {
4909                if rc != 0 {
4910                    return Ok(1);
4911                }
4912                return Ok(crate::fusevm_bridge::dispatch_builtin_raw(
4913                    cmd,
4914                    args.to_vec(),
4915                ));
4916            }
4917        }
4918        // c:Src/exec.c:531-534 — `execve(pth, argv, newenvp); if ((eno =
4919        // errno) == ENOEXEC || eno == ENOENT) { … }`. The kernel is the only
4920        // thing that understands `#!`, and when it REFUSES the file — ENOEXEC
4921        // (no valid magic and no shebang) or ENOENT (a `#!` line naming an
4922        // interpreter that does not exist as spelled, e.g. `#!sh`) — zsh reads
4923        // the shebang itself and re-execs with the interpreter it names,
4924        // falling back to `/bin/sh` for a shebang-less script. These three
4925        // hold what C's second `execve` would receive: `spawn_prog` is c:566's
4926        // `pprog` (the RESOLVED program), `spawn_arg0` is c:564's `ptr2` (the
4927        // interpreter NAME as written on the `#!` line), `spawn_args` the
4928        // rest. `Command::new` conflates program and argv[0], hence the
4929        // explicit `arg0`. `cmd`/`args` stay untouched: every diagnostic and
4930        // hook below reports the command the user actually typed, exactly as
4931        // C reports `arg0` (c:797/811).
4932        let mut spawn_prog: String = cmd.to_string();
4933        let mut spawn_arg0: String = cmd.to_string();
4934        let mut spawn_args: Vec<String> = args.to_vec();
4935        // C recurses through zexecve for each rewrite; the loop is that
4936        // recursion, re-driving the spawn with the rewritten argv.
4937        loop {
4938            let mut command = Command::new(&spawn_prog);
4939            {
4940                use std::os::unix::process::CommandExt as _;
4941                command.arg0(&spawn_arg0);
4942            }
4943            // c:Src/exec.c execute — C unmetafies every arg before the
4944            // execve (the child must see raw bytes, not the shell's
4945            // internal Meta encoding). Args carrying Meta-char pairs
4946            // (from `$'\xff'` etc., vm_helper::meta_encode_byte) are
4947            // decoded to raw bytes via OsStr; plain args pass through
4948            // unchanged. Bug #127.
4949            for a in &spawn_args {
4950                if a.contains('\u{83}') {
4951                    use std::os::unix::ffi::OsStrExt as _;
4952                    command.arg(std::ffi::OsStr::from_bytes(&unmetafy_str(a)));
4953                } else {
4954                    command.arg(a);
4955                }
4956            }
4957
4958            // Redirect handling lives in fusevm's WithRedirectsBegin/End
4959            // ops at compile time; `_redirects` arrives empty here.
4960
4961            // c:Src/jobs.c — `time` reports only on JOBS (forked work). This
4962            // is the single chokepoint where an external process is actually
4963            // spawned (both fg and bg, all callers), AFTER the
4964            // command-not-found and resolvebuiltin early-returns above — so
4965            // counting here makes BUILTIN_TIME_SUBLIST report `time sleep 0`
4966            // / `time /usr/bin/true` (external → fork) while staying silent
4967            // for `time true` (builtin, never reaches this point). The
4968            // subshell entry counts separately (fusevm_bridge.rs:9573).
4969            FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4970
4971            return if background {
4972                match command.spawn() {
4973                    Ok(child) => {
4974                        let pid = child.id();
4975                        let cmd_str = format!("{} {}", cmd, args.join(" "));
4976                        let job_id = self.jobs.add_job(child, cmd_str, JobState::Running);
4977                        println!("[{}] {}", job_id, pid);
4978                        Ok(0)
4979                    }
4980                    Err(e) => {
4981                        // c:534-627 — the kernel refused the file; retry with
4982                        // the interpreter the `#!` line names (or `/bin/sh` for a
4983                        // shebang-less script). See zexecve_recover.
4984                        let eno = e.raw_os_error().unwrap_or(0);
4985                        if eno == libc::ENOEXEC || eno == libc::ENOENT {
4986                            // c:Src/exec.c:815 — C hands `zexecve` the RESOLVED
4987                            // candidate `pbuf` from its own `$path` walk, never the
4988                            // bare word; and c:544 `*argv = pth;` then puts that
4989                            // resolved path into argv[0] for the interpreter. Here
4990                            // libc did the PATH search inside the spawn, so redo it
4991                            // with `pathprog` (utils.rs:798) before probing —
4992                            // otherwise a `#!` script found on `$path` was handed to
4993                            // its interpreter as the bare name and `#!echo foo`
4994                            // printed `foo tstcmd-arg` instead of
4995                            // `foo <dir>/tstcmd-arg`.
4996                            let probe_pth = if spawn_prog.contains('/') {
4997                                spawn_prog.clone()
4998                            } else {
4999                                match crate::ported::utils::pathprog(&spawn_prog) {
5000                                    Some(p) => p.display().to_string(), // c:815
5001                                    None => spawn_prog.clone(),
5002                                }
5003                            };
5004                            let mut cargv: Vec<String> = Vec::with_capacity(spawn_args.len() + 1);
5005                            cargv.push(spawn_arg0.clone());
5006                            cargv.extend_from_slice(&spawn_args);
5007                            if let Ok((prog, newargv)) = zexecve_recover(&probe_pth, &cargv, eno) {
5008                                spawn_arg0 =
5009                                    newargv.first().cloned().unwrap_or_else(|| prog.clone());
5010                                spawn_args =
5011                                    newargv.get(1..).map(|v| v.to_vec()).unwrap_or_default();
5012                                spawn_prog = prog;
5013                                continue;
5014                            }
5015                        }
5016                        let sn = crate::ported::utils::scriptname_get()
5017                            .unwrap_or_else(|| "zshrs".to_string());
5018                        if e.kind() == io::ErrorKind::NotFound {
5019                            // Inline Rust FFI export run in the background: an
5020                            // in-process FFI call has nothing to background, so run
5021                            // it synchronously (mirrors the plugin-builtin path).
5022                            if let Some(rc) = self.try_registered_ffi_command(cmd, args) {
5023                                return Ok(rc);
5024                            }
5025                            // zsh: absolute paths emit "no such file or
5026                            // directory" (the OS error, since the path was
5027                            // tried directly), not "command not found"
5028                            // (which implies PATH search).
5029                            // c:Src/exec.c:871-876 — `if (eno) zerr("%e: %s", eno, arg0);
5030                            // else … zerr("command not found: %s", arg0);`. `eno` is set
5031                            // by an execve that actually ran, and zsh runs execve directly
5032                            // for ANY arg0 containing a slash (no PATH search), so
5033                            // `./foo` and `dir/foo` report the errno, not "command not
5034                            // found". Testing only for a LEADING slash mis-reported the
5035                            // relative forms:
5036                            //   ./nonexistent_script
5037                            //   zsh  : zsh:1: no such file or directory: ./nonexistent_script
5038                            //   zshrs: zsh:1: command not found: ./nonexistent_script
5039                            if cmd.contains('/') {
5040                                eprintln!(
5041                                    "{}: no such file or directory: {}",
5042                                    zerr_prefix(&sn),
5043                                    cmd
5044                                );
5045                            } else {
5046                                eprintln!("{}: command not found: {}", zerr_prefix(&sn), cmd);
5047                            }
5048                            Ok(127)
5049                        } else {
5050                            Err(format!("{}: {}: {}", sn, cmd, e))
5051                        }
5052                    }
5053                }
5054            } else {
5055                // Queue signals across the wait so zshrs's SIGCHLD reaper
5056                // (waitpid(-1) in wait_for_processes, delivered on any
5057                // thread) can't reap this child before Command::status()
5058                // does — otherwise status() fails with ECHILD ("No child
5059                // processes"). See ForegroundWaitGuard in fusevm_bridge.
5060                let status_result = {
5061                    let _wait_guard = crate::fusevm_bridge::ForegroundWaitGuard::enter();
5062                    command.status()
5063                };
5064                match status_result {
5065                    Ok(status) => Ok(status.code().unwrap_or(1)),
5066                    Err(e) => {
5067                        // c:534-627 — the kernel refused the file; retry with
5068                        // the interpreter the `#!` line names (or `/bin/sh` for a
5069                        // shebang-less script). See zexecve_recover.
5070                        let eno = e.raw_os_error().unwrap_or(0);
5071                        if eno == libc::ENOEXEC || eno == libc::ENOENT {
5072                            // c:Src/exec.c:815 — C hands `zexecve` the RESOLVED
5073                            // candidate `pbuf` from its own `$path` walk, never the
5074                            // bare word; and c:544 `*argv = pth;` then puts that
5075                            // resolved path into argv[0] for the interpreter. Here
5076                            // libc did the PATH search inside the spawn, so redo it
5077                            // with `pathprog` (utils.rs:798) before probing —
5078                            // otherwise a `#!` script found on `$path` was handed to
5079                            // its interpreter as the bare name and `#!echo foo`
5080                            // printed `foo tstcmd-arg` instead of
5081                            // `foo <dir>/tstcmd-arg`.
5082                            let probe_pth = if spawn_prog.contains('/') {
5083                                spawn_prog.clone()
5084                            } else {
5085                                match crate::ported::utils::pathprog(&spawn_prog) {
5086                                    Some(p) => p.display().to_string(), // c:815
5087                                    None => spawn_prog.clone(),
5088                                }
5089                            };
5090                            let mut cargv: Vec<String> = Vec::with_capacity(spawn_args.len() + 1);
5091                            cargv.push(spawn_arg0.clone());
5092                            cargv.extend_from_slice(&spawn_args);
5093                            if let Ok((prog, newargv)) = zexecve_recover(&probe_pth, &cargv, eno) {
5094                                spawn_arg0 =
5095                                    newargv.first().cloned().unwrap_or_else(|| prog.clone());
5096                                spawn_args =
5097                                    newargv.get(1..).map(|v| v.to_vec()).unwrap_or_default();
5098                                spawn_prog = prog;
5099                                continue;
5100                            }
5101                        }
5102                        // Use scriptname (the user-visible shell identifier
5103                        // — "zsh" in --zsh mode, "zshrs" otherwise) instead
5104                        // of a hardcoded "zshrs:" prefix so --zsh-mode
5105                        // diagnostics byte-match C zsh's stderr format.
5106                        let sn = crate::ported::utils::scriptname_get()
5107                            .unwrap_or_else(|| "zshrs".to_string());
5108                        if e.kind() == io::ErrorKind::NotFound {
5109                            // c:Src/exec.c — `command_not_found_handler` user
5110                            // hook: when a command lookup fails AND a function
5111                            // by that name is defined, call it with the cmd
5112                            // name + original args and return its rc instead
5113                            // of the default 127 + "command not found" error.
5114                            // Documented in zshmisc(1) under "Special
5115                            // Functions". Bug #426.
5116                            //
5117                            // The hook only fires for bare names (PATH search
5118                            // failed); absolute paths skip it and emit the
5119                            // OS-error path below — matches zsh behavior.
5120                            if !cmd.contains('/') {
5121                                let mut hook_args = Vec::with_capacity(args.len() + 1);
5122                                hook_args.push(cmd.to_string());
5123                                hook_args.extend_from_slice(args);
5124                                if let Some(rc) = self
5125                                    .dispatch_function_call("command_not_found_handler", &hook_args)
5126                                {
5127                                    return Ok(rc);
5128                                }
5129                            }
5130                            // Inline Rust FFI export: consulted after builtins,
5131                            // functions, PATH search, and command_not_found_handler
5132                            // have all missed — real commands keep priority.
5133                            if let Some(rc) = self.try_registered_ffi_command(cmd, args) {
5134                                return Ok(rc);
5135                            }
5136                            // zsh: absolute paths emit "no such file or
5137                            // directory" (the OS error, since the path was
5138                            // tried directly), not "command not found"
5139                            // (which implies PATH search).
5140                            // c:Src/exec.c:871-876 — `if (eno) zerr("%e: %s", eno, arg0);
5141                            // else … zerr("command not found: %s", arg0);`. `eno` is set
5142                            // by an execve that actually ran, and zsh runs execve directly
5143                            // for ANY arg0 containing a slash (no PATH search), so
5144                            // `./foo` and `dir/foo` report the errno, not "command not
5145                            // found". Testing only for a LEADING slash mis-reported the
5146                            // relative forms:
5147                            //   ./nonexistent_script
5148                            //   zsh  : zsh:1: no such file or directory: ./nonexistent_script
5149                            //   zshrs: zsh:1: command not found: ./nonexistent_script
5150                            if cmd.contains('/') {
5151                                eprintln!(
5152                                    "{}: no such file or directory: {}",
5153                                    zerr_prefix(&sn),
5154                                    cmd
5155                                );
5156                            } else {
5157                                eprintln!("{}: command not found: {}", zerr_prefix(&sn), cmd);
5158                            }
5159                            Ok(127)
5160                        } else if e.kind() == io::ErrorKind::PermissionDenied {
5161                            // zsh: non-executable file → "permission denied"
5162                            // on stderr and exit 126 (POSIX "command found
5163                            // but not executable").
5164                            eprintln!("{}: permission denied: {}", zerr_prefix(&sn), cmd);
5165                            Ok(126)
5166                        } else {
5167                            Err(format!("{}: {}: {}", sn, cmd, e))
5168                        }
5169                    }
5170                }
5171            };
5172        }
5173    }
5174    /// !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
5175    /// Inline Rust FFI fallback: when `cmd` names a function exported by a
5176    /// `rust { ... }` block (registered by the `__rust_compile` builtin) run it
5177    /// as a command. Consulted only when `cmd` resolved to nothing else — not a
5178    /// builtin, function, plugin, external on `$PATH`, or
5179    /// `command_not_found_handler` — so real commands keep priority. Positional
5180    /// args are marshalled as strings; fusevm coerces each to the export's
5181    /// signature (`i64` / `f64` / `*const c_char`). The return value is printed
5182    /// to stdout (the redirect-aware process fd 1) and the command exits 0.
5183    /// Bare names only — a `/`-qualified token is a filesystem path, never an
5184    /// FFI export. Returns `None` when `cmd` is not a registered export, so the
5185    /// caller emits its normal "command not found".
5186    fn try_registered_ffi_command(&self, cmd: &str, args: &[String]) -> Option<i32> {
5187        if cmd.contains('/') || !fusevm::ffi::is_registered(cmd) {
5188            return None;
5189        }
5190        let vals: Vec<fusevm::Value> = args.iter().map(|a| fusevm::Value::str(a.clone())).collect();
5191        match fusevm::ffi::try_call(cmd, &vals) {
5192            Some(Ok(v)) => {
5193                use std::io::Write as _;
5194                let mut out = io::stdout().lock();
5195                let _ = writeln!(out, "{}", v.to_str());
5196                let _ = out.flush();
5197                Some(0)
5198            }
5199            Some(Err(e)) => {
5200                eprintln!("zshrs: {e}");
5201                Some(1)
5202            }
5203            // Registered a moment ago but the entry vanished (registry race) —
5204            // treat as unresolved and let the caller report command-not-found.
5205            None => None,
5206        }
5207    }
5208
5209    /// Parse `cmd_str` via parse_init+parse and pull out the first Simple
5210    /// command's words, untokenized + variable-expanded, ready to spawn
5211    /// as argv. Used by process-substitution where we need raw argv to
5212    /// hand to `Command::new`. Returns empty vec if the cmd isn't a
5213    /// simple shape — pipelines / compound forms aren't process-sub
5214    /// friendly anyway.
5215    fn simple_cmd_words(&mut self, cmd_str: &str) -> Vec<String> {
5216        // Mirror Src/init.c-style errflag save/clear/check around the
5217        // parse. Process-sub argv extraction silently bails on syntax
5218        // errors (matches zsh's behavior when the inner command can't
5219        // be parsed).
5220        let saved_errflag = errflag.load(Ordering::Relaxed);
5221        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
5222        // Context-isolated nested parse (c:Src/exec.c:283 parse_string) —
5223        // same rationale as run_command_substitution: process-sub argv
5224        // extraction runs during execution and must not clobber the outer
5225        // single-event reader's lexer/input position.
5226        let prog = parse_isolated(cmd_str);
5227        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
5228        errflag.store(saved_errflag, Ordering::Relaxed);
5229        if parse_failed {
5230            return Vec::new();
5231        }
5232        let first = match prog.lists.first() {
5233            Some(l) => l,
5234            None => return Vec::new(),
5235        };
5236        let pipe = &first.sublist.pipe;
5237        if let crate::parse::ZshCommand::Simple(simple) = &pipe.cmd {
5238            simple
5239                .words
5240                .iter()
5241                .map(|w| {
5242                    // Untokenize then variable-expand — text-based
5243                    // word expansion for the spawned argv.
5244                    let untoked = crate::lex::untokenize(w);
5245                    singsub(&untoked)
5246                })
5247                .collect()
5248        } else {
5249            Vec::new()
5250        }
5251    }
5252    /// `run_command_substitution` — see implementation.
5253    /// The SQLite mirror, opened the first time anything asks for it.
5254    ///
5255    /// Returns `None` when no cache file exists yet (or it failed to open),
5256    /// which is the same answer the eager constructor produced.
5257    pub fn compsys_cache(&self) -> Option<&CompsysCache> {
5258        self.compsys_cache
5259            .get_or_init(|| {
5260                let cache_path = crate::compsys::cache::default_cache_path();
5261                if !cache_path.exists() {
5262                    tracing::debug!("compsys: no cache at {}", cache_path.display());
5263                    return None;
5264                }
5265                let db_size = fs::metadata(&cache_path).map(|m| m.len()).unwrap_or(0);
5266                match CompsysCache::open(&cache_path) {
5267                    Ok(c) => {
5268                        tracing::info!(
5269                            db_bytes = db_size,
5270                            path = %cache_path.display(),
5271                            "compsys: sqlite mirror opened (dbview/SQL inspection only; rkyv shards are the authoritative cache)"
5272                        );
5273                        Some(c)
5274                    }
5275                    Err(e) => {
5276                        tracing::warn!(error = %e, "compsys: failed to open cache");
5277                        None
5278                    }
5279                }
5280            })
5281            .as_ref()
5282    }
5283
5284    pub fn run_command_substitution(&mut self, cmd_str: &str) -> String {
5285        // c:Src/subst.c / Src/lex.c — the text inside `$(…)` is a FRESH
5286        // command line. The double quotes that may surround the substitution
5287        // apply to its RESULT, not to the words inside it: in `"$(f $x)"` the
5288        // `$x` is unquoted. `in_dq_context` is the runtime signal the
5289        // `${(flags)…}` bridges read for paramsubst's `qt` (c:1625), and it
5290        // stayed set for the whole body, so every flag-expansion inside a
5291        // DQ command substitution ran as if quoted.
5292        //
5293        // What that broke: `qt` suppresses RC_EXPAND_PARAM's word removal, so
5294        // under `setopt rcexpandparam` an EMPTY array kept a word instead of
5295        // deleting it (c:4327's `while ((x = *aval++))` emits nothing for an
5296        // empty array; the `!plan9` single-empty-word path at c:4261 is the
5297        // one that must NOT run):
5298        //     setopt rcexpandparam
5299        //     f() { declare -a x; print "n=$(set -- H ${(q)x}; print $#)" }
5300        //     f            # zsh: n=1, zshrs was n=2
5301        // Only the `"$(…)"` spelling was affected — unquoted `$(…)`,
5302        // backticks, and `v=$(…)` were all already correct, which is what
5303        // made it look like a quoting bug rather than an option bug.
5304        //
5305        // Bit through compsys: completion runs with rcexpandparam ON, and
5306        // `_git`'s __git_recent_commits passes `${(q)commit_opts}` to
5307        // `_call_program` inside `"$(…)"`. The stray empty word became a
5308        // bogus `''` argument to `git rev-list`, the command failed, and
5309        // `git checkout <TAB>` lost its whole recent-commits group.
5310        //
5311        // `SUBEXP_SCALAR_CTX` carries the same thing one level down — it is
5312        // what a NESTED expansion reads as `subexp_dq` (subst.rs:18873) to
5313        // learn that its OUTER `${…}` was quoted. A `$(…)` inside a quoted
5314        // outer expansion is still a fresh command line, so it has to be
5315        // cleared too:
5316        //     setopt rcexpandparam
5317        //     f() { declare -a co; local -a c
5318        //           c=("${(f)"$(cmd HEAD ${(q)co})"}") }
5319        // is `_git`'s exact shape, and the leaked context flipped c:4354's
5320        // `mark_empty`, keeping the empty element that plan9 must delete.
5321        let saved_dq = std::mem::replace(&mut self.in_dq_context, 0);
5322        let saved_subexp = crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.replace(0));
5323        let out = self.run_command_substitution_inner(cmd_str, false);
5324        crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.set(saved_subexp));
5325        self.in_dq_context = saved_dq;
5326        out
5327    }
5328
5329    /// ksh93 funsub `${ list; }` / mksh valsub `${| list; }` — capture the
5330    /// output of `cmd_str` WITHOUT the subshell isolation `$( … )` applies.
5331    ///
5332    /// ksh(1), Command Substitution: "${ command;} … the command is
5333    /// executed in the current shell environment", so an assignment or a
5334    /// `cd` inside survives:
5335    ///   `ksh -c 'x=0; y=${ x=5; print -n out; }; print "x=$x y=$y"'`
5336    ///   → `x=5 y=out`, where the same body in `$( … )` leaves `x` at 0.
5337    /// mksh behaves identically for both of its forms.
5338    ///
5339    /// Same capture machinery as `$( … )` — only the parent-state
5340    /// snapshot/restore is skipped, which is exactly the difference the
5341    /// two references document.
5342    ///
5343    /// !!! RUST-ONLY ENTRY POINT — zsh has no funsub/valsub !!!
5344    pub fn run_shared_state_substitution(&mut self, cmd_str: &str) -> String {
5345        let saved_dq = std::mem::replace(&mut self.in_dq_context, 0);
5346        let saved_subexp = crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.replace(0));
5347        let out = self.run_command_substitution_inner(cmd_str, true);
5348        crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.set(saved_subexp));
5349        self.in_dq_context = saved_dq;
5350        out
5351    }
5352
5353    /// `shared_state`: skip the parent-state snapshot/restore that makes
5354    /// `$( … )` a subshell. Only the ksh/mksh funsub-valsub entry point
5355    /// passes true.
5356    fn run_command_substitution_inner(&mut self, cmd_str: &str, shared_state: bool) -> String {
5357        // `$(< FILE)` — zsh shorthand for "read FILE contents". Faster
5358        // than spawning `cat`. The leading `<` (after stripping
5359        // whitespace) means "read this file". Trailing newline is
5360        // stripped (same as command-substitution).
5361        let trimmed = cmd_str.trim_start();
5362        // Only treat as `$(<file)` shorthand when the SINGLE leading `<`
5363        // is followed by a filename, not another `<`. `$(<<<"hi" cat)`
5364        // starts with `<<<` (here-string) and must go through the full
5365        // parse path, not the read-file shortcut.
5366        if let Some(rest) = trimmed.strip_prefix('<').filter(|s| !s.starts_with('<')) {
5367            let filename = rest.trim();
5368            // c:Src/lex.c — the `$(<file)` shortcut ONLY applies when
5369            // the body is exactly `<` + ONE word. Anything else (extra
5370            // args, redirects, semicolons, pipes) is a regular command
5371            // list and must go through the full parse path so `2>/dev/null`
5372            // / `>file` / `|cmd` / `; next` etc. work. Without this
5373            // gate, `$(< file 2>/dev/null)` treated `file 2>/dev/null`
5374            // as the literal filename and errored on the missing file.
5375            // Bug #615.
5376            let is_single_word = !filename.is_empty()
5377                && !filename.chars().any(|c| {
5378                    matches!(
5379                        c,
5380                        ' ' | '\t'
5381                            | '\n'
5382                            | ';'
5383                            | '&'
5384                            | '|'
5385                            | '<'
5386                            | '>'
5387                            | '('
5388                            | ')'
5389                            | '`'
5390                            | '"'
5391                            | '\''
5392                    )
5393                });
5394            if is_single_word {
5395                // Expand any leading $ / tilde in the filename so
5396                // `$(< $f)` and `$(< ~/x)` work.
5397                let resolved = if filename.contains('$') || filename.starts_with('~') {
5398                    singsub(filename)
5399                } else {
5400                    filename.to_string()
5401                };
5402                let resolved = resolved.to_string();
5403                match fs::read_to_string(&resolved) {
5404                    Ok(contents) => {
5405                        return contents.trim_end_matches('\n').to_string();
5406                    }
5407                    Err(_) => {
5408                        eprintln!("zshrs:1: no such file or directory: {}", resolved);
5409                        return String::new();
5410                    }
5411                }
5412            }
5413            // Multi-word / has-redirects → fall through to full parse.
5414        }
5415
5416        // Port of getoutput(char *cmd, int qt) from Src/exec.c. Parse and compile via
5417        // the lex+parse free ported + ZshCompiler pipeline, run on a
5418        // sub-VM with the host wired up. Stdout is captured through
5419        // an in-process pipe via dup2 — no fork. The sub-VM emits
5420        // Op::Exec for unknown command names, which forks/execs
5421        // through the host.
5422
5423        // Set up the stdout-capture pipe. We dup the original stdout
5424        // so post-run we can restore it; the write end is dup2'd onto
5425        // STDOUT_FILENO so all output the sub-VM emits (including from
5426        // forked children, which inherit fd 1) lands in the pipe.
5427        //
5428        // c:Src/exec.c:4753 — `if (mpipe(pipes) < 0)`. mpipe (c:5160)
5429        // moves BOTH pipe ends to fd >= 10 via movefd and marks them
5430        // FDT_INTERNAL. This is load-bearing: zsh's invariant is that
5431        // shell-internal fds never live below 10, so user redirections
5432        // like `exec 9>&-` (which close fd<10 unconditionally, no
5433        // FDT_INTERNAL guard — c:Src/exec.c:3856-3868) can never hit
5434        // them. A raw pipe() here landed the read end on fd 9 when
5435        // fresh-HOME init held fds 3-7, and A04redirect's %prep
5436        // `exec 9>&-` closed our own capture pipe → SIGPIPE killed
5437        // the whole shell.
5438        let (read_fd, write_fd) = {
5439            let mut fds = [0i32; 2];
5440            if crate::ported::exec::mpipe(&mut fds) < 0 {
5441                return String::new();
5442            }
5443            (fds[0], fds[1])
5444        };
5445        // c:Src/utils.c:1996 — `movefd(dup(fd))`: saved copies of the
5446        // user-visible fds are shell-internal, so they too must live
5447        // at fd >= 10 / FDT_INTERNAL.
5448        let saved_stdout = crate::ported::utils::movefd(unsafe { libc::dup(libc::STDOUT_FILENO) });
5449        if saved_stdout < 0 {
5450            crate::ported::utils::zclose(read_fd);
5451            crate::ported::utils::zclose(write_fd);
5452            return String::new();
5453        }
5454        // Flush Rust's stdout BufWriter against the ORIGINAL fd before
5455        // dup2 swaps fd 1 to the capture pipe. Without this, bytes left
5456        // buffered by a prior `print -n` get drained to fd 1 AFTER the
5457        // dup2, which routes them into the cmd-subst's pipe — they end
5458        // up in the captured result and disappear from terminal output.
5459        //
5460        // Bug #10 in docs/BUGS.md — `print -n "A"; v=$(true); print -n
5461        // "B"; v=$(true); print -n "C"; echo` printed only `C` because
5462        // `A` and `B` were redirected into the empty cmd-subst's pipe
5463        // and discarded as its "output". C zsh's getoutput() forks, so
5464        // the child inherits the buffer COPY and the parent's buffer
5465        // stays untouched; zshrs runs cmd-subst in-process so the
5466        // parent buffer is the only one — must flush before the swap.
5467        let _ = io::stdout().flush();
5468        // c:Bug #56 — publish the saved outer stdout so a trap firing
5469        // during the nested run routes body output to the parent's
5470        // real stdout instead of the cmdsub's pipe-bound fd 1.
5471        // c:Src/utils.c:1996 — movefd(dup(fd)): internal fd, keep >= 10.
5472        let saved_stderr_for_trap =
5473            crate::ported::utils::movefd(unsafe { libc::dup(libc::STDERR_FILENO) });
5474        crate::fusevm_bridge::CMDSUBST_OUTER_FDS
5475            .with(|s| s.borrow_mut().push((saved_stdout, saved_stderr_for_trap)));
5476        unsafe {
5477            libc::dup2(write_fd, libc::STDOUT_FILENO);
5478        }
5479        // zclose (not raw close) so the FDT_INTERNAL mark set by mpipe
5480        // is cleared from fdtable — c:Src/utils.c:2137.
5481        crate::ported::utils::zclose(write_fd);
5482
5483        // Drain the capture pipe CONCURRENTLY on a background reader
5484        // thread. The sub-VM (and any children it forks, which inherit
5485        // fd 1) writes to the pipe; reading it only AFTER vm.run()
5486        // returns deadlocks the moment the output exceeds the OS pipe
5487        // buffer (~64KB): the writer blocks on a full pipe that nothing
5488        // is draining, so vm.run() never returns. `$(alias)` over
5489        // zpwr's 2000+ aliases (~177KB) hung the whole shell a few
5490        // prompts in (thefuck's `fuck()` init runs `TF_SHELL_ALIASES=
5491        // $(alias)`). C's getoutput (Src/exec.c) forks the writer child
5492        // so the parent reads concurrently; this reader thread is the
5493        // in-process analog. It does only raw fd reads (no shell state /
5494        // thread-locals). EOF arrives once every write end closes — fd 1
5495        // restored below plus any forked child exiting.
5496        let reader_handle = std::thread::spawn(move || {
5497            let mut buf: Vec<u8> = Vec::new();
5498            let mut chunk = [0u8; 65536];
5499            loop {
5500                let n = unsafe {
5501                    libc::read(
5502                        read_fd,
5503                        chunk.as_mut_ptr() as *mut libc::c_void,
5504                        chunk.len(),
5505                    )
5506                };
5507                if n < 0 {
5508                    // Retry on EINTR (a signal interrupted the read);
5509                    // any other error ends the drain.
5510                    let e = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
5511                    if e == libc::EINTR {
5512                        continue;
5513                    }
5514                    break;
5515                }
5516                if n == 0 {
5517                    break; // EOF — all write ends closed.
5518                }
5519                buf.extend_from_slice(&chunk[..n as usize]);
5520            }
5521            buf
5522        });
5523
5524        // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
5525        // which does `zsh_subshell++`; in-process equivalent (RAII,
5526        // restored on every return path below).
5527        // A funsub/valsub is NOT a subshell — ksh(1) says the command runs
5528        // "in the current shell environment" — so it must not bump the
5529        // nesting counter `$ZSH_SUBSHELL` / `$BASH_SUBSHELL` reads.
5530        let _subshell_bump = if shared_state {
5531            None
5532        } else {
5533            Some(crate::fusevm_bridge::CmdSubstSubshellBump::enter())
5534        };
5535
5536        // c:Src/exec.c:1208-1209 — the same forked child clears
5537        // `opts[USEZLE]` and `zleactive`. Without it a substitution run
5538        // from inside a widget still looks "in ZLE", so `fc` refuses with
5539        // "no interactive history within ZLE" (c:Src/builtin.c:1523-1527)
5540        // and history-based completers come back empty. Placed here rather
5541        // than in exec::getoutput so the bridge's own cmdsubst paths
5542        // (BUILTIN_CMD_SUBST_TEXT, backtick) are covered too.
5543        let _subsh_state = crate::ported::exec::SubshStateGuard::enter();
5544
5545        // Parse + compile + run.
5546        // Push CS_CMDSUBST for `%_` xtrace prefix — direct port of
5547        // Src/exec.c:4783 `cmdpush(CS_CMDSUBST);` around execode().
5548        // Trace lines emitted by the inner program inherit this token
5549        // so their PS4 prefix shows "cmdsubst" matching zsh -x.
5550        cmdpush(crate::ported::zsh_h::CS_CMDSUBST as u8); // c:zsh.h:2799
5551                                                          // Save LINENO so the inner cmdsubst's line counter doesn't
5552                                                          // leak into the outer trace — direct port of Src/exec.c:1407
5553                                                          // `oldlineno = lineno;` followed by `lineno = oldlineno;`
5554                                                          // restore at line 1640. Inner program parses fresh as line 1
5555                                                          // and increments from there; once it returns, the outer
5556                                                          // line at the `$(…)` site must read the original outer
5557                                                          // lineno (so xtrace renders `+:5:> echo …` not `+:1:> …`).
5558        let saved_lineno = getsparam("LINENO");
5559        // Anchor the inner program's lineno to the outer's current
5560        // $LINENO so xtrace inside the cmdsubst renders the outer
5561        // line. zsh's execlist preserves lineno across the inner
5562        // exec — for our sub-VM (fresh compile) we use lineno_addend
5563        // to shift inner's line N → outer_lineno + (N - 1).
5564        let outer_lineno: u64 = self
5565            .scalar("LINENO")
5566            .and_then(|s| s.parse::<u64>().ok())
5567            .unwrap_or(0);
5568        // Mirror Src/init.c errflag save/clear/check pattern around
5569        // the nested parse so an inner syntax error doesn't bleed into
5570        // the outer execution.
5571        let saved_errflag = errflag.load(Ordering::Relaxed);
5572        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
5573        // Context-isolated nested parse (c:Src/exec.c:283 parse_string).
5574        // The outer loop()/parse_event reader may be mid-stream when this
5575        // cmd-subst executes (single-event mode), so a destructive
5576        // parse_init/lex_init would clobber its next read. parse_isolated
5577        // brackets the parse with zcontext_save/restore + inpush/inpop.
5578        let parsed = parse_isolated(cmd_str);
5579        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
5580        errflag.store(saved_errflag, Ordering::Relaxed);
5581        let prog = if parse_failed { None } else { Some(parsed) };
5582        let mut cmd_status: Option<i32> = None;
5583        if let Some(prog) = prog {
5584            let mut compiler = crate::compile_zsh::ZshCompiler::new();
5585            compiler.lineno_addend = outer_lineno.saturating_sub(1);
5586            let chunk = compiler.compile(&prog);
5587            if !chunk.ops.is_empty() {
5588                crate::fusevm_disasm::maybe_print_stdout("run_command_substitution", &chunk);
5589                // c:Src/exec.c:4783 — `$(...)` runs in a subshell, so
5590                // assignments / setopt / cd / trap changes inside
5591                // mustn't leak to the parent. zsh forks; we run
5592                // in-process and snapshot/restore manually. Same
5593                // snapshot shape used by host_subshell_begin/end for
5594                // the `(...)` subshell form.
5595                let paramtab_snap = crate::ported::params::paramtab()
5596                    .read()
5597                    .ok()
5598                    .map(|t| t.clone())
5599                    .unwrap_or_default();
5600                let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
5601                    .lock()
5602                    .ok()
5603                    .map(|m| m.clone())
5604                    .unwrap_or_default();
5605                let pparams_snap = self.pparams();
5606                let opts_snap = crate::ported::options::opt_state_snapshot();
5607                // c:Src/exec.c:1161 — a command substitution runs in a
5608                // subshell, so IFS changes inside it must NOT leak to the
5609                // parent. IFS lives in the external `ifs_lock` global (not
5610                // paramtab), so the paramtab snapshot above doesn't cover
5611                // it: `echo $(IFS=:; set -- a b c; echo "$*")` set IFS=":"
5612                // which both produced "a:b:c" AND then word-split the
5613                // UNQUOTED result on the leaked ":" → "a b c". Snapshot the
5614                // global IFS here and restore it (with inittyptab) below.
5615                let ifs_snap = crate::ported::params::ifs_lock()
5616                    .lock()
5617                    .map(|g| g.clone())
5618                    .unwrap_or_default();
5619                let traps_snap = crate::ported::builtin::traps_table()
5620                    .lock()
5621                    .map(|t| t.clone())
5622                    .unwrap_or_default();
5623                // c:Src/exec.c:4783 — function definitions / unfunction
5624                // inside `$(...)` must also be isolated from the parent.
5625                // C zsh's getoutput() forks, so the child's shfunctab
5626                // mutations die with the child. zshrs's in-process
5627                // cmd-subst needs to snapshot/restore the function
5628                // tables manually alongside the param/opts/trap snaps
5629                // already in this block. Bug #455.
5630                let shfunctab_snap = crate::ported::hashtable::shfunctab_lock()
5631                    .read()
5632                    .ok()
5633                    .map(|t| t.snapshot())
5634                    .unwrap_or_default();
5635                let functions_compiled_snap = self.functions_compiled.clone();
5636                let function_source_snap = self.function_source.clone();
5637                // c:Src/exec.c:4782 — getoutput's child runs
5638                // `entersubsh(ESUB_PGRP|ESUB_NOMONITOR)`, and c:1219
5639                // `if (flags & ESUB_PGRP) clearjobtab(monitor)` hands
5640                // that child an EMPTY job table. The oldjobtab snapshot
5641                // (c:Src/jobs.c:1800) is monitor-only, so a
5642                // non-interactive shell keeps nothing at all — which is
5643                // why zsh prints nothing for `sleep 5 & print $(jobs)`.
5644                // The `(...)` and pipeline-stage paths already call
5645                // clearjobtab in their forked children; cmd-subst runs
5646                // in-process, so snapshot the globals clearjobtab
5647                // mutates and restore them below. freejob (c:1457) is
5648                // struct-local — no waitpid/kill — so the restore is
5649                // exact.
5650                // c:Src/exec.c:4782 — same fork, one more thing it copies:
5651                // the completion-match arena (c:Src/Zle/compcore.c:124-259).
5652                // A `compadd` run inside `$(…)` lands in the CHILD's
5653                // `matches`/`amatches`/`mgroup`, which die with it, so the
5654                // completing parent never sees those matches. zshrs's
5655                // in-process cmd-subst shares the arena, so `_tmux`'s
5656                // `desc="$(_tmux-backup)"` description probe leaked five
5657                // whole completion groups into `tmux <TAB>` (551 matches vs
5658                // zsh's 450). Snapshot/restore it by hand, exactly as the
5659                // param/opts/trap/job snaps above do.
5660                let comp_arena_snap = crate::comp_match_handles::comp_arena_save();
5661                let jobtab_snap = crate::ported::jobs::JOBTAB
5662                    .get()
5663                    .and_then(|t| t.lock().ok().map(|g| g.clone()));
5664                let maxjob_snap = crate::ported::jobs::MAXJOB
5665                    .get()
5666                    .and_then(|m| m.lock().ok().map(|g| *g));
5667                let thisjob_snap = crate::ported::jobs::THISJOB
5668                    .get()
5669                    .and_then(|t| t.lock().ok().map(|g| *g));
5670                // curjob/prevjob (c:Src/jobs.c) are plain globals in C,
5671                // so the forked child's setcurjob calls never reach the
5672                // parent — restore them alongside the table, else the
5673                // `+`/`-` markers are lost after a `$(jobs)`.
5674                let curjob_snap = crate::ported::jobs::CURJOB
5675                    .get()
5676                    .and_then(|t| t.lock().ok().map(|g| *g));
5677                let prevjob_snap = crate::ported::jobs::PREVJOB
5678                    .get()
5679                    .and_then(|t| t.lock().ok().map(|g| *g));
5680                {
5681                    let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
5682                    crate::ported::jobs::clearjobtab(&mut self.jobs, monitor);
5683                }
5684                let mut vm = fusevm::VM::new(chunk);
5685                register_builtins(&mut vm);
5686                vm.set_shell_host(Box::new(ZshrsHost));
5687                // Seed inner $? with the outer's last_status so the
5688                // sub-shell inherits the parent's exit code. Direct
5689                // port of Src/exec.c:4783 around execcmd_exec — the
5690                // child inherits `lastval` at fork time, so `false;
5691                // echo $(echo $?)` reads 1, not the freshly-zeroed
5692                // sub-VM default. Without this, every cmd-subst
5693                // started with $?==0 regardless of the parent's
5694                // last command.
5695                vm.last_status = self.last_status();
5696                // `exit N` inside a cmd-subst should terminate ONLY
5697                // the sub-shell (C zsh: cmd-subst forks, the child
5698                // `_exit(N)`s; status reaches the parent as
5699                // cmd-subst exit). zshrs runs in-process, so we
5700                // route through the SUBSHELL_DEPTH-gated deferred
5701                // path inside zexit (builtin.rs:7713): bump
5702                // SUBSHELL_DEPTH so `exit` sets EXIT_PENDING/
5703                // EXIT_VAL instead of calling realexit (which would
5704                // process::exit and kill the parent shell). After
5705                // the sub-VM returns, harvest EXIT_PENDING/EXIT_VAL
5706                // as the cmd-subst's status, then restore the
5707                // parent's flags so the outer VM continues normally.
5708                use crate::ported::builtin::{
5709                    BREAKS, EXIT_PENDING, EXIT_VAL, RETFLAG, SHELL_EXITING, SUBSHELL_DEPTH,
5710                };
5711                use std::sync::atomic::Ordering::Relaxed;
5712                let saved_exit_pending = EXIT_PENDING.swap(0, Relaxed);
5713                let saved_exit_val = EXIT_VAL.swap(0, Relaxed);
5714                let saved_shell_exiting = SHELL_EXITING.swap(0, Relaxed);
5715                let saved_retflag = RETFLAG.swap(0, Relaxed);
5716                let saved_breaks = BREAKS.swap(0, Relaxed);
5717                // c:Src/exec.c:4784 — `execode(prog, 0, 1, "cmdsubst");`.
5718                // execode (c:1245-1266) APPENDS its `context` argument to
5719                // `zsh_eval_context` for the duration of the body, so code
5720                // inside `$(…)` / backticks sees `cmdarg:cmdsubst` where the
5721                // top level sees just `cmdarg`. zshrs pushed "shfunc" at the
5722                // function-call site but never pushed "cmdsubst", so
5723                // `$(print $ZSH_EVAL_CONTEXT)` reported `cmdarg` and
5724                // `$(f)` reported `cmdarg:shfunc` instead of
5725                // `cmdarg:cmdsubst:shfunc`. Popped on every return path by the
5726                // guard below, mirroring execode's stack discipline.
5727                // Bug #1065.
5728                let sync_eval_ctx = |stack: &[String]| {
5729                    let joined = stack.join(":");
5730                    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
5731                        if let Some(pm) = tab.get_mut("zsh_eval_context") {
5732                            pm.u_arr = Some(stack.to_vec());
5733                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
5734                        }
5735                        if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
5736                            pm.u_str = Some(joined);
5737                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
5738                        }
5739                    }
5740                };
5741                if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
5742                    ctx.push("cmdsubst".to_string());
5743                    sync_eval_ctx(&ctx);
5744                }
5745                struct CmdsubstEvalCtxGuard<F: Fn(&[String])>(F);
5746                impl<F: Fn(&[String])> Drop for CmdsubstEvalCtxGuard<F> {
5747                    fn drop(&mut self) {
5748                        if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
5749                            ctx.pop();
5750                            (self.0)(&ctx);
5751                        }
5752                    }
5753                }
5754                let _cs_eval_ctx_guard = CmdsubstEvalCtxGuard(sync_eval_ctx);
5755                SUBSHELL_DEPTH.fetch_add(1, Relaxed);
5756                let _ctx = ExecutorContext::enter(self);
5757                let _ = vm.run();
5758                let inner_exit_pending = EXIT_PENDING.load(Relaxed);
5759                let inner_exit_val = EXIT_VAL.load(Relaxed);
5760                let inner_status = if inner_exit_pending != 0 {
5761                    inner_exit_val & 0xFF
5762                } else {
5763                    vm.last_status
5764                };
5765                cmd_status = Some(inner_status);
5766                SUBSHELL_DEPTH.fetch_sub(1, Relaxed);
5767                // c:Src/exec.c — `$(…)` is a FORK in C: an errflag
5768                // abort inside the child ends the child (its lastval
5769                // becomes the cmd-subst status) and the flag dies
5770                // with the child process — the parent's lists keep
5771                // running. zsh 5.9: `v=$(typeset -A q; q=(odd));
5772                // echo "after $?"` prints `after 1`. Mirror the fork
5773                // isolation by clearing ERRFLAG_ERROR at the
5774                // cmd-subst boundary.
5775                //
5776                // ERRFLAG_HARD dies here too: `${u:?msg}` inside the
5777                // child sets it (c:Src/subst.c:3344) then `_exit(1)`s
5778                // (c:3353) — C's parent never sees the bit. A leaked
5779                // HARD bit makes every later zerr() silent
5780                // (c:Src/utils.c:175-177) and silently fails every
5781                // later parse. Same fix as subshell_end.
5782                errflag.fetch_and(
5783                    !(ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
5784                    Relaxed,
5785                );
5786                // c:Src/exec.c:4783 execcmdoutsubst — `$(...)` is a
5787                // subshell, and zsh fires the EXIT trap when the
5788                // subshell ends BUT only if the trap was installed
5789                // INSIDE the subshell. An EXIT trap inherited from
5790                // the parent fires when the parent shell exits, not
5791                // again at cmdsub end. Detect "installed inside" by
5792                // comparing the current traps_table["EXIT"] entry
5793                // against the pre-cmdsub snapshot — fire only when
5794                // the body differs (newly set, removed, or replaced).
5795                // Pop the body before execute_script to avoid the
5796                // re-fire inside execute_script_zsh_pipeline's own
5797                // EXIT-handler tail at vm_helper.rs:1490. Bug #354.
5798                let snap_exit = traps_snap.get("EXIT").cloned();
5799                let live_exit = crate::ported::builtin::traps_table()
5800                    .lock()
5801                    .ok()
5802                    .and_then(|t| t.get("EXIT").cloned());
5803                if live_exit != snap_exit {
5804                    if let Some(body) = live_exit {
5805                        if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
5806                            t.remove("EXIT");
5807                        }
5808                        let _ = crate::ported::exec::execute_script(&body);
5809                    }
5810                }
5811                // c:Src/signals.c::dotrap(SIGEXIT) — also fire the
5812                // TRAPEXIT() function-named form (ZSIG_FUNC) — but
5813                // only if it was defined INSIDE the subshell (the
5814                // parent's TRAPEXIT fires at parent exit, not here).
5815                // ZSIG_FUNC bit on sigtrapped[SIGEXIT] tells us
5816                // whether a TRAPEXIT function is registered; check
5817                // BEFORE the snapshot restore.
5818                // Skip for now — function-form detection mirrors the
5819                // raw-body check above; deferred until a clean
5820                // sigtrapped snapshot/restore pair exists.
5821                // Restore parent's exit / loop / function-return
5822                // state so the outer VM continues normally.
5823                EXIT_PENDING.store(saved_exit_pending, Relaxed);
5824                EXIT_VAL.store(saved_exit_val, Relaxed);
5825                SHELL_EXITING.store(saved_shell_exiting, Relaxed);
5826                RETFLAG.store(saved_retflag, Relaxed);
5827                BREAKS.store(saved_breaks, Relaxed);
5828                // Restore parent state. The inner cmd-subst's stdout
5829                // (the captured pipe contents) is the only thing
5830                // that leaks out.
5831                //
5832                // A funsub/valsub skips ALL of it: that is the entire
5833                // difference between `${ list; }` and `$(list)`.
5834                if !shared_state {
5835                    if let Ok(mut t) = crate::ported::params::paramtab().write() {
5836                        *t = paramtab_snap;
5837                    }
5838                    if let Ok(mut m) = crate::ported::params::paramtab_hashed_storage().lock() {
5839                        *m = paramtab_hashed_snap;
5840                    }
5841                    self.set_pparams(pparams_snap);
5842                    crate::ported::options::opt_state_restore(opts_snap);
5843                    // Restore the parent's IFS (subshell isolation): the body's
5844                    // `IFS=` must not leak out and word-split the parent's use
5845                    // of the cmdsub result. Runtime word-splitting reads the
5846                    // IFS *string* (this `ifs_lock` global), so restoring it is
5847                    // sufficient. Deliberately do NOT call inittyptab() here —
5848                    // that rewrites the process-global typtab the LEXER reads
5849                    // on every character, and firing it per-cmdsub races
5850                    // concurrent lexing in zshrs's worker threads, producing
5851                    // spurious "parse error" flakes (HEAD ran clean 3/3; the
5852                    // per-cmdsub inittyptab flaked ~50%). The typtab only
5853                    // affects re-lexing — the parent is already compiled — and
5854                    // leaving it at the body's value is strictly less divergent
5855                    // than the prior behavior, which leaked the whole IFS.
5856                    if let Ok(mut g) = crate::ported::params::ifs_lock().lock() {
5857                        *g = ifs_snap;
5858                    }
5859                    if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
5860                        *t = traps_snap;
5861                    }
5862                    // Restore function tables (parallel to the trap/param
5863                    // restore above). Bug #455.
5864                    if let Ok(mut t) = crate::ported::hashtable::shfunctab_lock().write() {
5865                        t.restore(shfunctab_snap);
5866                    }
5867                    self.functions_compiled = functions_compiled_snap;
5868                    self.function_source = function_source_snap;
5869                    // Discard anything the substitution added to the completion
5870                    // arena — the in-process stand-in for the forked child's
5871                    // address space going away (see comp_arena_save above).
5872                    crate::comp_match_handles::comp_arena_restore(comp_arena_snap);
5873                    // Undo the clearjobtab above — in C the cleared table
5874                    // belongs to the forked child and dies with it, so the
5875                    // parent's table must come back untouched.
5876                    if let (Some(js), Some(t)) = (jobtab_snap, crate::ported::jobs::JOBTAB.get()) {
5877                        if let Ok(mut g) = t.lock() {
5878                            *g = js;
5879                        }
5880                    }
5881                    if let (Some(mj), Some(m)) = (maxjob_snap, crate::ported::jobs::MAXJOB.get()) {
5882                        if let Ok(mut g) = m.lock() {
5883                            *g = mj;
5884                        }
5885                    }
5886                    if let (Some(tj), Some(t)) = (thisjob_snap, crate::ported::jobs::THISJOB.get())
5887                    {
5888                        if let Ok(mut g) = t.lock() {
5889                            *g = tj;
5890                        }
5891                    }
5892                    if let (Some(cj), Some(t)) = (curjob_snap, crate::ported::jobs::CURJOB.get()) {
5893                        if let Ok(mut g) = t.lock() {
5894                            *g = cj;
5895                        }
5896                    }
5897                    if let (Some(pj), Some(t)) = (prevjob_snap, crate::ported::jobs::PREVJOB.get())
5898                    {
5899                        if let Ok(mut g) = t.lock() {
5900                            *g = pj;
5901                        }
5902                    }
5903                } // if !shared_state
5904            }
5905        }
5906        // Restore LINENO so outer xtrace sees the outer line. LINENO
5907        // carries PM_READONLY (matching zsh's `integer-readonly-special`
5908        // GSU), so the restore must bypass the generic readonly guard
5909        // exactly like BUILTIN_SET_LINENO (fusevm_bridge.rs:5156) — write
5910        // the param's `u_val` directly and mirror the file-static /
5911        // lexer line counters. The previous `set_scalar` went through the
5912        // readonly-checked path: harmless on the `-c` route (LINENO not
5913        // yet flagged readonly there) but fatal on the faithful
5914        // loop()/zsh_main route, where every `$(...)` in piped/redirected
5915        // input died with `read-only variable: LINENO`.
5916        if let Some(ln) = saved_lineno {
5917            let n: crate::ported::zsh_h::zlong = ln.parse().unwrap_or(0);
5918            if let Ok(mut tab) = crate::ported::params::paramtab().write() {
5919                if let Some(pm) = tab.get_mut("LINENO") {
5920                    // c:Src/utils.c:121 `zlong lineno` — the value lives in the C
5921                    // GLOBAL, reached through LINENO's GSU. A `typeset -h +g LINENO`
5922                    // local shadow has no PM_SPECIAL and no GSU, so C's `lineno = N`
5923                    // never touches it; skip the paramtab mirror for the same reason.
5924                    if (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) != 0 {
5925                        pm.u_val = n;
5926                        pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
5927                    }
5928                }
5929            }
5930            crate::ported::utils::set_lineno(n as i32);
5931            crate::ported::lex::set_lineno(n as u64);
5932        }
5933        cmdpop();
5934        // Propagate the inner cmd's status to the parent shell. zsh:
5935        // `a=$(false); echo $?` → 1 because cmd-subst status leaks to
5936        // $?. Set last_status on the executor so $? reads the right
5937        // value for callers that don't have a SetStatus(0) overwrite
5938        // (echo, test, etc.). Bare assignment paths still get the
5939        // SetStatus(0) from compile_simple — that's a separate gap.
5940        // Empty cmd-subst (`\`\``, `$()`) resets status to 0 per
5941        // Src/exec.c — the inner ran no command so the "last
5942        // command's exit" is the implicit success of "did nothing".
5943        // Without this branch, a prior command's non-zero status
5944        // leaked through the empty cmd-subst.
5945        let final_status = cmd_status.unwrap_or(0);
5946        self.set_last_status(final_status);
5947        // c:Src/exec.c:4775 — `getoutput` (the C cmd-subst path used by
5948        // both `$(…)` and `` `…` ``) propagates the inner exit through
5949        // `cmdoutval`, then the caller does `LASTVAL = cmdoutval`. Mirror
5950        // by writing the cmd-subst's exit into the ported `cmdoutval`
5951        // global so `getoutput()`'s post-call `LASTVAL = cmdoutval` (at
5952        // exec.rs:559-562) and the C-equivalent `cmdoutval = lastval`
5953        // bookkeeping in execcmd_exec's assignment paths both see the
5954        // real exit. Without this, backtick assignments (`a=\`false\`;
5955        // echo $?`) reported 0 because getoutput's caller path read a
5956        // cmdoutval that was never updated by the in-process hook.
5957        crate::ported::exec::cmdoutval.store(final_status, std::sync::atomic::Ordering::Relaxed);
5958
5959        // Flush any buffered Rust-side stdout so it reaches the pipe
5960        // before we restore.
5961        let _ = io::stdout().flush();
5962
5963        // Pop the trap-routing stack BEFORE restoring stdout so any
5964        // trap that fires during the restore goes to the cmdsub's
5965        // pipe (matching what zsh's forked cmdsub would do — the
5966        // child's fd 1 is the pipe right up until the child exits).
5967        crate::fusevm_bridge::CMDSUBST_OUTER_FDS.with(|s| {
5968            s.borrow_mut().pop();
5969        });
5970        // c:Bug #353 — restore fd 2 from the saved outer stderr. A
5971        // body that ran `exec 2>&1` (no command, just redirects)
5972        // would have committed fd 2 → the cmdsub's pipe write end.
5973        // In zsh's forked cmdsub the committed redirect dies with
5974        // the child; zshrs's in-process cmdsub would leak the dup
5975        // back to the parent and keep the pipe write-end alive,
5976        // blocking the parent's read on the read_end forever.
5977        // Always restoring fd 2 here rolls back any commit so the
5978        // pipe write-end count drops to zero when we drop the
5979        // local write_fd reference (which already happened above).
5980        if saved_stderr_for_trap >= 0 {
5981            unsafe {
5982                libc::dup2(saved_stderr_for_trap, libc::STDERR_FILENO);
5983            }
5984            crate::ported::utils::zclose(saved_stderr_for_trap);
5985        }
5986        // Restore stdout and read what was captured.
5987        unsafe {
5988            libc::dup2(saved_stdout, libc::STDOUT_FILENO);
5989        }
5990        crate::ported::utils::zclose(saved_stdout);
5991        // Collect the concurrently-drained output. With fd 1 restored
5992        // above, the last shell-side write end is closed, so the reader
5993        // hits EOF and join() returns the full buffer regardless of
5994        // size — no pipe-full deadlock. The reader only read (never
5995        // closed) read_fd, so zclose still clears its FDT_INTERNAL mark
5996        // (c:Src/utils.c:2137).
5997        let bytes = reader_handle.join().unwrap_or_default();
5998        crate::ported::utils::zclose(read_fd);
5999        let mut output = String::from_utf8_lossy(&bytes).into_owned();
6000
6001        // POSIX: trailing newlines stripped from cmd-sub result.
6002        while output.ends_with('\n') {
6003            output.pop();
6004        }
6005        // !!! RUST-ONLY: provenance tap. `$(…)` is a lineage ORIGIN —
6006        // these bytes did not exist in the shell before the inner list
6007        // ran. This is the in-process cmd-subst funnel (the host
6008        // `ShellHost::cmd_subst` path taps the same event for chunks
6009        // that reach the VM as sub-chunks instead of source text).
6010        if crate::provenance::active() {
6011            crate::provenance::on_cmd_subst(cmd_str, &output);
6012        }
6013        output
6014    }
6015}
6016
6017#[cfg(test)]
6018mod tests {
6019    use super::*;
6020
6021    #[test]
6022    fn test_simple_echo() {
6023        let _g = crate::test_util::global_state_lock();
6024        let mut exec = ShellExecutor::new();
6025        let status = exec.execute_script("true").unwrap();
6026        assert_eq!(status, 0);
6027    }
6028
6029    /// A `zsh/parameter` param is an autoload stub until it is READ; the read
6030    /// materializes it. `$parameters` enumerations type a stub as "undefined"
6031    /// (Src/Modules/parameter.c:49-50) and never resolve it, which is what
6032    /// puts those names in the right `_parameters -g` bucket.
6033    ///
6034    /// Reference behavior (`zsh -f`):
6035    ///   `m=( ${(kv)parameters} ); print $m[aliases]`            → undefined
6036    ///   `${parameters[aliases]}` first, then the same scan      → association-…
6037    #[test]
6038    fn module_params_are_autoload_stubs_until_read() {
6039        let _g = crate::test_util::global_state_lock();
6040        // `jobstates` is never touched by shell startup, unlike `aliases`.
6041        assert!(
6042            module_param_is_autoload_stub("jobstates"),
6043            "untouched module param must read as a stub"
6044        );
6045        mark_module_param_used("jobstates");
6046        assert!(
6047            !module_param_is_autoload_stub("jobstates"),
6048            "a read must materialize it"
6049        );
6050        // A core special (not module-provided) is never a stub.
6051        assert!(!module_param_is_autoload_stub("path"));
6052        assert!(!module_param_is_autoload_stub("PATH"));
6053    }
6054
6055    /// Phase 3 diagnostic: a worker must be able to run a USER-DEFINED function
6056    /// (defined on the main executor) — the function source lives in the shared
6057    /// shfunctab, and the worker lazy-compiles it from there. Its `typeset -g`
6058    /// must reach the global param table. This is what `async_precmd` needs.
6059    #[test]
6060    fn phase3_worker_runs_user_defined_function() {
6061        let _g = crate::test_util::global_state_lock();
6062        let mut main = ShellExecutor::new();
6063        main.execute_script("phase3fn() { typeset -g PHASE3_FN_RESULT=fn_ran }")
6064            .unwrap();
6065        // sanity: it ran on main? (define only — not called yet)
6066        assert_eq!(getsparam("PHASE3_FN_RESULT"), None);
6067
6068        let pool = std::sync::Arc::new(crate::worker::WorkerPool::new(2));
6069        let pool2 = std::sync::Arc::clone(&pool);
6070        let (tx, rx) = std::sync::mpsc::channel::<()>();
6071        pool.submit(move || {
6072            let mut wex = ShellExecutor::new_worker(pool2);
6073            let _ = wex.execute_script_zsh_pipeline("phase3fn");
6074            let _ = tx.send(());
6075        });
6076        rx.recv().expect("worker completed");
6077        assert_eq!(
6078            getsparam("PHASE3_FN_RESULT"),
6079            Some("fn_ran".to_string()),
6080            "worker could not run the user-defined function from shared shfunctab"
6081        );
6082    }
6083
6084    /// Phase 1 of the in-process thread-execution model: prove a shell body
6085    /// runs on a POOL WORKER THREAD via `new_worker` and that its `typeset -g`
6086    /// lands in the GLOBAL (RwLock-synchronized) param table. Each worker writes
6087    /// a DISTINCT key, so a green run shows: (a) `ExecutorContext::enter` +
6088    /// `execute_script_zsh_pipeline` work off the main thread, and (b) N
6089    /// concurrent writers don't corrupt the shared table. This is the linchpin
6090    /// for converting the subprocess-forking parallel builtins to threads.
6091    #[test]
6092    fn phase1_worker_shell_writes_reach_global_paramtab() {
6093        let _g = crate::test_util::global_state_lock();
6094        // Seed the globals (options, default params) exactly as a live session
6095        // would — workers SHARE these; new_worker() never re-seeds them.
6096        let _main = ShellExecutor::new();
6097
6098        let pool = std::sync::Arc::new(crate::worker::WorkerPool::new(4));
6099        const N: usize = 16;
6100        let (tx, rx) = std::sync::mpsc::channel::<usize>();
6101        for i in 0..N {
6102            let tx = tx.clone();
6103            let pool_for_worker = std::sync::Arc::clone(&pool);
6104            pool.submit(move || {
6105                // Lightweight per-worker executor; shares the global tables.
6106                let mut wex = ShellExecutor::new_worker(pool_for_worker);
6107                let _ =
6108                    wex.execute_script_zsh_pipeline(&format!("typeset -g PHASE1_WK_{i}=val_{i}"));
6109                let _ = tx.send(i);
6110            });
6111        }
6112        drop(tx);
6113        // Barrier: wait for all N workers.
6114        let mut done = 0usize;
6115        while rx.recv().is_ok() {
6116            done += 1;
6117        }
6118        assert_eq!(done, N, "all {N} workers completed");
6119
6120        // Every worker's write must be visible in the global param table.
6121        for i in 0..N {
6122            assert_eq!(
6123                getsparam(&format!("PHASE1_WK_{i}")),
6124                Some(format!("val_{i}")),
6125                "worker {i} typeset -g did not reach the global paramtab"
6126            );
6127        }
6128    }
6129
6130    #[test]
6131    fn test_if_true() {
6132        let _g = crate::test_util::global_state_lock();
6133        let mut exec = ShellExecutor::new();
6134        let status = exec.execute_script("if true; then true; fi").unwrap();
6135        assert_eq!(status, 0);
6136    }
6137
6138    #[test]
6139    fn test_if_false() {
6140        let _g = crate::test_util::global_state_lock();
6141        let mut exec = ShellExecutor::new();
6142        let status = exec
6143            .execute_script("if false; then true; else false; fi")
6144            .unwrap();
6145        assert_eq!(status, 1);
6146    }
6147
6148    #[test]
6149    fn test_for_loop() {
6150        let _g = crate::test_util::global_state_lock();
6151        let mut exec = ShellExecutor::new();
6152        exec.execute_script("for i in a b c; do true; done")
6153            .unwrap();
6154        assert_eq!(exec.last_status(), 0);
6155    }
6156
6157    #[test]
6158    fn test_and_list() {
6159        let _g = crate::test_util::global_state_lock();
6160        let mut exec = ShellExecutor::new();
6161        let status = exec.execute_script("true && true").unwrap();
6162        assert_eq!(status, 0);
6163
6164        let status = exec.execute_script("true && false").unwrap();
6165        assert_eq!(status, 1);
6166    }
6167
6168    #[test]
6169    fn test_or_list() {
6170        let _g = crate::test_util::global_state_lock();
6171        let mut exec = ShellExecutor::new();
6172        let status = exec.execute_script("false || true").unwrap();
6173        assert_eq!(status, 0);
6174    }
6175
6176    /// Pin: `forklevel` matches the C global declared at
6177    /// `Src/exec.c:1052` (`int forklevel;`). Like `int` in C, the
6178    /// Rust port is an AtomicI32 starting at 0 (no fork has occurred
6179    /// at process start). Per `Src/exec.c:1221` (`forklevel =
6180    /// locallevel;`), every subshell entry copies `locallevel` into
6181    /// the global; the SIGPIPE handler at `Src/signals.c:808` reads
6182    /// it back to distinguish the top-level shell from a subshell.
6183    #[test]
6184    fn test_forklevel_default_zero_and_roundtrip() {
6185        let _g = crate::test_util::global_state_lock();
6186        use std::sync::atomic::Ordering;
6187        let prev = crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed);
6188        // Default state at process start: zero (matches C's BSS init
6189        // of `int forklevel;` to 0).
6190        crate::ported::exec::FORKLEVEL.store(0, Ordering::Relaxed);
6191        assert_eq!(crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed), 0);
6192        // Simulate the c:1221 store: `forklevel = locallevel;`.
6193        crate::ported::exec::FORKLEVEL.store(3, Ordering::Relaxed);
6194        assert_eq!(crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed), 3);
6195        crate::ported::exec::FORKLEVEL.store(prev, Ordering::Relaxed);
6196    }
6197}
6198
6199// Plugin-Framework-Agnostic State-Modification Recorder hook helpers.
6200/// Recorder helper: emit one record for an array/scalar mutation
6201/// targeting a path-family parameter (path/fpath/manpath/module_path/
6202/// cdpath, lower- or upper-cased), or one `assign` record for any
6203/// other name. Centralises the path-family list so `BUILTIN_SET_ARRAY`,
6204/// `BUILTIN_APPEND_ARRAY`, and `BUILTIN_APPEND_SCALAR_OR_PUSH` share
6205/// the same routing.
6206///
6207/// `is_append` distinguishes `arr=(...)` from `arr+=(...)` so the
6208/// emitted event carries the APPEND attr bit and replay can choose
6209/// between fresh-set and extend semantics.
6210///
6211/// `attrs` carries any pre-existing type info from
6212/// `recorder_attrs_for(name)` (readonly/export/global) — array shape
6213/// and APPEND get OR'd in by emit_array_assign.
6214#[cfg(feature = "recorder")]
6215pub(crate) fn emit_path_or_assign(
6216    name: &str,
6217    values: &[String],
6218    attrs: crate::recorder::ParamAttrs,
6219    is_append: bool,
6220    ctx: &crate::recorder::RecordCtx,
6221) {
6222    let lower = name.to_ascii_lowercase();
6223    let kind_name: Option<&'static str> = match lower.as_str() {
6224        "path" => Some("path"),
6225        "fpath" => Some("fpath"),
6226        "manpath" => Some("manpath"),
6227        "module_path" => Some("module_path"),
6228        "cdpath" => Some("cdpath"),
6229        _ => None,
6230    };
6231    match kind_name {
6232        Some(k) => {
6233            for v in values {
6234                crate::recorder::emit_path_mod(v, k, ctx.clone());
6235                // Each fpath addition also surfaces every `_completion`
6236                // file inside the directory — matches zinit-report's
6237                // per-plugin "Completions:" listing. Only fpath dirs
6238                // get this treatment; PATH dirs hold executables, not
6239                // completion functions.
6240                if k == "fpath" {
6241                    crate::recorder::discover_completions_in_fpath_dir(v, ctx);
6242                }
6243            }
6244        }
6245        None => {
6246            // Non-path arrays: emit ONE `assign` event with the
6247            // ordered element list preserved in value_array. Replay
6248            // reconstructs `name=(elem1 elem2 ...)` exactly without
6249            // having to re-split a joined string.
6250            crate::recorder::emit_array_assign(
6251                name,
6252                values.to_vec(),
6253                attrs,
6254                is_append,
6255                ctx.clone(),
6256            );
6257        }
6258    }
6259}
6260
6261use std::os::unix::fs::MetadataExt;
6262
6263bitflags::bitflags! {
6264    /// Flags for zfork()
6265    #[derive(Debug, Clone, Copy, Default)]
6266    pub struct ForkFlags: u32 {
6267        const NOJOB = 1 << 0;    // Don't add to job table
6268        const NEWGRP = 1 << 1;   // Create new process group
6269        const FGTTY = 1 << 2;    // Take foreground terminal
6270        const KEEPSIGS = 1 << 3; // Keep signal handlers
6271    }
6272}
6273
6274bitflags::bitflags! {
6275    /// Flags for entersubsh()
6276    #[derive(Debug, Clone, Copy, Default)]
6277    pub struct SubshellFlags: u32 {
6278        const NOMONITOR = 1 << 0; // Disable job control
6279        const KEEPFDS = 1 << 1;   // Keep file descriptors
6280        const KEEPTRAPS = 1 << 2; // Keep trap handlers
6281    }
6282}
6283
6284/// Result of fork operation
6285#[derive(Debug)]
6286/// `fork()` outcome (parent / child / error).
6287/// Mirrors the integer return of `zfork()` from Src/exec.c:349.
6288pub enum ForkResult {
6289    /// `Parent` variant.
6290    Parent(i32), // Contains child PID
6291    /// `Child` variant.
6292    Child,
6293}
6294
6295/// Redirection mode
6296#[derive(Debug, Clone, Copy)]
6297/// File-redirection mode (`>` / `>>` / `<` / etc.).
6298/// Mirrors the `REDIR_*` enum from Src/zsh.h.
6299pub enum RedirMode {
6300    /// `Dup` variant.
6301    Dup,
6302    /// `Close` variant.
6303    Close,
6304}
6305
6306/// Builtin command type
6307#[derive(Debug, Clone, Copy)]
6308/// Builtin classification.
6309/// Mirrors the `BINF_*` flag set Src/builtin.c uses to
6310/// classify special vs regular builtins.
6311pub enum BuiltinType {
6312    /// `Normal` variant.
6313    Normal,
6314    /// `Disabled` variant.
6315    Disabled,
6316}
6317
6318use crate::fusevm_bridge::with_executor;
6319use crate::ported::glob::*;
6320use crate::ported::hist::*;
6321use crate::ported::jobs::*;
6322use crate::ported::math::*;
6323use crate::ported::module::*;
6324use crate::ported::modules::cap::*;
6325use crate::ported::modules::terminfo::*;
6326use crate::ported::options::*;
6327use crate::ported::params::*;
6328use crate::ported::pattern::*;
6329use crate::ported::prompt::*;
6330use crate::ported::signals::*;
6331use crate::ported::subst::*;
6332use crate::ported::utils::{zerr, zerrnam, zwarn, zwarnnam};
6333use ::regex::{Error as RegexError, Regex, RegexBuilder};
6334
6335pub use crate::ported::modules::regex::posix_ere_bracket_escape;
6336
6337impl ShellExecutor {
6338    /// Every option name in `ZSH_OPTIONS_SET` (port of `optns[]` at
6339    /// `Src/options.c:79+`).
6340    pub(crate) fn all_zsh_options() -> Vec<&'static str> {
6341        ZSH_OPTIONS_SET.iter().copied().collect()
6342    }
6343
6344    /// `name → default-on` map via canonical `default_on_options`
6345    /// (port of `defset()` macro at `Src/options.c:73`).
6346    pub(crate) fn default_options() -> HashMap<String, bool> {
6347        let on = default_on_options();
6348        Self::all_zsh_options()
6349            .into_iter()
6350            .map(|n| (n.to_string(), on.contains(n)))
6351            .collect()
6352    }
6353}
6354impl ShellExecutor {
6355    /// PURE PASSTHRU to the canonical `params::getsparam` (C port of
6356    /// `Src/params.c::getsparam`). Every special-name case the old
6357    /// 316-line body handled lives in `params::lookup_special_var` +
6358    /// `getsparam`'s paramtab/env walk. Returns an empty string for
6359    /// unset names (matching the old fn's signature; callers that
6360    /// need the set/unset distinction call `scalar` / `has_scalar`
6361    /// directly).
6362    pub(crate) fn get_variable(&self, name: &str) -> String {
6363        getsparam(name).unwrap_or_default()
6364    }
6365}
6366
6367// Source-form registration step used by the autoload-load path
6368// (`dispatch_function_call` / `run_function_body_only`). Decides
6369// whether to feed the file body to the funcdef pipeline VERBATIM
6370// (zsh-style: the body's own `function NAME() {...}` definition
6371// registers the function on execution) or to WRAP it in
6372// `NAME() {...}` (ksh-style or multi-statement: the body is just
6373// commands or includes additional statements past the def).
6374//
6375// The classification mirrors c:Src/exec.c:5725 + 5750 — KSHAUTOLOAD-
6376// equivalent vs zsh-style autoload. The structural check is the
6377// canonical `stripkshdef` (Src/exec.c:6291, ported at exec.rs:10548):
6378// parse the body to Eprog, run stripkshdef, and check whether it
6379// returned a stripped (different-length wordcode) Eprog. When it
6380// did, the file is the single-funcdef shape `[function] NAME [()] {
6381// INNER }`; running the file source directly through the funcdef
6382// pipeline registers NAME via the WC_FUNCDEF opcode at
6383// fusevm_bridge.rs:6330, matching C's `shf->funcdef = stripkshdef(
6384// prog, name)` semantics (the inner body becomes the function's
6385// body). When it didn't strip — single statement that isn't a
6386// funcdef, or multiple list nodes (e.g. `function ztm() {...}` +
6387// trailing `ztm "$@"` self-call) — we fall back to wrap-and-run so
6388// the canonical funcdef opcode still fires and any extra
6389// statements run inside the registered body, matching C's
6390// behavior of using the whole prog as funcdef in that case.
6391/// Restore `noaliases` on scope exit.
6392///
6393/// C's `loadautofn` (Src/exec.c:5684-5704) saves `noaliases`, sets it from the
6394/// function's PM_UNALIASED bit for the duration of the body parse, and restores
6395/// it unconditionally. The zshrs autoload block has early `return` paths, so the
6396/// restore has to ride on Drop rather than a trailing statement — otherwise a
6397/// `-U` autoload that failed to load would leave alias expansion disabled for
6398/// the rest of the shell.
6399struct NoAliasesRestore(bool);
6400
6401impl Drop for NoAliasesRestore {
6402    fn drop(&mut self) {
6403        crate::ported::lex::set_noaliases(self.0); // c:5704
6404    }
6405}
6406
6407/// c:Src/exec.c:5735 `loadautofnsetfile(shf, fdir)` + c:5751 — put the load
6408/// directory (and the absolute-path marker) back on a function whose body
6409/// zshrs just re-registered through the funcdef pipeline.
6410///
6411/// C loads an autoloaded body IN PLACE on the existing `Shfunc`, so
6412/// `filename`, PM_LOADDIR and PM_ABSPATH_USED all survive the load:
6413/// `shf->node.flags &= ~PM_UNDEFINED` (c:5751) is the only flag C clears.
6414/// zshrs re-registers the body as SOURCE through the WC_FUNCDEF pipeline,
6415/// which builds a FRESH node whose `filename` is the enclosing script and
6416/// whose flag word starts at zero. Both have to be reinstated, or `whence -v`
6417/// names the calling script instead of the definition file, and the
6418/// PM_LOADDIR|PM_ABSPATH_USED pair that `add_autoload_function`
6419/// (Src/builtin.c:3310-3323) tests — to hand a sibling `autoload -Uz NAME`
6420/// the caller's directory — is gone.
6421///
6422/// One helper rather than four inline copies: all four
6423/// `autoload_register_source` call sites need the identical restore.
6424///
6425/// `ksh_style` must be sampled BEFORE the re-registration (the fresh node's
6426/// flag word no longer carries PM_KSHSTORED / PM_ZSHSTORED, so re-deriving it
6427/// here would read the wrong answer): c:Src/exec.c:5792-5806 is the one arm
6428/// where C does NOT reload the body in place. It runs the whole FILE at top
6429/// level (`execode(prog, 1, 0, "evalautofunc")`, c:5795) and then REFETCHES
6430/// the node (`shf = shfunctab->getnode(shfunctab, n)`, c:5797) because the
6431/// file's own `NAME() { … }` created a brand-new Shfunc. There is no
6432/// `loadautofnsetfile` call on that arm, so zsh itself loses `filename`,
6433/// PM_LOADDIR and PM_ABSPATH_USED there. Verified against the reference shell:
6434///
6435/// ```text
6436/// $ zsh -f -c 'autoload -k /D/kw; kw'
6437/// ksh-kw ran
6438/// kw: ksib: function definition file not found
6439/// $ zsh -f -c 'autoload -k /D/kw; kw >/dev/null; whence -v kw'
6440/// kw is a shell function from zsh
6441/// ```
6442///
6443/// Restoring on that arm would make a ksh-autoloaded function inherit its
6444/// directory to siblings where zsh does not.
6445fn restore_loaddir(name: &str, dir: &str, abspath_used: bool, ksh_style: bool) {
6446    if ksh_style {
6447        return; // c:5792-5806 — no loadautofnsetfile on the ksh arm
6448    }
6449    if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
6450        if let Some(shf) = tab.get_mut(name) {
6451            crate::ported::exec::loadautofnsetfile(shf, Some(dir)); // c:5735
6452            if abspath_used {
6453                shf.node.flags |= crate::ported::zsh_h::PM_ABSPATH_USED as i32; // c:5751
6454            }
6455        }
6456    }
6457}
6458
6459/// c:Src/exec.c:5781 — `if (ksh == 2 || (ksh == 1 && isset(KSHAUTOLOAD)))`,
6460/// the ksh-style load branch. `ksh` derives from the stub's stored-style bits
6461/// per c:5762-5766 (`PM_KSHSTORED ? 2 : PM_ZSHSTORED ? 0 : 1`; a decisive
6462/// `.zwc` header flag was already folded into these bits by `loadautofn`).
6463///
6464/// Two zshrs steps need the same answer — `autoload_register_source` (wrap vs
6465/// verbatim) and `restore_loaddir` (whether the load kept the original node)
6466/// — so the decision lives in one place.
6467fn autoload_is_ksh_style(name: &str) -> bool {
6468    let flags = crate::ported::utils::getshfunc(name)
6469        .map(|f| f.node.flags as u32)
6470        .unwrap_or(0);
6471    let ksh = if flags & crate::ported::zsh_h::PM_KSHSTORED != 0 {
6472        2 // c:5765
6473    } else if flags & crate::ported::zsh_h::PM_ZSHSTORED != 0 {
6474        0 // c:5766
6475    } else {
6476        1 // c:5766
6477    };
6478    ksh == 2 || (ksh == 1 && crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHAUTOLOAD))
6479    // c:5781
6480}
6481
6482/// The cache key for an autoloaded function: `(resolved fpath dir,
6483/// SHA-256 of the definition text)`, or `None` when the directory
6484/// cannot be pinned down.
6485///
6486/// `loadautofn` records the resolved fpath directory on the shfunc
6487/// (`filename` + `PM_LOADDIR`, c:Src/exec.c:5657); a function whose
6488/// `filename` is still the placeholder `"zsh"` was not resolved through
6489/// `$fpath` and is not cached.
6490///
6491/// The hash is over `registered` — the exact string about to be
6492/// compiled — and NOT over a `stat` of `<dir>/<name>`. Those are not the
6493/// same thing: `getfpfunc` prefers a `<dir>.zwc` digest over the plain
6494/// file whenever the digest is newer (c:Src/parse.c:3771-3777), so the
6495/// body being installed may have no relationship to the bytes of the
6496/// file that path names. Stamping the path let a chunk built from one
6497/// text be served for another.
6498///
6499/// `from_wordcode` salts the digest because it changes what the same bytes
6500/// COMPILE TO: a wordcode-derived body is compiled under [`ZwcRelexGuard`],
6501/// so it resolves quotes and aliases the way `zcompile` did, while a
6502/// plain-file body of those same bytes resolves them against the live
6503/// RCQUOTES / alias state. One cache line must never be served for both.
6504fn autoload_source_key(
6505    name: &str,
6506    registered: &str,
6507    from_wordcode: bool,
6508) -> Option<(String, [u8; 32])> {
6509    let dir = crate::ported::utils::getshfunc(name)
6510        .and_then(|f| f.filename)
6511        .filter(|d| d != "zsh")?;
6512    let sha = if from_wordcode {
6513        crate::autoload_cache::source_digest(&format!("\0zwc\0{registered}"))
6514    } else {
6515        crate::autoload_cache::source_digest(registered)
6516    };
6517    Some((dir, sha))
6518}
6519
6520/// Returns the text to run to install `name`, plus whether that text came out
6521/// of a `.zwc` (see [`autoload_note_wordcode_body`]) and therefore has to be
6522/// lexed under [`ZwcRelexGuard`] wherever it is lexed.
6523fn autoload_register_source(name: &str, body: &str) -> (String, bool) {
6524    // c:Src/exec.c:5725 `stripkshdef(prog, …)` — the ksh-vs-zsh wrap decision
6525    // below PARSES `body`, so it is itself a second lex of the deparse and
6526    // needs the same pin as the compile that follows it.
6527    let from_wordcode = autoload_body_from_wordcode(name, body);
6528    let _relex = from_wordcode.then(ZwcRelexGuard::enter);
6529    (
6530        autoload_definition_source(name, body, autoload_is_ksh_style(name)),
6531        from_wordcode,
6532    )
6533}
6534
6535/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
6536///
6537/// Pins the lexer to the spelling a `.zwc` deparse was written in, for the
6538/// duration of one compile.
6539///
6540/// C never needs this: the compiled arm of `source()` is `execode(prog, 1,
6541/// 0, "filecode")` (c:Src/init.c:1621) and the ksh-autoload arm is
6542/// `execode(prog, 1, 0, "evalautofunc")` (c:Src/exec.c:5795) — the wordcode
6543/// runs as it stands and NOTHING is lexed. A `.zwc` is resolved once, at
6544/// `zcompile` time. zshrs has no execute-the-wordcode path: it deparses back
6545/// to source with `getpermtext` and lexes that, so every piece of LEXER-TIME
6546/// state that C bypassed has to be neutralised by hand, or the second lex
6547/// resolves the text differently from the first.
6548///
6549/// Two such pieces are known to change the result:
6550///
6551///   * **RCQUOTES.** `untokenize` (c:Src/exec.c:2134) renders every quote
6552///     null through `ztokens[Snull - Pound]`, and that entry is a bare
6553///     single quote (c:Src/lex.c:38), so a closing null followed by an
6554///     opening one deparses to two adjacent quotes. Under RCQUOTES the lexer
6555///     reads that pair inside a quoted word as one LITERAL quote
6556///     (c:Src/lex.c:1328) rather than as two delimiters. `zsh-expand`'s
6557///     plugin entry does `setopt rcquotes` at its line 39, so every later
6558///     `.zwc`-sourced alias with adjacent quoted segments — the whole
6559///     `zsh-openshift-aliases` set — gained literal quotes zsh never had.
6560///
6561///   * **Aliases.** `checkalias` (c:Src/lex.c:1909) fires from the lexer, so
6562///     an alias installed before the `source` rewrote words INSIDE the
6563///     compiled program: with `alias mycmd=…` live, a `.zwc` whose second
6564///     line is `mycmd` ran the alias where zsh runs the function. Global
6565///     aliases are worse — they rewrite any word, so `print -r -- GA: x`
6566///     became `print -r -- GA: LEAKED` under `alias -g x=LEAKED`. This is
6567///     C's `noaliases` (c:Src/lex.c:135), the same switch `par_case` uses to
6568///     keep `in` from being alias-expanded.
6569///
6570/// Both are restored on drop, so the restore survives a panic out of the
6571/// compiler — and, more importantly, the program's own RUNTIME lexing is
6572/// unaffected: a `setopt rcquotes` the `.zwc` performs still takes effect
6573/// and still outlives the source, and a function or `eval` body it runs is
6574/// lexed later, against the live alias table.
6575///
6576/// One residual case is out of reach here and needs a real
6577/// execute-the-wordcode path: a `.zwc` COMPILED while RCQUOTES was set holds
6578/// an unescaped literal quote in its tokenized word (c:Src/lex.c:1329 adds
6579/// the character with no `Bnull` prefix), and `untokenize` cannot tell that
6580/// apart from a quote null. No option state at re-lex time recovers it.
6581///
6582/// `noaliases` is a thread-local (`LEX_NOALIASES`), but the option store is
6583/// process-wide, so the RCQUOTES window is visible to a worker thread that
6584/// lexes concurrently. The window is one synchronous compile.
6585pub(crate) struct ZwcRelexGuard {
6586    rcquotes: bool,
6587    noaliases: bool,
6588}
6589
6590impl ZwcRelexGuard {
6591    pub(crate) fn enter() -> Self {
6592        let rcquotes = crate::ported::zsh_h::isset(crate::ported::zsh_h::RCQUOTES);
6593        if rcquotes {
6594            crate::ported::options::opt_state_set("rcquotes", false);
6595        }
6596        let noaliases = crate::ported::lex::noaliases();
6597        crate::ported::lex::set_noaliases(true); // c:Src/lex.c:1909
6598        Self {
6599            rcquotes,
6600            noaliases,
6601        }
6602    }
6603}
6604
6605impl Drop for ZwcRelexGuard {
6606    fn drop(&mut self) {
6607        if self.rcquotes {
6608            crate::ported::options::opt_state_set("rcquotes", true);
6609        }
6610        crate::ported::lex::set_noaliases(self.noaliases);
6611    }
6612}
6613
6614/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
6615///
6616/// The RCQUOTES state each shell-defined function's body was resolved
6617/// under at DEFINITION time, keyed by name and digested body text.
6618///
6619/// C has no such fact to carry. `execfuncdef` compiles the body into
6620/// wordcode when the definition runs (c:Src/exec.c:5389-5391 —
6621/// `shf->funcdef = dupeprog(...)`), and `printshfuncnode` renders THAT
6622/// program back with `getpermtext(f->funcdef, NULL, 1)`
6623/// (c:Src/hashtable.c:954). Every lexer-time decision is already settled
6624/// in the wordcode, so printing cannot revisit it — `functions f` gives
6625/// the same text no matter which options are set when it runs.
6626///
6627/// zshrs has no `Eprog` for a shell-defined function: `shfunc::body` keeps
6628/// the raw source and `printshfuncnode`'s stand-in RE-LEXES it at print
6629/// time. Under RCQUOTES an adjacent quote pair inside a quoted word lexes
6630/// as one LITERAL quote (c:Src/lex.c:1328) instead of as two delimiters,
6631/// so one unchanged function deparsed two different ways across a
6632/// `setopt rcquotes` — docs/BUGS.md #1105. Pinning the print-time lex to
6633/// defaults is wrong in the other direction: a function defined WHILE
6634/// RCQUOTES was set has to keep printing the RCQUOTES resolution, which is
6635/// why the state has to be recorded rather than assumed.
6636///
6637/// The state is sampled where the definition INSTALLS (the
6638/// `BUILTIN_REGISTER_COMPILED_FN` handler), not where its body was lexed.
6639/// In C those are the same instant, because zsh lexes one command list at
6640/// a time as it runs it; zshrs compiles a whole script ahead of running
6641/// it, so a runtime `setopt rcquotes` on an earlier line of the same file
6642/// is already in force at install time but was NOT in force at the ahead
6643/// -of-time lex. Install time is the one that matches zsh's printed
6644/// output (`setopt rcquotes` on line 1, `f() { … }` on line 2 →
6645/// C prints the RCQUOTES resolution), and it collapses onto lex time the
6646/// day the compiler stops running ahead of execution.
6647///
6648/// The mark is a DIGEST of the body rather than a bare flag, exactly like
6649/// [`AUTOLOAD_WORDCODE_BODY`]: `functions[name]=…`, an autoload, and a
6650/// plain redefinition all install different text, and a stale entry
6651/// simply stops matching instead of pinning the lexer for a body it never
6652/// described.
6653static FUNCDEF_LEX_RCQUOTES: Mutex<Option<HashMap<String, ([u8; 32], bool)>>> = Mutex::new(None);
6654
6655/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
6656///
6657/// Record the RCQUOTES state `name`'s definition was installed under.
6658/// See [`FUNCDEF_LEX_RCQUOTES`]; called from the funcdef install path
6659/// (c:Src/exec.c:5389 `shf->funcdef = …`, where C bakes the same fact
6660/// into the wordcode instead).
6661pub(crate) fn funcdef_note_rcquotes(name: &str, body: &str, rcquotes: bool) {
6662    FUNCDEF_LEX_RCQUOTES
6663        .lock()
6664        .get_or_insert_with(HashMap::new)
6665        .insert(
6666            name.to_string(),
6667            (crate::autoload_cache::source_digest(body), rcquotes),
6668        );
6669}
6670
6671/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
6672///
6673/// Pin RCQUOTES to the value `name`'s body was defined under, for the
6674/// duration of one deparse of that body.
6675///
6676/// This is [`ZwcRelexGuard`]'s sibling: same problem (zshrs lexes text
6677/// where C runs wordcode), same RAII shape, but the target value is the
6678/// RECORDED one rather than "off" — the definition may itself have been
6679/// made under RCQUOTES, and then the RCQUOTES resolution is the correct
6680/// one to print. With no record for this exact body (a function installed
6681/// through a path that does not record — `functions[f]=…`, the ported
6682/// walker, an autoload stub) nothing is pinned and the live option stands,
6683/// which is the behaviour that predates the record.
6684///
6685/// The option store is process-wide, so the pin is visible to a worker
6686/// thread that lexes concurrently; the window is one synchronous deparse,
6687/// as it is for [`ZwcRelexGuard`].
6688pub(crate) fn funcdef_lex_pin(name: &str, body: &str) -> FuncdefLexPin {
6689    let want = {
6690        let slot = FUNCDEF_LEX_RCQUOTES.lock();
6691        slot.as_ref()
6692            .and_then(|map| map.get(name))
6693            .filter(|(sha, _)| *sha == crate::autoload_cache::source_digest(body))
6694            .map(|(_, rcquotes)| *rcquotes)
6695    };
6696    let live = crate::ported::zsh_h::isset(crate::ported::zsh_h::RCQUOTES);
6697    match want {
6698        Some(w) if w != live => {
6699            crate::ported::options::opt_state_set("rcquotes", w);
6700            FuncdefLexPin { restore: Some(live) }
6701        }
6702        _ => FuncdefLexPin { restore: None },
6703    }
6704}
6705
6706/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
6707///
6708/// RAII half of [`funcdef_lex_pin`]. `restore` is `None` when nothing was
6709/// changed, so the common path costs one hash lookup and no option write.
6710pub(crate) struct FuncdefLexPin {
6711    restore: Option<bool>,
6712}
6713
6714impl Drop for FuncdefLexPin {
6715    fn drop(&mut self) {
6716        if let Some(live) = self.restore {
6717            crate::ported::options::opt_state_set("rcquotes", live);
6718        }
6719    }
6720}
6721
6722/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
6723///
6724/// Names whose installed autoload body was rendered back from WORDCODE
6725/// (`getpermtext` over a `.zwc` program) rather than read from a source file,
6726/// each mapped to a digest of that body.
6727///
6728/// C has no such fact to carry. `loadautofn` hands the dump's program
6729/// straight to `shf->funcdef = stripkshdef(prog, …)` (c:Src/exec.c:5753-5755),
6730/// and the ksh-style arm runs it with `execode(prog, 1, 0, "evalautofunc")`
6731/// (c:Src/exec.c:5795): a dump-loaded funcdef is never lexed again, at load
6732/// time or ever. zshrs executes function bodies as TEXT, so `loadautofn`
6733/// deparses the program and the REAL compile happens later — at the call that
6734/// installs the function, through [`ShellExecutor::run_autoload_definition`].
6735/// The two are separated by arbitrary user code, so "this text came from
6736/// wordcode" has to survive the gap or the deparse is re-lexed against
6737/// whatever RCQUOTES / alias state is live at call time. See
6738/// [`ZwcRelexGuard`] for exactly what that costs; the `source` leg of the same
6739/// bug is fixed by [`ShellExecutor::execute_zwc_program`].
6740///
6741/// The mark is a DIGEST of the body, not a bare flag, so it self-invalidates.
6742/// `functions[name]=…` (c:Src/Modules/parameter.c setpmfunction) and a later
6743/// load of the same name from a plain file both install different text, and
6744/// the digest simply stops matching — no writer anywhere else has to know this
6745/// table exists, and a stale entry cannot pin the lexer for a body that was
6746/// never wordcode.
6747static AUTOLOAD_WORDCODE_BODY: Mutex<Option<HashMap<String, [u8; 32]>>> = Mutex::new(None);
6748
6749/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
6750///
6751/// Record (`Some`) or drop (`None`) the "body came from wordcode" mark for
6752/// `name`. Called from `loadautofn` (c:Src/exec.c:5682) on every load, so a
6753/// plain-file load clears a mark an earlier dump load left.
6754pub(crate) fn autoload_note_wordcode_body(name: &str, body: Option<&str>) {
6755    let mut slot = AUTOLOAD_WORDCODE_BODY.lock();
6756    match body {
6757        Some(text) => {
6758            slot.get_or_insert_with(HashMap::new)
6759                .insert(name.to_string(), crate::autoload_cache::source_digest(text));
6760        }
6761        None => {
6762            if let Some(map) = slot.as_mut() {
6763                map.remove(name);
6764            }
6765        }
6766    }
6767}
6768
6769/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
6770///
6771/// True when `body` is the exact text [`autoload_note_wordcode_body`] recorded
6772/// for `name` — i.e. this is a `.zwc` deparse about to be lexed a second time.
6773pub(crate) fn autoload_body_from_wordcode(name: &str, body: &str) -> bool {
6774    let slot = AUTOLOAD_WORDCODE_BODY.lock();
6775    slot.as_ref()
6776        .and_then(|map| map.get(name))
6777        .is_some_and(|sha| *sha == crate::autoload_cache::source_digest(body))
6778}
6779
6780/// The exact source text an autoload of `name` installs — either the
6781/// file body verbatim (ksh style, or a file that already defines the
6782/// function) or `name() { <body> }`.
6783///
6784/// Split out of [`autoload_register_source`] with the ksh decision
6785/// passed IN so the prewarm (`autoload_prewarm`, which has no shfunc
6786/// flags to consult because nothing is registered yet) compiles
6787/// byte-identical text to what the loader will run. The two drifting
6788/// apart is precisely what made the pre-v2 shard unusable: it cached a
6789/// different program than the one the loader installs.
6790pub(crate) fn autoload_definition_source(name: &str, body: &str, ksh_style: bool) -> String {
6791    // c:Src/exec.c:5781 — a ksh-style load executes the file contents at top
6792    // level (c:5795 `execode(prog, 1, 0, "evalautofunc")`) and expects the
6793    // file itself to define the function — so the body goes through the
6794    // pipeline VERBATIM, never wrapped.
6795    if ksh_style {
6796        return body.to_string(); // c:5795 execode(prog, ..., "evalautofunc")
6797    }
6798    let stripped = crate::ported::exec::parse_string(body, 0)
6799        .map(|prog| {
6800            let original_len = prog.prog.len();
6801            // stripkshdef returns the input untouched when the prog
6802            // doesn't match the single-`function NAME` shape, and a
6803            // shorter (body-only) prog when it does. Compare the
6804            // wordcode length to detect the strip without owning the
6805            // post-strip Eprog (we only need the yes/no answer here).
6806            let prog_box = Box::new(prog);
6807            crate::ported::exec::stripkshdef(Some(prog_box), name)
6808                .map(|p| p.prog.len() != original_len)
6809                .unwrap_or(false)
6810        })
6811        .unwrap_or(false);
6812    if stripped {
6813        body.to_string()
6814    } else {
6815        format!("{name}() {{\n{body}\n}}")
6816    }
6817}
6818
6819// zsh_eval_context push/pop/sync relocated 2026-06-12 INTO doshfunc
6820// (src/ported/exec.rs) — its sole caller, and `zsh_eval_context` is
6821// that module's own static. The shell-visible mirror writes inline
6822// at the push site + the guard's Drop. No bridge indirection.
6823
6824impl ShellExecutor {
6825    /// Execute the trap body for a signal name from the REPL signal
6826    /// loop (bins/zshrs.rs CtrlC/CtrlD dispatch). Thin passthru to
6827    /// `traps_table` lookup + `execute_script` — kept as a method
6828    /// because the REPL loop owns `&mut ShellExecutor` and needs a
6829    /// single call point. The async signal-handler dispatch path
6830    /// goes through `crate::ported::signals::dotrap` instead.
6831    pub fn run_trap(&mut self, signal: &str) {
6832        let action = crate::ported::builtin::traps_table()
6833            .lock()
6834            .ok()
6835            .and_then(|t| t.get(signal).cloned());
6836        if let Some(body) = action {
6837            if !body.is_empty() {
6838                let _ = self.execute_script(&body);
6839            }
6840        }
6841    }
6842}
6843
6844impl ShellExecutor {
6845    pub(crate) fn apply_prompt_theme(&mut self, theme: &str, preview: bool) {
6846        let (ps1, rps1) = match theme {
6847            "minimal" => ("%# ", ""),
6848            "off" => ("$ ", ""),
6849            "adam1" => (
6850                "%B%F{cyan}%n@%m %F{blue}%~%f%b %# ",
6851                "%F{yellow}%D{%H:%M}%f",
6852            ),
6853            "redhat" => ("[%n@%m %~]$ ", ""),
6854            _ => ("%n@%m %~ %# ", ""),
6855        };
6856        if preview {
6857            println!("PS1={:?}", ps1);
6858            println!("RPS1={:?}", rps1);
6859        } else {
6860            self.set_scalar("PS1".to_string(), ps1.to_string());
6861            self.set_scalar("RPS1".to_string(), rps1.to_string());
6862            self.set_scalar("prompt_theme".to_string(), theme.to_string());
6863        }
6864    }
6865}
6866impl ShellExecutor {
6867    /// Expand glob pattern via canonical `glob_path` (port of
6868    /// `Src/glob.c::zglob`). Adds executor-side `current_command_glob_failed`
6869    /// cell so the dispatch layer skips the current command on NOMATCH +
6870    /// looks_like_glob instead of exiting the shell.
6871    pub fn expand_glob(&self, pattern: &str) -> Vec<String> {
6872        let expanded = glob_path(pattern);
6873        if !expanded.is_empty() {
6874            // c:Src/glob.c:1871-1872 — `if (matchct) badcshglob |= 2;`
6875            // (at least one expansion on this command line worked).
6876            // Only real glob patterns count — C's zglob early-returns
6877            // before the matchct accounting for non-wild words, so
6878            // gate on haswilds like the failure path below. Consumed
6879            // per command by fusevm_bridge::consume_badcshglob.
6880            if crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLGLOB) {
6881                let mut pattern_tok = pattern.to_string();
6882                crate::ported::glob::tokenize(&mut pattern_tok);
6883                if crate::ported::pattern::haswilds(&pattern_tok) {
6884                    crate::ported::glob::BADCSHGLOB
6885                        .fetch_or(2, std::sync::atomic::Ordering::Relaxed);
6886                }
6887            }
6888            return expanded;
6889        }
6890        // c:Src/glob.c:1786-1788 — `if (errflag) { restore_globstate(saved);
6891        // return; }`. A qualifier-parse error returns from `zglob` outright,
6892        // so C never reaches the c:1873-1886 nullglob/nomatch dispatch below.
6893        // The port has to re-derive `gf_nullglob` from the pattern because
6894        // `glob_path` hands back only a `Vec` — and that SECOND qualifier
6895        // parse re-runs every diagnostic the first one already emitted. It is
6896        // normally invisible because `zerr` suppresses itself while
6897        // ERRFLAG_ERROR is set (c:Src/utils.c:175), but a subscript qualifier
6898        // runs the lexer (`getindex` → `parse_subscript` → `strinbeg` →
6899        // `hbegin`, c:Src/hist.c:1115 `errflag &= ~ERRFLAG_ERROR`), which
6900        // clears exactly that bit — so `*(N[1,])` printed `bad math
6901        // expression: empty string` twice. Bail out where C's `return` lands.
6902        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
6903            return Vec::new();
6904        }
6905        // No matches. Mirror zsh's `setopt nullglob` / `nomatch`
6906        // dispatch (Src/glob.c:1873-1886) here because glob_path
6907        // returns an empty Vec without knowing executor state.
6908        // c:Src/glob.c:1567-1569 `gf_nullglob` per-glob — the `(N)`
6909        // qualifier acts like `setopt nullglob` for this expression
6910        // alone. parse_qualifiers detects the suffix `(...)` block;
6911        // the resulting `qualifiers.nullglob` mirrors C's gf_nullglob
6912        // carrier.
6913        let per_glob_nullglob = crate::ported::glob::parse_qualifiers(pattern)
6914            .1
6915            .map(|q| q.nullglob)
6916            .unwrap_or(false);
6917        let nullglob = opt_state_get("nullglob").unwrap_or(false) || per_glob_nullglob;
6918        if nullglob {
6919            // c:Src/glob.c:1888-1894 —
6920            //   `else if (in_expandredir) {`
6921            //     `/* if completing for redirection, we can't remove the`
6922            //     `   pattern even if NULL_GLOB is in effect */`
6923            //     `zerr("redirection failed (no match): %s", ostr);`
6924            //     `zfree(matchbuf, 0);`
6925            //     `restore_globstate(saved);`
6926            //     `return;`
6927            //   `}`
6928            // Reached ONLY when gf_nullglob is set (the `else if` chain at
6929            // c:1873 owns every other no-match case), which is exactly the
6930            // `> file(N)` shape: dropping the word would leave the
6931            // redirection with no target at all, so `echo > nope(N)` failed
6932            // with an empty filename in `no such file or directory:`.
6933            if crate::ported::glob::IN_EXPANDREDIR.load(std::sync::atomic::Ordering::SeqCst) != 0 {
6934                zerr(&format!(
6935                    "redirection failed (no match): {}",
6936                    crate::ported::lex::untokenize(pattern)
6937                )); // c:1891
6938                self.current_command_glob_failed.set(true);
6939                return Vec::new(); // c:1894
6940            }
6941            return Vec::new();
6942        }
6943        let nomatch = opt_state_get("nomatch").unwrap_or(true);
6944        // Use canonical `haswilds` (port of Src/pattern.c:4306-4376)
6945        // instead of the Rust-only `looks_like_glob`. C zsh's
6946        // `Src/glob.c:1876` NOMATCH branch fires whenever the input
6947        // tripped haswilds during the `zglob` entry check —
6948        // including patterns whose internal `(` / `)` form a group
6949        // or alternation but don't end with `)` (e.g. `abc(a)def`,
6950        // `(abc`). The previous `looks_like_glob` only caught
6951        // trailing-`(...)` qualifiers, leaving mid-word groups and
6952        // unclosed parens to fall through to the literal-passthrough
6953        // branch. #170 in docs/BUGS.md.
6954        //
6955        // haswilds scans TOKENIZED strings (C's zglob gets the
6956        // lexer-tokenized word at Src/glob.c:1230); this entry point
6957        // receives untokenized fast-path patterns, so tokenize a
6958        // local copy first — the same preparation C applies to
6959        // runtime-built strings (compcore.c:2231 tokenizes fignore
6960        // entries before its haswilds call). tokenize Bnull's
6961        // backslash-escaped metachars, so `\*` stays literal here
6962        // exactly as in C. Bug #627: plain multibyte text (`↔`)
6963        // passes through tokenize unchanged and matches no token.
6964        let mut pattern_tok = pattern.to_string();
6965        crate::ported::glob::tokenize(&mut pattern_tok); // c:Src/glob.c:3548
6966        let is_glob = crate::ported::pattern::haswilds(&pattern_tok);
6967        // c:Src/glob.c:1874-1875 — `if (isset(CSHNULLGLOB)) {
6968        // badcshglob |= 1; }` — the else-if chain means neither the
6969        // NOMATCH error nor the literal passthrough runs: the failed
6970        // word is silently DROPPED here, and the per-command boundary
6971        // (fusevm_bridge::consume_badcshglob, Src/subst.c:505-507)
6972        // emits the csh-style `no match` iff NO glob on the line
6973        // matched.
6974        if is_glob && crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLGLOB) {
6975            crate::ported::glob::BADCSHGLOB.fetch_or(1, std::sync::atomic::Ordering::Relaxed);
6976            return Vec::new();
6977        }
6978        if nomatch && is_glob {
6979            // c:Src/glob.c:1876-1880 — `else if (isset(NOMATCH)) {`
6980            //   `zerr("no matches found: %s", ostr);`
6981            //   `zfree(matchbuf, 0);`
6982            //   `restore_globstate(saved);`
6983            //   `return;`
6984            // `}`
6985            // C aborts via ERRFLAG_ERROR set by zerr() at c:Src/utils.c
6986            // and the matchbuf/state cleanup. The Rust port mirrors
6987            // both: zerr() in utils.rs sets ERRFLAG_ERROR via
6988            // `errflag.fetch_or(ERRFLAG_ERROR, ...)` already; we then
6989            // re-set explicitly (defensive — historically this line
6990            // had `fetch_and(!ERRFLAG_ERROR)` which CLEARED the flag
6991            // immediately after zerr, making `echo /never/*` print
6992            // the literal and exit 0 instead of erroring like zsh —
6993            // parity bug #13).
6994            // c:1877 `zerr("no matches found: %s", ostr);` — `ostr` is
6995            // the TOKENIZED word, and zerrmsg's `%s` arm renders it
6996            // through `nicezputs` → `sb_niceformat`, which calls
6997            // `untokenize(ums)` (Src/utils.c). Without that step the
6998            // token bytes are dropped by the terminal and the message
6999            // reads `no matches found: /tmp/nope_.txt`.
7000            zerr(&format!(
7001                "no matches found: {}",
7002                crate::ported::lex::untokenize(pattern)
7003            )); // c:1877
7004            self.current_command_glob_failed.set(true);
7005            // c:Src/glob.c:1876-1880 — zerr sets ERRFLAG_ERROR and
7006            // glob_failed cell carries the signal. The ERRFLAG_ERROR
7007            // clear (so subsequent sublists run) now lives at the
7008            // dispatcher's post-command-boundary at
7009            // fusevm_bridge.rs:299 where current_command_glob_failed
7010            // is consumed — matches C's execlist behavior of clearing
7011            // command-error errflag between sublists.
7012            return Vec::new(); // c:1880 return
7013        }
7014        // Pattern has no glob meta — pass through literally.
7015        // c:Src/glob.c:1882-1886 — `/* treat as an ordinary string */
7016        // untokenize(matchptr->name = dupstring(ostr));`. The word
7017        // arrives here in LEXER-TOKENIZED form (c:1221 `ostr =
7018        // getdata(np)`), so the literal fallback MUST untokenize or the
7019        // raw token bytes reach stdout: `unsetopt nomatch; echo
7020        // /tmp/nope_*.txt` printed `/tmp/nope_\u{87}.txt`.
7021        vec![crate::ported::lex::untokenize(pattern)]
7022    }
7023    /// True iff the literal `pattern` actually contains a glob metachar
7024    /// in a position that would have triggered globbing. Used to avoid
7025    /// spurious "no matches" errors when expand_glob is called on a
7026    /// plain path that happened to route through this code (e.g. some
7027    /// fast paths bridge unconditionally).
7028    pub(crate) fn looks_like_glob(pattern: &str) -> bool {
7029        // A trailing `(qualifier)` is itself a glob trigger — e.g.
7030        // `path(L+10)` should be treated as a glob even when the
7031        // body has no `*`/`?`/`[...]`.
7032        let has_qual_suffix = if let Some(open) = pattern.rfind('(') {
7033            pattern.ends_with(')') && open + 1 < pattern.len() - 1
7034        } else {
7035            false
7036        };
7037        // Strip trailing `(...)` qualifier so we test the pattern body.
7038        let body = if let Some(open) = pattern.rfind('(') {
7039            if pattern.ends_with(')') {
7040                &pattern[..open]
7041            } else {
7042                pattern
7043            }
7044        } else {
7045            pattern
7046        };
7047        // Walk character-by-character so escaped metachars (`\*`, `\?`,
7048        // `\[`) are NOT counted as glob triggers. zsh: `echo \*` prints
7049        // a literal `*`; without the unescaped check, looks_like_glob
7050        // returned true on the bare `*` and the runtime glob expansion
7051        // aborted with NOMATCH.
7052        let chars: Vec<char> = body.chars().collect();
7053        let mut i = 0;
7054        let mut has_unescaped_star = false;
7055        let mut has_unescaped_question = false;
7056        let mut has_unescaped_bracket_open: Option<usize> = None;
7057        while i < chars.len() {
7058            let c = chars[i];
7059            if c == '\\' && i + 1 < chars.len() {
7060                // Escaped char — skip both.
7061                i += 2;
7062                continue;
7063            }
7064            match c {
7065                '*' => has_unescaped_star = true,
7066                '?' => has_unescaped_question = true,
7067                '[' if has_unescaped_bracket_open.is_none() => {
7068                    has_unescaped_bracket_open = Some(i);
7069                }
7070                _ => {}
7071            }
7072            i += 1;
7073        }
7074        // `[` only counts when there's a matching `]` after it.
7075        let has_bracket_class = has_unescaped_bracket_open
7076            .map(|i| body[i + 1..].contains(']'))
7077            .unwrap_or(false);
7078        // `<N-M>` numeric range glob is also a trigger — match shape
7079        // `<` + optional digits + `-` + optional digits + `>` outside
7080        // any bracket expression.
7081        let has_numeric_range =
7082            body.contains('<') && body.contains('>') && !extract_numeric_ranges(body).is_empty();
7083        has_unescaped_star
7084            || has_unescaped_question
7085            || has_bracket_class
7086            || has_qual_suffix
7087            || has_numeric_range
7088    }
7089}
7090
7091impl ShellExecutor {
7092    pub(crate) fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
7093        if !dest.exists() {
7094            fs::create_dir_all(dest)?;
7095        }
7096        for entry in fs::read_dir(src)? {
7097            let entry = entry?;
7098            let file_type = entry.file_type()?;
7099            let src_path = entry.path();
7100            let dest_path = dest.join(entry.file_name());
7101
7102            if file_type.is_dir() {
7103                Self::copy_dir_recursive(&src_path, &dest_path)?;
7104            } else {
7105                fs::copy(&src_path, &dest_path)?;
7106            }
7107        }
7108        Ok(())
7109    }
7110}
7111
7112// Magic-assoc scan-by-name aggregator. C's per-table getfn/scanfn
7113// pointers in paramdef[] (Src/Modules/parameter.c:825+) handle this
7114// indirectly via paramtab dispatch; this Rust-only helper exposes a
7115// single `partab_get` / `partab_scan_keys` entry that the bridge
7116// uses for name → keys lookup.
7117use std::cell::RefCell;
7118thread_local! {
7119    static SCAN_KEYS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
7120}
7121
7122/// Lookup helper for `${name[key]}` magic-assoc reads — dispatches
7123/// through canonical `PARTAB` (Src/Modules/parameter.c:2235 ports).
7124/// Returns `None` if name isn't a known magic-assoc.
7125/// Module parameters that have actually been touched this session.
7126///
7127/// zshrs-original bookkeeping for a C behavior that falls out of the module
7128/// system there. zsh registers `zsh/parameter`'s params (`aliases`,
7129/// `commands`, `functions`, …) as PM_AUTOLOAD stubs in `realparamtab`;
7130/// touching ONE of them materializes only that name — its siblings stay
7131/// stubs even though the module is now loaded. `paramtypestr`
7132/// (Src/Modules/parameter.c:49-50) reports a PM_AUTOLOAD node as
7133/// "undefined", which is what an enumeration of `$parameters` shows.
7134/// zshrs seeds all of them eagerly (init_partab_params), so without this
7135/// set every one reported its real type and `${(@k)parameters[(R)a*]}`
7136/// matched 56 names against zsh's 18 — putting them in the wrong
7137/// `_parameters -g` bucket (`unset <TAB>`: 418 entries vs zsh's 496).
7138static MATERIALIZED_MODULE_PARAMS: std::sync::OnceLock<Mutex<HashSet<String>>> =
7139    std::sync::OnceLock::new();
7140
7141/// Record that `name` was read/written, so `$parameters` stops reporting it
7142/// as an unmaterialized autoload stub. See [`MATERIALIZED_MODULE_PARAMS`].
7143pub fn mark_module_param_used(name: &str) {
7144    let set = MATERIALIZED_MODULE_PARAMS.get_or_init(|| Mutex::new(HashSet::new()));
7145    let first_touch = {
7146        let mut g = set.lock();
7147        // Drop the guard before the module load below — `boot_` runs shell
7148        // code (setsparam/setiparam) that can re-enter this function.
7149        g.insert(name.to_string())
7150    };
7151    if first_touch {
7152        materialize_module_param(name);
7153    }
7154}
7155
7156/// The side effect C's `loadparamnode` (`Src/params.c:563-585`) has beyond
7157/// clearing PM_AUTOLOAD: `(void)ensurefeature(mn, "p:", nam)`
7158/// (`Src/module.c:3419-3432`) actually LOADS the owning module, running its
7159/// `setup_`/`boot_`. `zsh/watch`'s `boot_` (`Src/Modules/watch.c:750-753`)
7160/// seeds `WATCHFMT`/`LOGCHECK` when absent, so in zsh
7161/// `${parameters[watch]}` leaves `${parameters[LOGCHECK]}` == "integer".
7162///
7163/// !!! WARNING: RUST-ONLY HELPER !!!
7164/// C has no separate function: `loadparamnode` calls `ensurefeature`
7165/// inline. zshrs models PM_AUTOLOAD as a side-set rather than a node flag
7166/// (see [`MATERIALIZED_MODULE_PARAMS`]), so the load side effect needs its
7167/// own hook off the marking point. The `try_lock` and the re-entrancy guard
7168/// are also Rust-only: C serialises on `queue_signals`, whereas zshrs's
7169/// `MODULESTAB` is a real mutex that several callers of
7170/// `mark_module_param_used` already hold.
7171fn materialize_module_param(name: &str) {
7172    use std::cell::Cell;
7173    thread_local! {
7174        static LOADING: Cell<bool> = const { Cell::new(false) };
7175    }
7176    // c:Src/params.c:566 — only PM_AUTOLOAD stubs carry `pm->u.str` (the
7177    // owning module name); anything else falls straight through.
7178    let Some((_, modname)) = AUTOLOAD_PARAMS.iter().find(|(p, _)| *p == name) else {
7179        return;
7180    };
7181    if LOADING.with(|f| f.get()) {
7182        return;
7183    }
7184    // The whole load chain wants `&mut modulestab`. Every other caller takes
7185    // the same lock for a moment; if one of them is mid-flight (or is our own
7186    // caller), skip rather than deadlock — the mark itself already landed.
7187    let Ok(mut tab) = crate::ported::module::MODULESTAB.try_lock() else {
7188        return;
7189    };
7190    // c:Src/module.c:2352 — require_module short-circuits on an already
7191    // booted module, but check first so the common case never pays for the
7192    // find_module/alias walk.
7193    if tab
7194        .modules
7195        .get(*modname)
7196        .is_some_and(|m| (m.node.flags & crate::ported::zsh_h::MOD_INIT_B) != 0)
7197    {
7198        return;
7199    }
7200    LOADING.with(|f| f.set(true));
7201    // c:3419-3432 ensurefeature(mn, "p:", nam) — `silent` is 0 in C, but a
7202    // failure here is not user-visible in the autoload path (c:571-580 only
7203    // errors when the parameter is still undefined afterwards), and zshrs's
7204    // require_module warns on any module it cannot static-link.
7205    let _ = crate::ported::module::ensurefeature(&mut tab, modname, "p:", Some(name)); // c:3419
7206    LOADING.with(|f| f.set(false));
7207}
7208
7209/// True when `name` is still an untouched module-parameter stub — i.e. zsh
7210/// would report it as PM_AUTOLOAD ("undefined") when enumerating
7211/// `$parameters`. See [`MATERIALIZED_MODULE_PARAMS`].
7212pub fn module_param_is_autoload_stub(name: &str) -> bool {
7213    if !AUTOLOAD_PARAMS.iter().any(|(p, _)| *p == name) {
7214        return false;
7215    }
7216    MATERIALIZED_MODULE_PARAMS
7217        .get_or_init(|| Mutex::new(HashSet::new()))
7218        .lock()
7219        .contains(name)
7220        .eq(&false)
7221}
7222
7223/// True when the magic special parameter `name` (a `partab[]` row from
7224/// `Src/Modules/parameter.c:2235-2298` — `options`, `functions`,
7225/// `commands`, `parameters`, `dirstack`, …) is currently SHADOWED by a
7226/// plain user parameter of the same name.
7227///
7228/// Rust-only helper with no C counterpart BY CONSTRUCTION: C zsh keeps
7229/// specials and user parameters in the ONE `paramtab` hash, so ordinary
7230/// hash lookup already implements this. `createparam`
7231/// (c:Src/params.c:1090-1115) finds the existing special node, stashes
7232/// it in `pm->old`, and inserts a fresh plain node under the same key;
7233/// every later `getvalue` / `fetchvalue` / `gethashparam` therefore hits
7234/// the plain node and the special's `gsu` callbacks are unreachable
7235/// until `endparamscope` restores `pm->old`. zshrs instead keeps the
7236/// magic rows in SEPARATE static tables (`PARTAB`, `PARTAB_ARRAY`) and
7237/// matches them BY NAME, so a name-only match resurrected the special
7238/// even while a local shadowed it. This predicate re-imposes C's
7239/// shadowing on the split-table layout; it is architecture glue, not a
7240/// port.
7241///
7242/// `init_partab_params` (below) seeds every magic row into `paramtab`
7243/// with `PM_SPECIAL` (C's `SPECIALPMDEF` macro), and `local`/`typeset`
7244/// replaces that node with one carrying no `PM_SPECIAL`, so the live
7245/// node's `PM_SPECIAL` bit is exactly C's "is the special still the
7246/// visible binding" test.
7247///
7248/// Returns false when a MODULE-GATED row (`sysparams`, `errnos`,
7249/// `mapfile`, `langinfo`) has no `paramtab` node: those are seeded on
7250/// demand by `seed_partab_param`, and the PARTAB walk must still answer
7251/// for them (their own `module` gate decides).
7252///
7253/// For every other magic row an ABSENT node means the binding is gone —
7254/// `unset` removed it. C reaches the same answer through one gate:
7255/// `Src/params.c:2264-2266` `if (!pm || ((pm->node.flags & PM_UNSET) &&
7256/// !(pm->node.flags & PM_DECLARED))) return NULL;` in `fetchvalue`, the
7257/// single choke point every `${X}` / `${#X}` / `${(k)X}` / `${(t)X}` /
7258/// `${X[k]}` read passes through. Both of its arms show up here:
7259///  * `!pm` — `unset functions` at a point where the name is still the
7260///    `PM_AUTOLOAD` stub (`Src/module.c:1218-1223`) finds a PLAIN
7261///    `PM_SCALAR` node with neither `PM_SPECIAL` nor `PM_READONLY`, so
7262///    `unsetparam_pm`'s c:3851-3852 keep-the-node test
7263///    (`(flags & (PM_SPECIAL|PM_REMOVABLE)) == PM_SPECIAL`) is false and
7264///    c:3874 `paramtab->removenode` drops it outright.
7265///  * `PM_UNSET && !PM_DECLARED` — once the special HAS been
7266///    materialized, c:3851-3852 keeps the node and `stdunsetfn`
7267///    (c:3939) marking `PM_UNSET` is the entire record of the unset;
7268///    `setpmfunctions(pm, NULL)` returns immediately on `if (!ht)
7269///    return` (`Src/Modules/parameter.c:361-362`), so `shfunctab` — the
7270///    real table behind the row — survives untouched and `ff` still runs.
7271///
7272/// Both arms mean the same thing for a split-table port: the magic row
7273/// is no longer the visible binding for this name, exactly as when a
7274/// `local` shadows it. Answering that one question here is what lets
7275/// every PARTAB dispatch site keep a single guard.
7276///
7277/// Symptom this fixes: git's `git-completion.bash`
7278/// `__git_resolve_builtins` does `local options; eval
7279/// "options=\${$var-}"` (git 2.55.0
7280/// share/zsh/site-functions/git-completion.bash:500-501). `$options`
7281/// read back the zsh/parameter option table (`off on off …`) instead of
7282/// the local, so `git checkout --<TAB>` completed nothing.
7283pub fn magic_special_shadowed(name: &str) -> bool {
7284    // Only `partab[]` names can be shadowed in this sense — an ordinary
7285    // user assoc / array has no special behind it, and its own paramtab
7286    // node legitimately carries no PM_SPECIAL.
7287    if !PARTAB.iter().any(|e| e.name == name) && !PARTAB_ARRAY.iter().any(|e| e.name == name) {
7288        return false;
7289    }
7290    crate::ported::params::paramtab()
7291        .read()
7292        .map_or(false, |tab| {
7293            let Some(pm) = tab.get(name) else {
7294                // c:Src/params.c:2264 `if (!pm ...) return NULL` —
7295                // `unset` removed the node (see the doc comment). Only
7296                // a valid reading once the rows have been seeded, and
7297                // never for the seeded-on-demand module rows.
7298                return PARTAB_SEEDED.load(std::sync::atomic::Ordering::Acquire)
7299                    && module_gated_partab_module(name).is_none();
7300            };
7301            {
7302                // c:Src/params.c:2264-2266 — `(pm->node.flags & PM_UNSET)
7303                // && !(pm->node.flags & PM_DECLARED)`: the materialized
7304                // special was unset and the node kept (c:3851-3852).
7305                let f = pm.node.flags as u32;
7306                if (f & crate::ported::zsh_h::PM_UNSET) != 0
7307                    && (f & crate::ported::zsh_h::PM_DECLARED) == 0
7308                {
7309                    return true;
7310                }
7311            }
7312            {
7313                // c:Src/module.c:1029-1052 checkaddparam — `if (pm->level ||
7314                // !(pm->node.flags & PM_AUTOLOAD))` is C's OWN test for "is
7315                // this node a blocker or the module's own placeholder": a
7316                // GLOBAL PM_AUTOLOAD node is the autoload STUB
7317                // `add_autoparam` planted (c:1222-1223 `setsparam(pnam,
7318                // module); pm->node.flags |= PM_AUTOLOAD` — its VALUE is the
7319                // owning module's name), and C replaces it with the real
7320                // special via `unsetparam_pm` (c:1051) + `createspecialhash`
7321                // (c:1068) the moment the module loads. Reading the name is
7322                // what triggers that: c:Src/params.c:563-585 loadparamnode
7323                // runs `ensurefeature(mn, "p:", nam)` and re-fetches the node.
7324                // So a stub NEVER makes the special unreachable — treating it
7325                // as a shadow left `${options}` reading the stub's own scalar
7326                // value ("zsh/parameter") and killed every magic-assoc read
7327                // for that name for the rest of the session. A LOCAL stub
7328                // (pm->level != 0) still blocks, exactly as c:1032 says.
7329                let f = pm.node.flags as u32;
7330                if pm.level == 0 && (f & crate::ported::zsh_h::PM_AUTOLOAD) != 0 {
7331                    return false;
7332                }
7333                (f & crate::ported::zsh_h::PM_SPECIAL) == 0
7334            }
7335        })
7336}
7337
7338pub fn partab_get(name: &str, key: &str) -> Option<String> {
7339    // C's paramtab lookup would already have found the shadowing local;
7340    // the split-table port needs the explicit check.
7341    if magic_special_shadowed(name) {
7342        return None;
7343    }
7344    mark_module_param_used(name);
7345    // c:Src/Modules/system.c:902,904 — `sysparams` and `errnos` are
7346    // bound by zsh/system's boot_/setup_ chain. Same for `mapfile`
7347    // from zsh/mapfile. Without explicit `zmodload`, these names
7348    // are unset in zsh; gate the PARTAB dispatch here so they
7349    // resolve via the empty-fallback path (matching ${sysparams[k]:-x}
7350    // taking the default). Bug #69 in docs/BUGS.md.
7351    if let Some(modname) = module_gated_partab_module(name) {
7352        if !crate::ported::module::MODULESTAB
7353            .lock()
7354            .unwrap()
7355            .is_loaded(modname)
7356        {
7357            return None;
7358        }
7359    }
7360    for entry in PARTAB.iter() {
7361        if entry.name == name {
7362            return (entry.getfn)(std::ptr::null_mut(), key).and_then(|p| p.u_str);
7363        }
7364    }
7365    None
7366}
7367
7368/// Returns the owning module name for partab entries that are
7369/// bound by an explicit zmodload — `sysparams`/`errnos` from
7370/// zsh/system, `mapfile` from zsh/mapfile. Other partab entries
7371/// (aliases/commands/functions/...) are part of zsh/main and
7372/// always available.
7373fn module_gated_partab_module(name: &str) -> Option<&'static str> {
7374    match name {
7375        "sysparams" | "errnos" => Some("zsh/system"),
7376        "mapfile" => Some("zsh/mapfile"),
7377        "langinfo" => Some("zsh/langinfo"),
7378        _ => None,
7379    }
7380}
7381
7382/// Publish a value into a read-only special from shell-INTERNAL code.
7383///
7384/// C binds these params to C variables through a gsu vtable —
7385/// `compvarscalar_gsu` for `$QIPREFIX`/`$QISUFFIX`
7386/// (Src/Zle/complete.c:1308-1324), `keymap_gsu` for `$KEYMAP`
7387/// (Src/Zle/zle_params.c:151) — and the shell's own writes go straight
7388/// to that variable. PM_READONLY is only consulted on the ASSIGNMENT
7389/// path (`assignsparam`, Src/params.c), so the bit stops a user's
7390/// `QIPREFIX=x` without ever standing in the way of the completion
7391/// machinery's own publish.
7392///
7393/// zshrs keeps the value in the param itself, so the internal publish
7394/// has to step around the same gate explicitly: drop PM_READONLY,
7395/// assign through the canonical path, put the bit back.
7396pub fn set_readonly_special(name: &str, value: &str) {
7397    use crate::ported::zsh_h::PM_READONLY;
7398    let was_readonly = crate::ported::params::paramtab()
7399        .write()
7400        .ok()
7401        .and_then(|mut tab| {
7402            tab.get_mut(name).map(|pm| {
7403                let ro = (pm.node.flags & PM_READONLY as i32) != 0;
7404                pm.node.flags &= !(PM_READONLY as i32);
7405                ro
7406            })
7407        })
7408        .unwrap_or(false);
7409    let _ = crate::ported::params::setsparam(name, value);
7410    if was_readonly {
7411        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
7412            if let Some(pm) = tab.get_mut(name) {
7413                pm.node.flags |= PM_READONLY as i32;
7414            }
7415        }
7416    }
7417}
7418
7419/// PM_ARRAY lookup for `${name}` / `${name[N]}` — walks
7420/// PARTAB_ARRAY and dispatches the whole-array getfn (Src/Modules/
7421/// parameter.c:2239-2291 ports). Returns `None` if name isn't a
7422/// known PM_ARRAY magic-assoc.
7423pub fn partab_array_get(name: &str) -> Option<Vec<String>> {
7424    // c:Src/params.c:1090-1115 createparam — see `partab_get`.
7425    if magic_special_shadowed(name) {
7426        return None;
7427    }
7428    mark_module_param_used(name);
7429    // Bug #69 — gate module-bound PARTAB names on the owning
7430    // module's MOD_LINKED && !MOD_UNLOAD state.
7431    if let Some(modname) = module_gated_partab_module(name) {
7432        if !crate::ported::module::MODULESTAB
7433            .lock()
7434            .unwrap()
7435            .is_loaded(modname)
7436        {
7437            return None;
7438        }
7439    }
7440    for entry in PARTAB_ARRAY.iter() {
7441        if entry.name == name {
7442            return Some((entry.getfn)(std::ptr::null_mut()));
7443        }
7444    }
7445    None
7446}
7447
7448/// Scan helper for `${(k)name}` — enumerates keys via canonical
7449/// scanfn, collected into Vec via SCAN_KEYS thread-local.
7450pub fn partab_scan_keys(name: &str) -> Option<Vec<String>> {
7451    // c:Src/params.c:1090-1115 createparam — see `partab_get`.
7452    if magic_special_shadowed(name) {
7453        return None;
7454    }
7455    mark_module_param_used(name);
7456    // Bug #69 — gate module-bound PARTAB names on the owning
7457    // module's MOD_LINKED && !MOD_UNLOAD state.
7458    if let Some(modname) = module_gated_partab_module(name) {
7459        if !crate::ported::module::MODULESTAB
7460            .lock()
7461            .unwrap()
7462            .is_loaded(modname)
7463        {
7464            return None;
7465        }
7466    }
7467    for entry in PARTAB.iter() {
7468        if entry.name == name {
7469            SCAN_KEYS.with(|k| k.borrow_mut().clear());
7470            // c:Src/Modules/parameter.c — a param-table ScanFunc receives
7471            // `&pm.node` of a fully populated `struct param`; the Rust side
7472            // models that as `ParamScanFunc = fn(&param, i32)`.
7473            fn cb(pm: &crate::ported::zsh_h::param, _flags: i32) {
7474                SCAN_KEYS.with(|k| k.borrow_mut().push(pm.node.nam.clone()));
7475            }
7476            // c:Src/params.c:3138 — `paramvalarr(…, SCANPM_WANTKEYS)`: keys
7477            // only, so a scanfn need not materialize the value side.
7478            (entry.scanfn)(
7479                std::ptr::null_mut(),
7480                Some(cb),
7481                crate::ported::zsh_h::SCANPM_WANTKEYS as i32,
7482            );
7483            return Some(SCAN_KEYS.with(|k| k.borrow().clone()));
7484        }
7485    }
7486    None
7487}
7488/// Populate paramtab with PM_SPECIAL placeholder Params for every
7489/// PARTAB / PARTAB_ARRAY entry — Rust-only init helper, no direct
7490/// C counterpart (closest is `handlefeatures` walking `partab[]`
7491/// in `Src/Modules/parameter.c:2341` boot/enables chain).
7492///
7493/// Each magic-assoc name gets a Param with `entry.flags | PM_SPECIAL`.
7494/// Value reads still route through `partab_get` / `partab_array_get`;
7495/// having the Param in paramtab makes `paramtab.get(name)` return
7496/// Some(Param) so `${+name}` / `${(t)name}` / `typeset -p name` see
7497/// the entry. Without this, those reads returned empty for every
7498/// magic-assoc (aliases, commands, functions, etc.).
7499///
7500/// Called from ShellExecutor::new() since zshrs's bin entry skips
7501/// the canonical module-bootstrap chain.
7502pub fn init_partab_params() {
7503    use crate::ported::modules::parameter::{PARTAB, PARTAB_ARRAY};
7504    use crate::ported::zsh_h::{
7505        hashnode, param, Param, PM_HIDE, PM_HIDEVAL, PM_READONLY, PM_SPECIAL,
7506    };
7507    let mut tab = match paramtab().write() {
7508        Ok(t) => t,
7509        Err(_) => return,
7510    };
7511    // c:Src/zsh.h SPECIALPMDEF macro: `flags | PM_SPECIAL | PM_HIDE |
7512    // PM_HIDEVAL`. All magic-assoc/array params get HIDE+HIDEVAL added
7513    // by the macro itself.
7514    //
7515    // PM_READONLY is preserved on the stub for params that legitimately
7516    // need user-write protection (reswords, dis_reswords, patchars,
7517    // dis_patchars — all compute via getfn and have no legitimate
7518    // internal-write path). Other specials that DO have internal-write
7519    // paths (e.g. funcstack from function-call tracking) get the bit
7520    // stripped so the runtime can mutate their u_arr. Bug #374.
7521    // `parameters` is computed entirely by getpmparameter and has no
7522    // internal-write path either (zsh: PM_READONLY_SPECIAL, c:2287), so it
7523    // keeps the bit — `${parameters[parameters]}` reads
7524    // `association-readonly-hide-hideval-special` in zsh.
7525    // The remaining names below are the rest of C's PM_READONLY_SPECIAL
7526    // rows whose `partab[]` entry has a NULL gsu (c:2237/2243/2255/2265/
7527    // :2272/2276-2280/2284/2296-2298 + Src/Zle/zleparameter.c:133): they
7528    // are computed purely by their getfn/scanfn, so there is nothing for
7529    // the runtime to write and the bit is safe to keep. Without them
7530    // `${(t)builtins}` and friends reported
7531    // `association-hide-hideval-special` where zsh reports
7532    // `association-readonly-hide-hideval-special`.
7533    let user_protected: &[&str] = &[
7534        "parameters",
7535        "reswords",
7536        "dis_reswords",
7537        "patchars",
7538        "dis_patchars",
7539        "historywords",
7540        "errnos",
7541        "keymaps",
7542        "builtins",             // c:2237
7543        "dis_builtins",         // c:2243
7544        "functions_source",     // c:2265
7545        "dis_functions_source", // c:2247
7546        "history",              // c:2272
7547        "jobdirs",              // c:2276
7548        "jobstates",            // c:2278
7549        "jobtexts",             // c:2280
7550        "modules",              // c:2284
7551        "userdirs",             // c:2296
7552        "usergroups",           // c:2297
7553        "widgets",              // c:Src/Zle/zleparameter.c:133
7554        // c:2279-2280 — `SPECIALPMDEF("funcstack", PM_ARRAY|
7555        // PM_READONLY_SPECIAL, &funcstack_gsu, NULL, NULL)`. The bit was
7556        // being stripped here on the theory that the runtime writes
7557        // `funcstack`'s `u_arr`; it does not — `funcstackgetfn`
7558        // (PARTAB_ARRAY, parameter.rs:4726-4732) computes the value from
7559        // the `FUNCSTACK` global on every read and the row's `setfn` is
7560        // `None`, so there is nothing to protect against. Without the bit
7561        // `${(t)funcstack}` read `array-hide-hideval-special` where zsh
7562        // reads `array-readonly-hide-hideval-special`, which put it in the
7563        // wrong `_parameters -g '^*(readonly|association)*'` bucket and
7564        // added one candidate zsh does not offer.
7565        "funcstack", // c:2279
7566        // Same argument as `funcstack` above, for the three sibling trace
7567        // arrays: `SPECIALPMDEF(..., PM_ARRAY|PM_READONLY_SPECIAL, ...)` in C
7568        // and `setfn: None` in PARTAB_ARRAY (parameter.rs:4717/4725/4741), so
7569        // they are getfn-computed with no internal-write path to protect.
7570        "funcfiletrace",   // c:2275
7571        "funcsourcetrace", // c:2277
7572        "functrace",       // c:2285
7573        // c:Src/Modules/termcap.c:312 / Src/Modules/terminfo.c:305 —
7574        // `SPECIALPMDEF("termcap", PM_READONLY, NULL, gettermcap, scantermcap)`
7575        // and the terminfo twin: NULL gsu, value produced entirely by the
7576        // getnode/scan fns, so the readonly bit has nothing to fight.
7577        "termcap",  // c:Src/Modules/termcap.c:312
7578        "terminfo", // c:Src/Modules/terminfo.c:305
7579        // c:Src/Builtins/sched.c:382 — `SPECIALPMDEF(
7580        // "zsh_scheduled_events", PM_ARRAY|PM_READONLY, &sched_gsu,
7581        // NULL, NULL)`. Same shape as `funcstack` above: `schedgetfn`
7582        // (sched.rs:582) walks the schedcmds list on every read and the
7583        // PARTAB_ARRAY row's `setfn` is `None`, so no internal write
7584        // needs the bit cleared. `sched`/`sched -N` mutate the list,
7585        // never the param.
7586        "zsh_scheduled_events", // c:Src/Builtins/sched.c:382
7587    ];
7588    let mk_pm = |name: &str, flags: i32| -> Param {
7589        let keep_readonly = user_protected.contains(&name);
7590        let pre_readonly_mask = if keep_readonly {
7591            !0i32
7592        } else {
7593            !(PM_READONLY as i32)
7594        };
7595        Box::new(param {
7596            node: hashnode {
7597                next: None,
7598                nam: name.to_string(),
7599                flags: (flags & pre_readonly_mask)
7600                    | PM_SPECIAL as i32
7601                    | PM_HIDE as i32
7602                    | PM_HIDEVAL as i32,
7603            },
7604            u_data: 0,
7605            u_tied: None,
7606            u_arr: None,
7607            u_str: None,
7608            u_val: 0,
7609            u_dval: 0.0,
7610            u_hash: None,
7611            gsu_s: None,
7612            gsu_i: None,
7613            gsu_f: None,
7614            gsu_a: None,
7615            gsu_h: None,
7616            base: 0,
7617            width: 0,
7618            env: None,
7619            ename: None,
7620            old: None,
7621            level: 0,
7622        })
7623    };
7624    // c:Src/Modules/system.c:902,904 + Src/Modules/mapfile.c — these
7625    // params are provided by modules that real zsh requires explicit
7626    // `zmodload` for. Seeding them unconditionally makes
7627    // `${+sysparams}` return 1 by default (bug #69 in docs/BUGS.md),
7628    // diverging from zsh which returns 0 until the user runs
7629    // `zmodload zsh/system`. Skip here; `seed_partab_param` below adds
7630    // them on demand from the module's load path.
7631    let module_gated: &[&str] = &[
7632        "sysparams", // zsh/system
7633        "errnos",    // zsh/system
7634        "mapfile",   // zsh/mapfile
7635        "langinfo",  // zsh/langinfo
7636    ];
7637    // c:Src/module.c:1065 `addparamdef` — `checkaddparam` (c:1026) finds
7638    // the PM_AUTOLOAD stub `init_bltinmods` planted, calls
7639    // `unsetparam_pm(pm, 0, 1)` which UNLINKS the node (c:1052), and only
7640    // then does `createparam` re-add it — so the real param takes a FRESH
7641    // chain slot, it does not inherit the stub's. `hashtable_nodes::
7642    // insert` is `addhashnode2` (Src/hashtable.c:168), which replaces an
7643    // existing key IN PLACE (c:187-203) and would pin the special to the
7644    // stub's slot; remove first to reproduce C. Visible in
7645    // `${(k)parameters}`: without the remove, `dis_reswords` and
7646    // `usergroups` came out one position off from `zsh -f`.
7647    for entry in PARTAB.iter() {
7648        if module_gated.contains(&entry.name) {
7649            continue;
7650        }
7651        tab.remove(entry.name); // c:1052 unsetparam_pm
7652        tab.insert(entry.name.to_string(), mk_pm(entry.name, entry.flags));
7653    }
7654    for entry in PARTAB_ARRAY.iter() {
7655        if module_gated.contains(&entry.name) {
7656            continue;
7657        }
7658        tab.remove(entry.name); // c:1052 unsetparam_pm
7659        tab.insert(entry.name.to_string(), mk_pm(entry.name, entry.flags));
7660    }
7661    // See `PARTAB_SEEDED` — every magic row now has its paramtab node,
7662    // so from here on a MISSING node is a real "no binding" answer.
7663    PARTAB_SEEDED.store(true, std::sync::atomic::Ordering::Release);
7664}
7665
7666/// !!! WARNING: RUST-ONLY HELPER !!!
7667///
7668/// True once [`init_partab_params`] has finished planting a paramtab
7669/// node for every `PARTAB` / `PARTAB_ARRAY` row.
7670///
7671/// No C counterpart BY CONSTRUCTION. In C the magic rows only ENTER
7672/// `paramtab` when `zsh/parameter` boots (`handlefeatures` →
7673/// `addparamdef` → `createspecialhash`, `Src/module.c:1065`), and before
7674/// that the name still resolves — to the `PM_AUTOLOAD` stub
7675/// `init_bltinmods` planted (`Src/module.c:1218-1223`). Either way C's
7676/// `paramtab->getnode(name)` answers, so C never has to distinguish
7677/// "not seeded yet" from "unset". zshrs seeds the rows from
7678/// `ShellExecutor::new` (vm_helper.rs:2566) and matches `PARTAB` BY NAME
7679/// out of a separate static table, so a magic read that runs BEFORE that
7680/// seeding would see an absent node. `magic_special_shadowed` reads
7681/// absence as "unset" (C: `getnode` → NULL → `fetchvalue` NULL,
7682/// `Src/params.c:2264-2266`), which is only a valid inference once the
7683/// seeding has run; this flag is that precondition.
7684static PARTAB_SEEDED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
7685
7686/// Insert a single PARTAB / PARTAB_ARRAY entry into paramtab. Called
7687/// from `zmodload <module>` once the module's boot completes, so that
7688/// `${+sysparams}` (etc.) flip from 0 → 1 only after explicit load.
7689/// No direct C counterpart — the C path runs through the module's
7690/// `setup_/boot_` chain which adds the SPECIALPMDEF entry via the
7691/// general hashtable machinery. Bug #69 in docs/BUGS.md.
7692pub fn seed_partab_param(name: &str) {
7693    use crate::ported::modules::parameter::{PARTAB, PARTAB_ARRAY};
7694    use crate::ported::zsh_h::{hashnode, param, PM_HIDE, PM_HIDEVAL, PM_READONLY, PM_SPECIAL};
7695    let mut tab = match crate::ported::params::paramtab().write() {
7696        Ok(t) => t,
7697        Err(_) => return,
7698    };
7699    if tab.contains_key(name) {
7700        return; // already seeded
7701    }
7702    let flags = PARTAB
7703        .iter()
7704        .find(|e| e.name == name)
7705        .map(|e| e.flags)
7706        .or_else(|| {
7707            PARTAB_ARRAY
7708                .iter()
7709                .find(|e| e.name == name)
7710                .map(|e| e.flags)
7711        });
7712    let Some(flags) = flags else {
7713        return;
7714    };
7715    let pm = Box::new(param {
7716        node: hashnode {
7717            next: None,
7718            nam: name.to_string(),
7719            // Keep C's PM_READONLY. `init_partab_params` strips it from
7720            // rows the RUNTIME writes internally (funcstack pushes and
7721            // friends) and keeps it on the getfn/scanfn-computed rows —
7722            // see its `user_protected` list. Every name reaching THIS
7723            // seeder is a zmodload-gated row
7724            // (`module_gated_params_for`), and all of them are the
7725            // computed kind: `SPECIALPMDEF("sysparams", PM_READONLY,
7726            // NULL, getpmsysparams, scanpmsysparams)`
7727            // (Src/Modules/system.c:906), `errnos` (c:904), `langinfo`
7728            // (Src/Modules/langinfo.c:455); `mapfile` carries flags 0 in
7729            // C so it is unaffected either way. Stripping the bit made
7730            // `${(t)sysparams}` read `association-hide-hideval-special`
7731            // against zsh's `association-readonly-hide-hideval-special`,
7732            // and let `unset sysparams` succeed where zsh rejects with
7733            // `read-only variable: sysparams`.
7734            flags: flags | PM_SPECIAL as i32 | PM_HIDE as i32 | PM_HIDEVAL as i32,
7735        },
7736        u_data: 0,
7737        u_tied: None,
7738        u_arr: None,
7739        u_str: None,
7740        u_val: 0,
7741        u_dval: 0.0,
7742        u_hash: None,
7743        gsu_s: None,
7744        gsu_i: None,
7745        gsu_f: None,
7746        gsu_a: None,
7747        gsu_h: None,
7748        base: 0,
7749        width: 0,
7750        env: None,
7751        ename: None,
7752        old: None,
7753        level: 0,
7754    });
7755    tab.insert(name.to_string(), pm);
7756}
7757
7758/// Default autoloadable parameters: name → owning module. Port of the
7759/// `autofeatures` `p:` rows in Src/Modules/parameter.mdd, watch.mdd,
7760/// termcap.mdd, terminfo.mdd, Src/Zle/zleparameter.mdd and
7761/// Src/Builtins/sched.mdd, registered at startup through
7762/// `setautofeatures` → `add_autoparam` (Src/module.c:1198-1229): each
7763/// name becomes a scalar paramtab stub whose VALUE is the module name,
7764/// flagged PM_AUTOLOAD (module.c:1218-1219). Matches `zmodload -ap`
7765/// output of the reference zsh build.
7766pub const AUTOLOAD_PARAMS: &[(&str, &str)] = &[
7767    // Src/Modules/watch.mdd:5 autofeatures
7768    ("WATCH", "zsh/watch"),
7769    ("watch", "zsh/watch"),
7770    // Src/Modules/parameter.mdd:5 autofeatures
7771    ("aliases", "zsh/parameter"),
7772    ("builtins", "zsh/parameter"),
7773    ("commands", "zsh/parameter"),
7774    ("dirstack", "zsh/parameter"),
7775    ("dis_aliases", "zsh/parameter"),
7776    ("dis_builtins", "zsh/parameter"),
7777    ("dis_functions", "zsh/parameter"),
7778    ("dis_functions_source", "zsh/parameter"),
7779    ("dis_galiases", "zsh/parameter"),
7780    ("dis_patchars", "zsh/parameter"),
7781    ("dis_reswords", "zsh/parameter"),
7782    ("dis_saliases", "zsh/parameter"),
7783    ("funcfiletrace", "zsh/parameter"),
7784    ("funcsourcetrace", "zsh/parameter"),
7785    ("funcstack", "zsh/parameter"),
7786    ("functions", "zsh/parameter"),
7787    ("functions_source", "zsh/parameter"),
7788    ("functrace", "zsh/parameter"),
7789    ("galiases", "zsh/parameter"),
7790    ("history", "zsh/parameter"),
7791    ("historywords", "zsh/parameter"),
7792    ("jobdirs", "zsh/parameter"),
7793    ("jobstates", "zsh/parameter"),
7794    ("jobtexts", "zsh/parameter"),
7795    ("modules", "zsh/parameter"),
7796    ("nameddirs", "zsh/parameter"),
7797    ("options", "zsh/parameter"),
7798    ("parameters", "zsh/parameter"),
7799    ("patchars", "zsh/parameter"),
7800    ("reswords", "zsh/parameter"),
7801    ("saliases", "zsh/parameter"),
7802    ("userdirs", "zsh/parameter"),
7803    ("usergroups", "zsh/parameter"),
7804    // Src/Zle/zleparameter.mdd:5 autofeatures
7805    ("keymaps", "zsh/zleparameter"),
7806    ("widgets", "zsh/zleparameter"),
7807    // Src/Modules/termcap.mdd:5 / terminfo.mdd:5 autofeatures
7808    ("termcap", "zsh/termcap"),
7809    ("terminfo", "zsh/terminfo"),
7810    // Src/Builtins/sched.mdd:5 autofeatures
7811    ("zsh_scheduled_events", "zsh/sched"),
7812];
7813
7814/// Autoload stubs whose owning module is NOT loaded — the rows zsh's
7815/// `typeset` listings print as `undefined NAME` (printparamnode's
7816/// PM_AUTOLOAD pmtypes row, Src/params.c:6011 + the PM_AUTOLOAD
7817/// NAMEONLY arm at Src/params.c:6146-6155). Once a module loads, its
7818/// stubs drop out and the real params list instead.
7819pub fn autoload_param_stubs() -> Vec<(&'static str, &'static str)> {
7820    use crate::ported::zsh_h::{MOD_INIT_B, MOD_UNLOAD};
7821    let tab = crate::ported::module::MODULESTAB.lock().unwrap();
7822    AUTOLOAD_PARAMS
7823        .iter()
7824        .copied()
7825        .filter(|(_, m)| {
7826            // "Boot ran" is MOD_INIT_B && !MOD_UNLOAD (the criterion
7827            // printmodulenode uses, src/ported/module.rs:246 — C's
7828            // `m->u.handle` union check at Src/module.c:218-241).
7829            // modulestab::is_loaded checks MOD_LINKED which
7830            // register_builtin_modules pre-seeds for EVERY compiled-in
7831            // module, so it would report zsh/parameter "loaded" in a
7832            // fresh `zsh -f` where real zsh still shows the stubs.
7833            !tab.modules.get(*m).is_some_and(|md| {
7834                (md.node.flags & MOD_INIT_B) != 0 && (md.node.flags & MOD_UNLOAD) == 0
7835            })
7836        })
7837        .collect()
7838}
7839
7840/// Names provided by `zsh/system` / `zsh/mapfile` etc. that are
7841/// gated on explicit `zmodload`. Used by the bin_zmodload path to
7842/// re-seed paramtab after the module's boot completes.
7843pub fn module_gated_params_for(module: &str) -> &'static [&'static str] {
7844    match module {
7845        "zsh/system" => &["sysparams", "errnos"],
7846        "zsh/mapfile" => &["mapfile"],
7847        "zsh/langinfo" => &["langinfo"],
7848        _ => &[],
7849    }
7850}
7851impl ShellExecutor {
7852    /// `enter_posix_mode` — see implementation.
7853    pub fn enter_posix_mode(&mut self) {
7854        self.posix_mode = true;
7855        self.plugin_cache = None;
7856        self.compsys_cache = std::cell::OnceCell::new();
7857        self.compinit_pending = None;
7858        self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
7859        // Direct call to the canonical `emulate()` port
7860        // (Src/options.c:533) — `-R` semantics = fully=true.
7861        // bin_emulate goes through dispatch_builtin which needs an
7862        // ExecutorContext that isn't set up yet at apply_cli_flags
7863        // time; the underlying emulate() doesn't need one.
7864        crate::ported::options::emulate("sh", true);
7865    }
7866    /// `enter_ksh_mode` — see implementation.
7867    pub fn enter_ksh_mode(&mut self) {
7868        self.plugin_cache = None;
7869        self.compsys_cache = std::cell::OnceCell::new();
7870        self.compinit_pending = None;
7871        self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
7872        crate::ported::options::emulate("ksh", true);
7873    }
7874    /// `enter_dash_mode` — strict-dash (Debian Almquist Shell) runtime.
7875    /// Same executor setup as [`enter_posix_mode`] (dash IS `sh` for every
7876    /// option), but calls `emulate("dash")` so the Rust-only DASH_STRICT
7877    /// flag is raised (and NOT cleared, as `emulate("sh")` would). See
7878    /// `src/extensions/dash_mode.rs`.
7879    pub fn enter_dash_mode(&mut self) {
7880        self.posix_mode = true;
7881        self.plugin_cache = None;
7882        self.compsys_cache = std::cell::OnceCell::new();
7883        self.compinit_pending = None;
7884        self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
7885        crate::ported::options::emulate("dash", true);
7886    }
7887}
7888
7889/// Thin (text, pattern) → bool wrapper over the canonical
7890/// `patcompile()` + `pattry()` pair from `Src/pattern.c`. Argument
7891/// order is flipped so callers read naturally. Lives in vm_helper.rs
7892/// (non-port file) as the public convenience entry for extensions
7893/// and the VM bridge; `src/ported/*` files inline the compile+match
7894/// idiom directly to preserve PORT.md Rule 1 faithfulness.
7895pub fn glob_match_static(s: &str, pattern: &str) -> bool {
7896    let Some(prog) = patcompile(
7897        &{
7898            let mut __pat_tok = (pattern).to_string();
7899            crate::ported::glob::tokenize(&mut __pat_tok);
7900            __pat_tok
7901        },
7902        PAT_HEAPDUP as i32,
7903        None,
7904    ) else {
7905        return false;
7906    };
7907    // c:Src/pattern.c:2570-2621 — `else if (prog->patnpar && !(patflags &
7908    // PAT_FILE))`: when the caller passes NO nump/begp/endp, `pattryrefs`
7909    // ITSELF publishes $match / $mbegin / $mend. That arm is ported in
7910    // pattern.rs, so this layer must not re-derive them from begp/endp.
7911    //
7912    // Re-deriving required compensating for the EXCLUSIVE end index
7913    // `pattryrefs` used to return, and the compensation was wrong twice over:
7914    //   * it would double-correct the c:2562-2564 `- 1` now applied there, and
7915    //     collide with the `endp[i] < 0` unset-group sentinel — an empty
7916    //     capture at offset 0 reports (0, -1) and was misread as an UNMATCHED
7917    //     alternation branch;
7918    //   * `saturating_sub(1)` clamped at 0, so under KSHARRAYS an empty capture
7919    //     at offset 0 gave `mend=0` where C computes `0 + 0 + 0 - 1` = -1.
7920    //     Measured: `setopt ksharrays; [[ abc = (#b)(x#)abc ]]` → zsh mend=-1,
7921    //     zshrs mend=0, while the substitution path (which already uses the
7922    //     c:2570-2621 arm) printed -1 correctly.
7923    let matched = pattry(&prog, s);
7924    // $MATCH / $MBEGIN / $MEND are set by the MATCHER (pattern.rs, c:2526),
7925    // which is where C decides it — gated on the GLOBAL patglobflags so a later
7926    // `(#M)` can turn GF_MATCHREF back off (c:1099-1100).
7927    //
7928    // This layer used to re-do it with `pattern.contains("(#m)")`, which can
7929    // only ever answer "on": it re-set $MATCH after the matcher had correctly
7930    // declined to, so `(#m)(#M)a*` reported a match string where zsh leaves it
7931    // unset. Wrong mechanism (a substring test cannot see which flag came last)
7932    // and wrong layer (the matcher already has the compiled flags).
7933    matched
7934}
7935
7936pub use crate::ported::lex::untokenize_ztokens;
7937
7938pub use crate::ported::utils::unmetafy_str;
7939
7940pub use crate::ported::utils::zsh_errno_msg;
7941
7942// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7943// PM_NAMEREF bridge helpers (typeset -n / named references).
7944//
7945// Rust-only adapters around the canonical C nameref machinery in
7946// Src/params.c (resolve_nameref_rec c:6332, setscope c:6382,
7947// upscope c:6455) and Src/builtin.c (bin_typeset nameref arm
7948// c:3117-3150). zshrs's paramtab is a name-keyed HashMap handing
7949// out clones, so the chain walk operates by NAME against the live
7950// table instead of by Param pointer — same hop rule, same loop
7951// detection, same upscope old-chain walk. The ported fns in
7952// params.rs/builtin.rs call into these at the exact C deref points
7953// (getparamnode c:570-575, getvalue/fetchvalue c:2247-2270,
7954// assignsparam c:3252/3258, assignaparam c:3392-3398, bin_unset
7955// c:3939-3951, typeset_single c:2032-2050).
7956// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7957
7958pub use crate::ported::params::*;