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    /// Set by `execute_cmdarg` for the next compile_script_isolated only —
81    /// the `-c` string itself, not an `eval` or trap it runs.
82    ///
83    /// !!! WARNING: RUST-ONLY FLAG — C PASSES `exiting` TO execode !!!
84    static CMDARG_EXITING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
85}
86
87/// O(1) single-key probe against the canonical hashed storage — the
88/// fast-path companion to subst.rs `assoc_get` for order-independent
89/// single-key reads. Returns `None` when `name` (post-nameref) isn't
90/// an assoc; otherwise `Some((key_present, value))` under ONE lock,
91/// with no whole-map clone and no zsh-bucket-order rebuild (that
92/// reorder is only observable to whole-map enumeration). Hot: shell
93/// loops reading `${assoc[$k]}` per iteration were O(n²) through
94/// assoc_get — zpwr expandstats over 42k records took 43s vs zsh's ~1s.
95/// Lives here (not src/ported/) because it has no C counterpart — C's
96/// getarg IS the single-key path.
97/// Classify an assoc subscript as an EXACT-key lookup and return the
98/// key: plain text (no leading flag group) passes through; a leading
99/// `(e…)`/`(E…)` group (c:Src/params.c:1449 — literal-key flag) is
100/// stripped. Search groups ((r)/(i)/(k)/…) return `None` — they need
101/// the full getarg walk. Companion gate for [`assoc_key_hit`]'s O(1)
102/// fast paths.
103/// !!! WARNING: RUST-ONLY HELPER — body of c:Src/init.c:180-215, lifted out
104/// of loop() so the script-file event loop (`run_events_per_command`) runs
105/// the same code; src/ported/ may not gain functions. !!!
106///
107/// The `toplevel` preexec branch of loop(): when a `preexec` function or
108/// `preexec_functions` exists, call the hook with `$1` (the history line, or
109/// "" when the history ring does not hold the current line — always the
110/// case for a script), `$2` (getjobtext) and `$3` (getpermtext), then clear
111/// ERRFLAG_ERROR (c:214). `event_src` is the event's source text captured
112/// around parse_event; zshrs's parse_event returns the fusevm AST, which the
113/// text renderers cannot walk, so the source is compiled to the wordcode
114/// Eprog C would already hold. That compile is silent and leaves errflag as
115/// it found it; an event it cannot take gets empty `$2`/`$3`.
116pub fn run_preexec_hook(event_src: Option<&str>) {
117    use crate::ported::zsh_h::{interact, isset, INTERACTIVECOMMENTS, SHINSTDIN};
118    // Native p10k engine command timer (src/extensions/p10k): stamp the
119    // command start for command_execution_time, with or without a user hook.
120    crate::p10k::note_exec_start(event_src);
121    let preexec_fn = crate::ported::utils::getshfunc("preexec"); // c:181
122    let preexec_hook = crate::ported::params::paramtab()
123        .read()
124        .ok()
125        .and_then(|t| {
126            t.get(&format!("preexec{}", crate::ported::zsh_h::HOOK_SUFFIX))
127                .map(|_| ())
128        }); // c:182 realparamtab->getnode2(realparamtab, "preexec" HOOK_SUFFIX)
129    if preexec_fn.is_none() && preexec_hook.is_none() {
130        return;
131    }
132    let mut args: Vec<String> = Vec::new(); // c:191 newlinklist()
133    args.push("preexec".to_string()); // c:192
134    {
135        // c:195 — `if (hist_ring && curline.histnum == curhist)`
136        let hr = crate::ported::hist::hist_ring.lock().unwrap();
137        let cl = crate::ported::hist::curline.lock().unwrap();
138        let same = !hr.is_empty()
139            && cl.as_ref().map(|c| c.histnum).unwrap_or(0)
140                == crate::ported::hist::curhist.load(Ordering::SeqCst);
141        if same {
142            args.push(hr.first().map(|h| h.node.nam.clone()).unwrap_or_default()); // c:196
143        } else {
144            args.push(String::new()); // c:198
145        }
146    }
147    let event_prog: Option<crate::ported::zsh_h::eprog> = event_src.and_then(|src| {
148        let noerrs = crate::ported::utils::noerrs_lock();
149        let saved_noerrs = std::mem::replace(&mut *noerrs.lock().unwrap(), 1);
150        let saved_errflag = errflag.load(Ordering::SeqCst);
151        // The event was lexed with `strin == 0`, where a `#` starts a comment
152        // only under INTERACTIVECOMMENTS in an interactive stdin shell
153        // (c:Src/lex.c:678-681). parse_string lexes under `strin`, which would
154        // turn comments on; `nocomments` restores that decision, the way
155        // getoutput does around its own parse_string (c:Src/exec.c:4720-4723).
156        let onc = crate::ported::lex::LEX_NOCOMMENTS.with(|c| c.get());
157        crate::ported::lex::LEX_NOCOMMENTS.with(|c| {
158            c.set(interact() && isset(SHINSTDIN) && !isset(INTERACTIVECOMMENTS))
159        });
160        let p = crate::ported::exec::parse_string(src, 0);
161        crate::ported::lex::LEX_NOCOMMENTS.with(|c| c.set(onc));
162        errflag.store(saved_errflag, Ordering::SeqCst);
163        *noerrs.lock().unwrap() = saved_noerrs;
164        p
165    });
166    // c:210 — addlinknode(args, dupstring(getjobtext(prog, NULL)))
167    // c:211 — addlinknode(args, cmdstr = getpermtext(prog, NULL, 0))
168    let (job_text, cmdstr) = match event_prog {
169        Some(p) => (
170            crate::ported::text::getjobtext(Box::new(p.clone()), None), // c:210
171            crate::ported::text::getpermtext(Box::new(p), None, 0),     // c:211
172        ),
173        None => (String::new(), String::new()),
174    };
175    args.push(crate::ported::mem::dupstring(&job_text));
176    args.push(cmdstr.clone());
177    crate::ported::utils::callhookfunc("preexec", Some(&args), 1, std::ptr::null_mut()); // c:202
178    crate::ported::mem::zsfree(cmdstr); // c:205
179    errflag.fetch_and(!ERRFLAG_ERROR, Ordering::SeqCst); // c:214
180}
181
182pub fn exact_assoc_sub_key(sub: &str) -> Option<&str> {
183    match sub.strip_prefix('(') {
184        None => Some(sub),
185        Some(rest) => {
186            let close = rest.find(')')?;
187            let grp = &rest[..close];
188            if !grp.is_empty() && grp.chars().all(|c| c == 'e' || c == 'E') {
189                Some(&rest[close + 1..])
190            } else {
191                None
192            }
193        }
194    }
195}
196
197pub fn assoc_key_hit(name: &str, key: &str) -> Option<(bool, Option<String>)> {
198    let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
199        crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
200        _ => name.to_string(),
201    };
202    // c:Src/params.c:1090-1115 createparam — a `local NAME` / `typeset
203    // NAME` replaces a special's paramtab node with a plain one, so the
204    // name is no longer a hash. Answering here would strand the read on
205    // the O(1) assoc fast path and never reach paramsubst's scalar
206    // subscript arm (`${options[1]}` came back empty for a local).
207    //
208    // …but "no longer the magic row" is NOT the same as "no longer a
209    // hash": a `local -A commands` shadow owns a table of its own and the
210    // key read must be served FROM IT (c:Src/params.c:2270 / :1597-1606).
211    // See `magic_special_shadowed_by_nonhash`, which every sibling
212    // dispatch site shares so the qualification cannot drift.
213    if magic_special_shadowed_by_nonhash(resolved.as_str()) {
214        return None;
215    }
216    // c:Src/Zle/complete.c:1272/1411 — `compstate[nmatches]` is a LIVE gsu
217    // integer (`get_nmatches` = `permmatches(0) ? 0 : nmatches`), not stored
218    // data, so the hashed store never held it and every shell-side read
219    // returned the EMPTY string. `_parameters` (`local -i nm=$compstate[
220    // nmatches]` … `(( compstate[nmatches] > nm ))`) therefore always
221    // reported "added nothing" and returned 1 — `unset <TAB>` offered 197
222    // names against zsh's 496 — and the same idiom in `_alternative`,
223    // `_describe` and `_arguments` mis-fired the same way.
224    // The same applies to the other NINE gsu-backed rows
225    // (c:complete.c:1261-1300): list_lines, list_max, unambiguous,
226    // unambiguous_cursor, unambiguous_positions, insert_positions, vared,
227    // all_quotes, ignored. Only `nmatches` was served live here, so
228    // `$compstate[list_lines]` and friends read empty from shell code
229    // where zsh reports a value.
230    if resolved == "compstate" && crate::ported::zle::compcore::LIVE_COMPSTATE_KEYS.contains(&key) {
231        return Some((
232            true,
233            Some(
234                crate::ported::zle::compcore::get_compstate_str(key).unwrap_or_else(|| {
235                    if key == "nmatches" {
236                        "0".to_string()
237                    } else {
238                        String::new()
239                    }
240                }),
241            ),
242        ));
243    }
244    crate::ported::params::paramtab_hashed_storage()
245        .lock()
246        .ok()
247        .and_then(|s| {
248            s.get(resolved.as_str())
249                .map(|m| (m.contains_key(key), m.get(key).cloned()))
250        })
251}
252use crate::ported::utils::{errflag, ERRFLAG_ERROR};
253use crate::ported::zsh_h::PM_UNDEFINED;
254use crate::ported::zsh_h::WC_SIMPLE;
255use crate::ported::zsh_h::{options, MAX_OPS};
256use crate::ported::zsh_h::{PM_ARRAY, PM_HASHED, PM_INTEGER, PM_READONLY};
257use parking_lot::Mutex;
258use std::collections::HashSet;
259use std::ffi::CStr;
260use std::ffi::CString;
261use std::ffi::OsStr;
262use std::fs;
263use std::io::Read;
264use std::os::unix::ffi::OsStrExt;
265use std::os::unix::fs::FileTypeExt;
266use std::os::unix::fs::PermissionsExt;
267use std::os::unix::io::FromRawFd;
268use std::sync::atomic::AtomicI32;
269use std::sync::atomic::Ordering;
270use std::time::{SystemTime, UNIX_EPOCH};
271use walkdir::WalkDir;
272
273// Backward-compat re-exports for free ported recently relocated to their
274// canonical-C-file Rust modules. Existing call-sites in this file (and
275// elsewhere) still reference these unqualified.
276#[allow(unused_imports)]
277pub(crate) use crate::func_body_fmt::FuncBodyFmt;
278#[allow(unused_imports)]
279pub(crate) use crate::ported::hist::bufferwords as bufferwords_z_tuple;
280#[allow(unused_imports)]
281pub(crate) use crate::ported::math::{parse_assign, parse_compound, parse_pre_inc};
282#[allow(unused_imports)]
283pub use crate::ported::params::convbase as format_int_in_base;
284pub use crate::ported::params::convbase_underscore;
285// `getarrvalue` is already re-exported by `pub use crate::ported::params::*`
286// below; an explicit `pub(crate) use` here only shadowed that public export.
287#[allow(unused_imports)]
288pub(crate) use crate::ported::utils::base64_decode;
289#[allow(unused_imports)]
290pub(crate) use crate::ported::utils::{ispwd, printprompt4, quotedzputs};
291
292pub(crate) use crate::intercepts::intercept_matches;
293/// AOP advice type — before, after, or around.
294pub use crate::intercepts::{AdviceKind, Intercept};
295
296/// Result from background compinit thread.
297pub use crate::compinit_bg::CompInitBgResult;
298use std::io::Write;
299use std::sync::LazyLock;
300
301/// State snapshot for plugin delta computation.
302pub(crate) use crate::plugin_cache::PluginSnapshot;
303
304/// Cached compiled regexes for hot paths
305pub(crate) static REGEX_CACHE: LazyLock<Mutex<HashMap<String, Regex>>> =
306    LazyLock::new(|| Mutex::new(HashMap::with_capacity(64)));
307
308// fusevm VM bridge (extension; not a port of Src/exec.c) lives in
309// src/fusevm_bridge.rs. Re-exports below let the rest of the codebase
310// reference symbols as `crate::ported::exec::X`.
311pub(crate) use crate::fusevm_bridge::ExecutorContext;
312pub use crate::fusevm_bridge::*;
313
314/// `ZSH_VERSION` / `ZSH_PATCHLEVEL` / `ZSH_VERSION_DATE` consts
315/// generated by `build.rs` from `src/zsh/Config/version.mk`. Use
316/// `zsh_version::ZSH_VERSION` etc. at call sites so version bumps
317/// pick up automatically.
318pub mod zsh_version {
319    include!(concat!(env!("OUT_DIR"), "/zsh_version.rs"));
320}
321
322/// Match an intercept pattern against a command name or full command string.
323/// Supports: exact match, glob ("git *", "_*", "*"), or "all".
324
325/// O(1) builtin-name lookup set derived from the canonical
326/// `BUILTINS` table (`src/ported/builtin.rs:122`, the 1:1 port of
327/// `static struct builtin builtins[]` at `Src/builtin.c:40-137`).
328/// The set is the union of: (1) names from `BUILTINS` (walked at first
329/// access), (2) zshrs daemon-side builtins from `ZSHRS_BUILTIN_NAMES`.
330/// Both arms run once at static init, so the set cannot drift from the
331/// canonical table.
332pub(crate) static BUILTIN_NAMES: LazyLock<HashSet<String>> = LazyLock::new(|| {
333    let mut s: HashSet<String> = HashSet::new();
334    // Walk the canonical `BUILTINS` table — the 1:1 port of
335    // `static struct builtin builtins[]` at `Src/builtin.c:40-137`
336    // (ported at `src/ported/builtin.rs:122`). Every name in there is
337    // a real zsh builtin; the set stays in sync as new ports land.
338    for b in crate::ported::builtin::BUILTINS.iter() {
339        s.insert(b.node.nam.clone());
340    }
341    // Daemon-side (zshrs-specific extensions).
342    for &n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.iter() {
343        s.insert(n.to_string());
344    }
345    s
346});
347
348use crate::exec_jobs::{JobState, JobTable};
349use crate::parse::{Redirect, RedirectOp, ShellCommand, ShellWord, VarModifier, ZshParamFlag};
350use indexmap::IndexMap;
351use std::collections::HashMap;
352use std::env;
353use std::fs::{File, OpenOptions};
354use std::io;
355use std::path::{Path, PathBuf};
356use std::process::{Child, Command, Stdio};
357
358// Re-exports for call-sites that reference `crate::ported::exec::<Name>`.
359pub use crate::bash_complete::CompSpec;
360pub use crate::ported::builtin::AutoloadFlags;
361pub use crate::ported::modules::zutil::zstyle_entry;
362
363/// One inline-assignment scope (`X=foo Y=bar cmd`).
364///
365/// `saved` holds `(name, prev_var, prev_env)` for each name the
366/// PREFIX assignments touched, so END_INLINE_ENV can put the shell
367/// var and the process env back.
368///
369/// `recording` is what keeps the frame from swallowing assignments
370/// the *command itself* performs. zsh's `addvars()` (Src/exec.c:4142)
371/// walks only the parsed WC_ASSIGN chain, and `save_params`
372/// (Src/exec.c:4410) snapshots only those names; once the command
373/// runs, the save list is closed. The bytecode emits the prefix
374/// assignments between BEGIN_INLINE_ENV and SEAL_INLINE_ENV, and
375/// SEAL clears this flag, so `X=y . file` no longer records (and
376/// then reverts) every global the sourced file assigns.
377pub struct InlineEnvFrame {
378    /// Per-name pre-assignment state, one entry per prefix assignment.
379    pub saved: Vec<SavedInlineParam>,
380    /// True only while the prefix assignments are being executed.
381    pub recording: bool,
382    /// C's `ADDVAR_EXPORT` bit for this command (c:Src/exec.c:37), decided
383    /// where C decides it: `if (is_shfunc) flags |= ADDVAR_EXPORT`
384    /// (c:Src/exec.c:4142-4143), under the comment "Export this if the
385    /// command is a shell function, but not if it's a builtin"
386    /// (c:4138-4140). `addvars` reads it to run the prefix assignment with
387    /// `opts[ALLEXPORT]` forced on (c:2646-2651), which is what makes
388    /// `X=y shellfn` leave `X` marked exported for the duration of the
389    /// call while `X=y builtin` does not.
390    pub export: bool,
391}
392
393/// One parameter a prefix assignment displaced — the Rust twin of the
394/// `tpm` that C's `save_params` builds with
395/// `copyparam(tpm, pm, 0)` (c:Src/exec.c:4491-4493).
396pub struct SavedInlineParam {
397    /// The assigned name (C's `s`, c:Src/exec.c:4475).
398    pub name: String,
399    /// The WHOLE pre-assignment `Param` — type flags, base, width, level
400    /// and the value union, with the value read back through the type's
401    /// GSU getfn exactly as `copyparam` does (c:Src/params.c:1273-1291).
402    /// `None` when the name did not exist, which is C's remove-only arm
403    /// at c:Src/exec.c:4507-4508.
404    pub pm: Option<crate::ported::zsh_h::param>,
405    /// !!! RUST-ONLY FIELD — THE SECOND HALF OF c:Src/params.c:1289 !!!
406    /// C's `copyparam` copies an association's pairs into `tpm->u.hash`,
407    /// so they travel inside the `Param` above. zshrs keeps them in the
408    /// name-keyed `paramtab_hashed_storage` instead, OUTSIDE the `Param`
409    /// (the structural mismatch `stdunsetfn` documents at
410    /// params.rs:11044), so the pairs must be carried — and restored —
411    /// alongside the node rather than within it.
412    pub hashed: Option<IndexMap<String, String>>,
413    /// Pre-assignment `environ` entry for the name.
414    pub prev_env: Option<String>,
415}
416
417impl InlineEnvFrame {
418    /// New frame, open for recording until SEAL_INLINE_ENV runs.
419    pub fn new() -> Self {
420        Self {
421            saved: Vec::new(),
422            recording: true,
423            // c:4141 `int flags = 0;` — BEGIN_INLINE_ENV sets the
424            // ADDVAR_EXPORT bit once it has resolved the command word.
425            export: false,
426        }
427    }
428
429    /// Put every displaced parameter back — the frame's half of
430    /// `restore_params` (c:Src/exec.c:4519), run when the prefixed
431    /// command returns.
432    ///
433    /// Saves are pushed as the prefix assignments execute, so a name
434    /// assigned twice (`X=1 X=2 cmd`) has its ORIGINAL in the first
435    /// entry; walking in reverse makes that the last write, which is
436    /// what C's single pre-`addvars` snapshot achieves.
437    pub fn restore(self) {
438        for saved in self.saved.into_iter().rev() {
439            // c:4525-4531 (remove the temporary parameter) followed by
440            // c:4533-4570 (put the saved one back). C's `remove_p` carries
441            // the name in BOTH arms of `save_params` (c:4504 runs whether or
442            // not a `tpm` was made), so the name is always in the remove
443            // list and the restore list holds the snapshot only if there was
444            // one.
445            crate::ported::exec::restore_params(
446                saved.pm.into_iter().collect(),
447                vec![saved.name.clone()],
448            );
449            // !!! RUST-ONLY — see `SavedInlineParam::hashed` !!!
450            // c:4561's `tpm->gsu.h->setfn(tpm, pm->u.hash)` in zshrs terms.
451            // Unconditional: when nothing was saved the row must be gone, or
452            // the pairs the command itself wrote would outlive the frame.
453            if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
454                match saved.hashed {
455                    Some(pairs) => {
456                        store.insert(saved.name.clone(), pairs);
457                    }
458                    None => {
459                        store.remove(saved.name.as_str());
460                    }
461                }
462            }
463            // c:4568-4569 — `if ((pm->node.flags & PM_EXPORTED) && ...)
464            // addenv(pm, s);`. zshrs's prefix-assignment path publishes the
465            // value with `zputenv` on the way in, so the way out is the
466            // matching restore of the pre-assignment `environ` entry.
467            match saved.prev_env {
468                Some(v) => std::env::set_var(&saved.name, &v),
469                None => std::env::remove_var(&saved.name),
470            }
471        }
472    }
473}
474
475impl Default for InlineEnvFrame {
476    fn default() -> Self {
477        Self::new()
478    }
479}
480
481/// Snapshot of subshell-isolated state. Captured at `(` entry, restored at
482/// `)` exit. zsh subshell semantics: assignments inside `(…)` don't leak to
483/// the outer scope — and that includes `export`. zsh forks a child for the
484/// subshell so the child's env::set_var dies with the child; without a fork
485/// (zshrs runs subshells in-process for perf), we snapshot+restore the OS
486/// env table around the subshell. Otherwise `(export y=v)` would leak `y`
487/// to the parent shell, breaking every script that uses a subshell to
488/// scope an env override.
489/// Snapshot of mutable executor state across a subshell
490/// boundary.
491/// Port of the `entersubsh()` save/restore Src/exec.c does at
492/// line 1084 — captures everything that must be replaced when a
493/// `(...)` group fires.
494pub struct SubshellSnapshot {
495    /// Snapshot of `paramtab` (the C-canonical parameter store) at
496    /// subshell entry. Step 1 of the unification mirrors writes to
497    /// paramtab, so subshell-scoped assignments now show up there
498    /// too — without this snapshot, restoring only `variables` /
499    /// `arrays` / `assoc_arrays` leaks the subshell's writes to the
500    /// parent via paramtab (e.g. `x=outer; (x=inner); echo $x` returned
501    /// `inner` because paramsubst reads through paramtab).
502    /// Same node storage as the live table (`Src/params.c:854`
503    /// `newparamtable(151, "paramtab")`) so restoring a snapshot restores
504    /// C's bucket-walk order too, not just the name→value mapping.
505    pub paramtab: crate::ported::hashtable::hashtable_nodes<crate::ported::zsh_h::Param>,
506    /// `paramtab_hashed_storage` field.
507    pub paramtab_hashed_storage: crate::cow_map::CowHashMap<String, IndexMap<String, String>>,
508    /// `positional_params` field.
509    pub positional_params: Vec<String>,
510    /// Values of the special parameters whose backing store is a process
511    /// GLOBAL rather than the parameter table — `Src/params.c`'s `char *ifs`
512    /// (IFS), `wordchars`, `home`, `histsiz`, … — each reached through a GSU
513    /// getfn/setfn pair (the dispatch list at params.rs:12548).
514    ///
515    /// The `paramtab` snapshot above restores the param NODE, but the node only
516    /// carries the GSU pair; the value itself lives in the global, which a
517    /// paramtab restore doesn't touch. C forks for `(...)`, so a child's writes
518    /// to those globals die with it. zshrs runs subshells in-process, so
519    /// `(IFS=,; :)` left the PARENT's IFS as `,` — and every later word-split
520    /// in the parent silently used it. Same fork-copy reasoning as `opts`
521    /// and `tables`.
522    pub special_globals: Vec<(String, String)>,
523    /// Flock fds (`Src/utils.c:2111` `addlockfd`) live at subshell
524    /// entry. `zsystem flock FILE` keeps the fd open for the life of
525    /// the shell; under C's forked `(...)` the child's fd — and hence
526    /// the lock — dies when the subshell exits. zshrs runs subshells
527    /// in-process, so the lock outlived the subshell and every later
528    /// `zsystem flock` on that file (from a real forked background job)
529    /// blocked forever. Recorded here so `subshell_end` can close the
530    /// fds the subshell itself opened.
531    pub flock_fds: Vec<i32>,
532    /// `loops` / `breaks` / `contflag` at subshell entry
533    /// (c:Src/loop.c, c:Src/builtin.c bin_break). C forks for `(...)`,
534    /// so a `break` executed inside dies with the child and the parent's
535    /// loop runs on: `for i in 1 2; do (break); print after; done` prints
536    /// `after` twice. zshrs runs subshells in-process, so the three
537    /// counters have to be restored by hand at the boundary.
538    /// Fourth slot: `ancestor_loops` (c:Src/loop.c:36), which entry moves
539    /// `loops` into (c:Src/exec.c:1263-1264, 55061).
540    pub loop_flags: (i32, i32, i32, i32),
541    /// `cmdoutval` (c:Src/exec.c:225) at subshell entry. A process global
542    /// the forked child owns a private copy of: `x=$(exit 3); (y=1); exec
543    /// 3>f` still reports 3 in zsh although the subshell's assignment reset
544    /// its own copy (c:2796). Restored by hand like `loop_flags`.
545    pub cmdoutval: i32,
546    /// `stopmsg` (c:Src/builtin.c) at subshell entry. Another process
547    /// global the child owns a copy of: a nofork `${ exit N }` in a
548    /// subshell sets it (c:Src/subst.c:2100) and the child then exits, so
549    /// in C it never reaches the parent. Leaked here it made every later
550    /// `exit` in a function or nofork skip its deferral (c:5884).
551    pub stopmsg: i32,
552    /// Parent's traps at subshell entry. zsh's `(trap "echo X" EXIT;
553    /// true)` runs the trap when the subshell exits — BEFORE the parent
554    /// continues. Without this snapshot, the trap inherited from parent
555    /// would fire, OR a trap set inside the subshell would leak to the
556    /// parent's process exit. Restored on subshell_end after the
557    /// subshell's own EXIT trap (if any) has fired. Stores a snapshot
558    /// of `crate::ported::builtin::traps_table()` (canonical).
559    pub traps: HashMap<String, String>,
560    /// Parent's shell options at subshell entry. `(set -e)` /
561    /// `(setopt extendedglob)` mustn't leak; zsh forks the subshell
562    /// so child options die with the child. We run in-process, so we
563    /// must restore the option store on subshell_end.
564    pub opts: HashMap<String, bool>,
565    /// The fork-copied hash tables — `aliastab`, `sufaliastab`, … — at
566    /// subshell entry (see `crate::ported::exec::SubshForkCopy`). zsh forks
567    /// for `(...)` so `(alias x=y)` inside a subshell dies with the child
568    /// and doesn't leak to the parent. Bug #209 in docs/BUGS.md. The
569    /// tables are restored whole — node flags (ALIAS_GLOBAL / DISABLED)
570    /// and bucket order included — which the `(name, text, flags)` list
571    /// this replaced got only partly right, and it never covered
572    /// `sufaliastab` at all: `(alias -s x=y)` leaked.
573    pub tables: crate::ported::exec::SubshForkCopy,
574    /// Parent's shell-function table at subshell entry. C zsh's
575    /// `entersubsh` (`Src/exec.c`) forks before running the
576    /// subshell body so `(f() { ... })` defining a function dies
577    /// with the child and never leaks to the parent. zshrs runs
578    /// subshells in-process, so we must clone `shfunctab` on entry
579    /// and restore on exit. Bug #208 in docs/BUGS.md. Stored as a
580    /// clone of the whole `shfunc_table` — the bucket layout is part
581    /// of the state, because `${(k)functions}` / `compadd -k functions`
582    /// emit C's bucket-walk order verbatim
583    /// (`Src/Modules/parameter.c:480-481`); rebuilding the table from
584    /// an unordered map on restore would reshuffle that order after
585    /// every `( … )` / `$( … )`.
586    pub shfuncs: std::sync::Arc<crate::ported::hashtable::shfunc_table>,
587    /// Parent's compiled-function chunks at subshell entry. Companion
588    /// to `shfuncs` above — `ShellExecutor.functions_compiled` is the
589    /// runtime dispatch table that `Op::CallFunction` reads through;
590    /// without restoring it, a subshell `(g() { override; })` leaves
591    /// the override bytecode chunk in place so the parent's
592    /// `g` call still runs the override after `subshell_end`
593    /// restored shfunctab. Bug #208 in docs/BUGS.md.
594    ///
595    /// Copy-on-write (`crate::cow_map`): taking this snapshot is a
596    /// refcount bump, and the deep copy happens only if the subshell
597    /// body actually compiles or redefines a function.
598    pub functions_compiled: crate::cow_map::CowHashMap<String, fusevm::Chunk>,
599    /// Parent's function source map at subshell entry. Companion to
600    /// `functions_compiled` so `typeset -f` / `whence` show the
601    /// parent's source after subshell exit, not the subshell's
602    /// overridden body. Bug #208 in docs/BUGS.md.
603    ///
604    /// Copy-on-write, for the same reason as `functions_compiled`.
605    pub function_source: crate::cow_map::CowHashMap<String, String>,
606    /// Parent's THINGYTAB (ZLE widget registry) at subshell entry.
607    /// zsh forks for `(...)` so `zle -N w f` / `zle -D w` inside the
608    /// subshell flip widget bindings only in the child; when the
609    /// child exits the parent's widget table is untouched. zshrs runs
610    /// subshells in-process so a subshell's `zle -D w` would
611    /// otherwise unbind the parent's widget. Bug #453 in docs/BUGS.md.
612    pub thingytab: HashMap<String, crate::ported::zle::zle_thingy::Thingy>,
613    /// Parent's KEYMAPNAMTAB (named keymap registry) at subshell
614    /// entry. Same fork-copy semantics as THINGYTAB — a subshell's
615    /// `bindkey -N km` / `bindkey -D km` mutates only the child's
616    /// keymap registry in C zsh. Bug #454 in docs/BUGS.md.
617    pub keymapnamtab:
618        crate::ported::hashtable::hashtable_nodes<crate::ported::zle::zle_keymap::KeymapName>,
619    /// Parent's `$!` (clone::lastpid) at subshell entry. C zsh forks
620    /// for `(...)`, so a background job started INSIDE the subshell
621    /// sets the child's `lastpid` only — `( : & ); echo $!` prints 0
622    /// in zsh. zshrs runs subshells in-process, so restore on end.
623    pub lastpid: i32,
624    /// Job-control state at subshell entry: (JOBTAB clone, CURJOB,
625    /// PREVJOB, MAXJOB, THISJOB). C zsh forks for `(...)` so any
626    /// `disown` / `wait` / new `&` job inside the subshell mutates the
627    /// CHILD's copy of jobtab and dies with it (Src/exec.c::entersubsh
628    /// fork semantics); the parent's table is untouched. zshrs's
629    /// in-process subshell must snapshot/restore to match — without
630    /// this, `sleep 1 & (disown); jobs` shows an empty table where
631    /// zsh still lists the job. Bug #462.
632    pub jobtab: Vec<crate::ported::zsh_h::job>,
633    /// `curjob` at subshell entry (Src/jobs.c:75 global).
634    pub curjob: i32,
635    /// `prevjob` at subshell entry (Src/jobs.c:80 global).
636    pub prevjob: i32,
637    /// `maxjob` at subshell entry (Src/jobs.c:71 global).
638    pub maxjob: usize,
639    /// `thisjob` at subshell entry (Src/jobs.c:77 global).
640    pub thisjob: i32,
641    /// `oldjobtab` / `oldmaxjob` at subshell entry (Src/jobs.c:101/104).
642    /// The subshell's `clearjobtab` (c:1808-1825) saves the parent's jobs
643    /// there; in C that copy lives in the forked child only, so the
644    /// parent's own `oldjobtab` must come back unchanged or its `jobs`,
645    /// `$jobstates` and `disown` would read the dead subshell's snapshot.
646    pub oldjobtab: Vec<crate::ported::zsh_h::job>,
647    pub oldmaxjob: usize,
648    /// The descriptors 0-9 a bare `exec` redirection in the body moves
649    /// are saved here on first touch and put back when this is dropped
650    /// (see `crate::ported::exec::SubshFdFrame`). C zsh forks for `(...)`
651    /// so the child's `exec >file` / `exec 3<&-` dies with it; without
652    /// this, `(exec >t.log; ...); cat t.log` left the PARENT's fd 1
653    /// pointing at t.log and `cat` looped forever copying the file into
654    /// itself.
655    pub fd_frame: crate::ported::exec::SubshFdFrame,
656    /// `redirect_scope_stack` depth at entry. A body aborted by an error
657    /// skips its `WithRedirectsEnd`; C's forked child takes those fds with
658    /// it, so subshell_end unwinds to here.
659    pub redir_depth: usize,
660    /// Pipeline job slots held at entry (`exec_jobs::execpline_slots_mark`).
661    /// Slots the body takes come from the cleared child table, so
662    /// subshell_end drops their records along with that table.
663    pub slots_mark: usize,
664    /// `sigtrapped[]` at subshell entry (Src/signals.c:39). C's
665    /// `entersubsh` clears per-signal trap STATE via `unsettrap(sig)`
666    /// (c:Src/exec.c:1088-1092), which zeroes both the body and the
667    /// sigtrapped flags. zshrs cleared only the body table, so the flags
668    /// desynced: a subshell that dropped a trap body still reported the
669    /// signal as trapped. Snapshot the whole vector so subshell_end can
670    /// restore the parent's exact state (including an inherited
671    /// ZSIG_IGNORED on SIGQUIT).
672    pub sigtrapped: Vec<i32>,
673    /// `subsh` at subshell entry (Src/exec.c:160 global). C's
674    /// `entersubsh` sets `subsh = 1` for a real (non-ESUB_FAKE)
675    /// subshell at c:Src/exec.c:1192-1193, and the forked child
676    /// carries it for the whole body. PRINT_EXIT_VALUE reads it
677    /// (c:4309 `&& !subsh`), which is why zsh prints nothing for
678    /// `setopt printexitvalue; (false)` while still reporting a
679    /// bare `false`. zshrs runs `( … )` in-process, so the flag has
680    /// to be set on entry and restored by hand on End.
681    pub subsh: i32,
682    /// The libc locale on entry, as `setlocale(LC_ALL, NULL)` reports it.
683    /// `LC_ALL=C` / `LANG=…` / `LC_CTYPE=…` inside the body reach libc via
684    /// `setlang` / `lcsetfn` (c:Src/params.c:4811, c:4877), which in C
685    /// changes only the forked child. The paramtab restore puts the
686    /// parameters back without re-running their setfns, so the parent kept
687    /// the child's locale: `() { (LC_ALL=C; true) }` left `${(q+)…}` quoting
688    /// multibyte text as raw bytes for the rest of the script.
689    pub locale: Option<std::ffi::CString>,
690}
691
692/// !!! WARNING: RUST-ONLY !!! C forks `( … )` and `$( … )`, so a locale the
693/// body sets dies with the child. zshrs runs both in-process; this records
694/// the parent's libc locale on entry for `subsh_locale_restore`.
695pub(crate) fn subsh_locale_save() -> Option<std::ffi::CString> {
696    let cur = unsafe { libc::setlocale(libc::LC_ALL, std::ptr::null()) };
697    (!cur.is_null()).then(|| unsafe { std::ffi::CStr::from_ptr(cur) }.to_owned())
698}
699
700/// !!! WARNING: RUST-ONLY !!! Put back the locale `subsh_locale_save`
701/// recorded, if the body changed it, and redo what the locale setfns do after
702/// `setlocale` (c:Src/params.c:4841 `clear_mbstate()`, c:4842 `inittyptab()`).
703/// Call it after the paramtab restore: inittyptab reads $IFS. The typtab is
704/// rebuilt only when the locale differs, so an ordinary subshell does not
705/// rewrite the table concurrent lexers read.
706pub(crate) fn subsh_locale_restore(saved: Option<&std::ffi::CStr>) {
707    let Some(loc) = saved else { return };
708    let cur = unsafe { libc::setlocale(libc::LC_ALL, std::ptr::null()) };
709    if !cur.is_null() && unsafe { std::ffi::CStr::from_ptr(cur) } == loc {
710        return;
711    }
712    unsafe { libc::setlocale(libc::LC_ALL, loc.as_ptr()) };
713    crate::ported::params::clear_mbstate();
714    crate::ported::utils::inittyptab();
715}
716
717#[allow(unused_imports)]
718pub(crate) use crate::ported::pattern::{
719    extract_numeric_ranges, numeric_range_contains, numeric_ranges_to_star,
720};
721
722/// Top-level shell executor state.
723/// Fork-equivalent event counter — incremented on every external
724/// spawn and in-process subshell entry. C zsh's `time` keyword
725/// reports only for JOBS (forked work, Src/jobs.c printtime via the
726/// job table); zshrs runs builtins/braces/functions in-process with
727/// no job, so the TIME_SUBLIST handler compares this counter across
728/// the timed body to decide whether to emit the report.
729pub static FORK_EVENTS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
730
731/// Port of the file-static globals + `Estate` chain Src/exec.c
732/// uses — `execlist()` (line 1349) drives every list, with
733/// `execpline()` (line 1668), `execpline2()` (line 1991),
734/// `execsimple()` (line 1290), and the per-`WC_*` `execfuncs[]`
735/// table (line 268) feeding off it. The Rust port collapses
736/// everything into one `ShellExecutor` so we don't need
737/// thread-local globals.
738pub struct ShellExecutor {
739    /// Mirrors C zsh's file-static `scriptname` (Src/init.c). Used by
740    /// PS4's `%N` and the `scriptname:line: …` prefix on error
741    /// messages. Inside a function, MUTATES to the function name
742    /// (Src/exec.c:5903 `scriptname = dupstring(name)`). Init sets
743    /// this in `-c` mode to the binary basename per init.c:479; when
744    /// sourcing a file via `source`/`bin_dot`, it becomes the
745    /// resolved file path; otherwise it falls back through `$0` →
746    /// `$ZSH_ARGZERO`.
747    pub scriptname: Option<String>,
748    /// Mirrors C zsh's `scriptfilename` global (Src/init.c). Tracks
749    /// the FILE BEING READ (vs scriptname which tracks the active
750    /// function name during a call). Used by PS4's `%x` and certain
751    /// error-message prefixes that want the file location, NOT the
752    /// function name.
753    ///
754    /// At -c-mode init, scriptname == scriptfilename == "zsh"
755    /// (Src/init.c:479). When entering a function, ONLY scriptname
756    /// updates (exec.c:5903); scriptfilename stays at the outer
757    /// file path, so `%x` inside a function still shows the file
758    /// the function was called from.
759    pub scriptfilename: Option<String>,
760    /// Stack of subshell-state snapshots. Each `(…)` subshell pushes a copy
761    /// of variables/arrays/assoc_arrays at entry and pops/restores at exit.
762    /// Without this, `(x=inner; …); echo $x` shows `inner` instead of the
763    /// outer-scope value.
764    pub subshell_snapshots: Vec<SubshellSnapshot>,
765    /// Stack of inline-assignment scopes — `X=foo Y=bar cmd` pushes
766    /// a frame at the start, the assigns run inside it, and `cmd`
767    /// returns into END_INLINE_ENV which restores both shell-vars
768    /// and process-env to the pre-frame state. Each frame holds
769    /// `(name, prev_var, prev_env)` per assigned name. zsh's
770    /// equivalent is the parser-level "addvar" list executed under
771    /// `addvars()` (Src/exec.c) right before the command exec.
772    pub inline_env_stack: Vec<InlineEnvFrame>,
773    /// Set by `expand_glob`'s no-match arm when `nomatch` is on (zsh
774    /// default) — instructs the simple-command dispatcher to skip
775    /// executing the current command, set last_status=1, and continue
776    /// to the next command in the script. zsh's bin_simple uses the
777    /// errflag global for the same role: error printed, command
778    /// suppressed, script continues. Without this we were calling
779    /// `process::exit(1)` deep inside expand_glob, killing the whole
780    /// shell on any unmatched glob even with multi-statement input.
781    /// `Cell` because the no-match site only has a `&self` borrow.
782    pub current_command_glob_failed: std::cell::Cell<bool>,
783    /// `jobs` field.
784    pub jobs: JobTable,
785    /// `fpath` field.
786    pub fpath: Vec<PathBuf>,
787    /// SQLite history index, opened on FIRST USE rather than at startup.
788    ///
789    /// Only `--doctor`, the cache report and `dbview history` read it, and
790    /// none of them runs on a script or `-c` invocation — but opening it
791    /// eagerly cost every `zshrs script.zsh` an sqlite open plus
792    /// `init_schema` (0.40 ms of a 1.75 ms startup, measured with
793    /// `ZSHRS_STARTUP_TRACE`). A pre-filled `None` is how a pool worker
794    /// declares it must never open one.
795    pub history: std::cell::OnceCell<Option<HistoryEngine>>,
796    pub(crate) process_sub_counter: u32,
797    pub completions: HashMap<String, CompSpec>, // command -> completion spec
798    pub zstyles: Vec<zstyle_entry>,             // zstyle configurations
799    /// Current function scope depth for `local` tracking.
800    pub local_scope_depth: usize,
801    /// Last arg of the currently-running command, deferred into `$_`
802    /// when the next command dispatches. zsh: `$_` reflects the LAST
803    /// command's last arg, so `echo hi; echo $_` prints `hi` (not the
804    /// `_` arg of `echo $_` itself). Promoted in `pop_args` and
805    /// `host.exec` before the command's args are read.
806    pub pending_underscore: Option<String>,
807    /// True while expanding inside a double-quoted context. Set by
808    /// `BUILTIN_EXPAND_TEXT` mode 1 around `expand_string` calls.
809    /// Used by parameter-flag application to suppress array-only flags
810    /// (`(o)`/`(O)`/`(n)`/`(i)`/`(M)`/`(u)`) — zsh's behaviour: those
811    /// flags only fire in array context.
812    pub in_dq_context: u32,
813    /// True (>0) while expanding the RHS of a scalar assignment.
814    /// Direct port of zsh's `PREFORK_SINGLE` bit set by
815    /// Src/exec.c::addvars line 2546 (`prefork(vl, isstr ?
816    /// (PREFORK_SINGLE|PREFORK_ASSIGN) : PREFORK_ASSIGN, ...)`).
817    /// Subst_port's paramsubst reads this via `ssub` and suppresses
818    /// `(f)` / `(s:STR:)` / `(0)` / `(z)` split flags per
819    /// Src/subst.c:1759 + 3902, so `y="${(f)x}"` preserves x's
820    /// original separator (newlines) instead of re-joining with
821    /// IFS-first-char (space).
822    pub in_scalar_assign: u32,
823    /// `profiling_enabled` field.
824    pub profiling_enabled: bool,
825    // compsys - completion system cache
826    /// `compsys_cache` field.
827    /// SQLite mirror, opened on FIRST USE via [`ShellExecutor::compsys_cache`].
828    ///
829    /// It is a dbview/FTS mirror for inspection — the authoritative completion
830    /// cache is the rkyv shards — so nothing on a normal command path touches
831    /// it. Opening it in the constructor still cost every shell three file
832    /// opens (`compsys.db`, `-wal`, `-shm`) plus WAL setup, including
833    /// `zshrs -f -c exit`, which cannot consult it at all.
834    pub compsys_cache: std::cell::OnceCell<Option<CompsysCache>>,
835    // Background compinit — receiver for async fpath scan result
836    /// `compinit_pending` field.
837    pub compinit_pending: Option<(
838        std::sync::mpsc::Receiver<CompInitBgResult>,
839        std::time::Instant,
840    )>,
841    // Plugin source cache — stores side effects of source/. in SQLite
842    /// `plugin_cache` field.
843    pub plugin_cache: Option<crate::plugin_cache::PluginCache>,
844    // cdreplay - deferred compdef calls for zinit turbo mode
845    /// `deferred_compdefs` field.
846    pub deferred_compdefs: Vec<Vec<String>>,
847    // Control flow signals
848    pub returning: Option<i32>, // Set by return builtin, cleared after function returns
849    /// zsh compatibility mode - use .zcompdump, fpath scanning, etc.
850    /// Also serves as the `--zsh` parity-test flag: caches off, daemon
851    /// off, plugin_cache replay off so every `source` re-runs the file
852    /// fresh per Src/builtin.c:6080-6123 bin_dot semantics.
853    pub zsh_compat: bool,
854    /// bash compatibility mode (`--bash`). Same parity-mode semantics
855    /// as `zsh_compat` (caches/daemon/replay off) plus bash-specific
856    /// behavior tweaks where bash 5.x diverges from zsh — e.g.
857    /// `BASH_VERSION` / `BASH_REMATCH` exposed, `[[ =~ ]]` populates
858    /// match indices the bash way, mapfile/readarray as builtins.
859    pub bash_compat: bool,
860    /// POSIX sh strict mode — no SQLite, no worker pool, no zsh extensions
861    pub posix_mode: bool,
862    /// Worker thread pool for background tasks (compinit, process subs, etc.)
863    pub worker_pool: std::sync::Arc<crate::worker::WorkerPool>,
864    /// AOP intercept table: command/function name → advice chain.
865    /// Glob patterns supported (e.g. "git *", "*").
866    pub intercepts: Vec<Intercept>,
867    /// Async job handles: id → receiver for (status, stdout)
868    pub async_jobs: HashMap<u32, crossbeam_channel::Receiver<(i32, String)>>,
869    /// Next async job ID
870    pub next_async_id: u32,
871    /// Per-scope saved-fd stacks for `Op::WithRedirectsBegin/End`. Each entry
872    /// is a Vec of (fd, saved_dup_fd) pairs taken from `dup(fd)` before the
873    /// redirect was applied; `with_redirects_end` `dup2`s them back and closes.
874    pub redirect_scope_stack: Vec<Vec<(i32, i32)>>,
875    /// Per-scope MULTIOS tee state. Each entry is `(pipe_write_fd,
876    /// JoinHandle)`: the pipe write-end currently dup2'd onto the
877    /// command's fd, and the splitter thread that reads from the
878    /// pipe read-end and writes to every collected target. Closed
879    /// + joined by `host_redirect_scope_end` BEFORE the saved fds
880    /// are restored so the splitter drains every byte the body
881    /// wrote into the pipe. Bug #36 in docs/BUGS.md.
882    pub multios_scope_stack: Vec<Vec<(i32, std::thread::JoinHandle<()>)>>,
883    /// True while applying a bare `exec`'s redirect list (`exec 1>&-`,
884    /// `exec 2>/dev/null` — no command words). `host_apply_redirect`
885    /// then skips pushing the saved fd into the enclosing scope so the
886    /// fd change survives group/command teardown.
887    /// c:Src/exec.c:3978-3986 — nullexec==1: "we specifically *don't*
888    /// restore the original fd's before returning"; C's per-execcmd
889    /// `save[]` means exec's redirs never enter the enclosing group's
890    /// save list either. Toggled by `BUILTIN_EXEC_PERM_REDIRS`.
891    pub exec_redirs_permanent: bool,
892    /// Set in a forked pipeline-stage child right after its stdout is
893    /// dup2'd onto the pipe write-end. Consumed by the FIRST
894    /// `host_redirect_scope_begin` (the stage command's own redirect
895    /// list) into `pipe_output_scope`.
896    /// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output,
897    /// 1, NULL)`: the pipe occupies mfds[1] in the SAME execcmd that
898    /// processes the stage command's redirect list.
899    pub pipe_output_pending: bool,
900    /// Index into `redirect_scope_stack` of the scope whose redirect
901    /// list shares an execcmd with the pipeline output on fd 1. A
902    /// write-side redirect of fd 1 applied at exactly this scope depth
903    /// MULTIOS-splits (tees) instead of replacing — c:Src/exec.c:
904    /// 2447-2480 addfd "split the stream". Cleared when that scope ends.
905    pub pipe_output_scope: Option<usize>,
906    /// Set by `host_apply_redirect` when a redirect target couldn't be
907    /// opened (permission denied, no such directory, etc). The next
908    /// builtin/command checks this at entry and short-circuits with
909    /// status 1 instead of running. Mirrors zsh's "command skip" on
910    /// redirect failure.
911    pub redirect_failed: bool,
912    /// Compiled function bodies — name → fusevm::Chunk. Populated by
913    /// `BUILTIN_REGISTER_FUNCTION` (from `FunctionDef` lowering) and lazily by
914    /// `ZshrsHost::call_function` when only an AST exists in `self.functions`
915    /// (autoloaded, sourced, etc.). `Op::CallFunction` dispatches through here.
916    ///
917    /// COPY-ON-WRITE. `$( … )` and `( … )` snapshot this whole map for
918    /// subshell isolation (Bug #208), so a plain `HashMap` made entering
919    /// a substitution cost one `Chunk` clone per compiled function. It
920    /// is a `CowHashMap`, so the snapshot shares and the first write
921    /// inside the body splits. Reads and writes are unchanged —
922    /// `Deref`/`DerefMut` keep every call site as it was.
923    pub functions_compiled: crate::cow_map::CowHashMap<String, fusevm::Chunk>,
924    /// Canonical source text for functions. Populated by autoload paths (the
925    /// raw file/cache body), runtime FuncDef compile (the parsed source span),
926    /// and `unfunction` removal. Used by introspection (`whence`, `which`,
927    /// `typeset -f`) instead of reconstructing from a ShellCommand AST. When a
928    /// function is in `functions_compiled` but not here, introspection falls
929    /// back to `text::getpermtext(self.functions[name])`.
930    ///
931    /// Copy-on-write for the same reason as `functions_compiled`: a
932    /// subshell snapshots it whole, and the body usually never writes.
933    pub function_source: crate::cow_map::CowHashMap<String, String>,
934    /// `first_body_line - 1` per compiled function — matches inner
935    /// `ZshCompiler::lineno_offset` / zsh `funcstack->flineno` combined with
936    /// relative `$LINENO` for Src/prompt.c:909 `%I`.
937    pub function_line_base: HashMap<String, i64>,
938    /// `scriptfilename` when `BUILTIN_REGISTER_COMPILED_FN` ran — `%x` inside
939    /// a function (prompt.c:931-934) reads `funcstack->filename`.
940    pub function_def_file: HashMap<String, Option<String>>,
941    /// Innermost-last stack of active compiled-call frames for prompt `%I` / `%x`.
942    pub prompt_funcstack: Vec<(String, i64, Option<String>)>,
943    /// Scalar→(array, sep) tie table set up by `typeset -T VAR var [SEP]`.
944    /// Array→(scalar, sep) reverse-tie table. Used by BUILTIN_SET_ARRAY to
945    /// join the array elements with `sep` and mirror to the scalar side.
946    pub tied_array_to_scalar: HashMap<String, (String, String)>,
947
948    // ── ztest framework counters (extensions/ztest.rs) ──────────────────
949    //
950    // Mirrors strykelang's per-VMHelper test counters
951    // (strykelang/builtins.rs:22292-22308 + builtins.rs::test_pass/_fail/_skip,
952    //  builtins.rs::test_pass_count etc.). Each `zassert_*` builtin bumps
953    // the per-block counter; `ztest_run` rolls per-block into _total and
954    // resets the per-block side, so a single test file with multiple
955    // `ztest_run` calls can reuse the counters. The worker-pool runner in
956    // src/extensions/ztest.rs reads pass_total+pass_count and
957    // fail_total+fail_count after `execute_script` returns for the cumulative
958    // numbers (strykelang/cli_runners.rs:115-118). `ztest_run_failed` is a
959    // sticky bool the runner reads so a test that asserts but then exits
960    // 0 still flags as failed. `ztest_suppress_stdout` matches
961    // VMHelper::suppress_stdout — the runner sets it inside the forked
962    // grandchild so the per-test stderr capture stays clean.
963    /// Per-block pass count (reset by `ztest_run`).
964    pub ztest_pass_count: std::sync::atomic::AtomicUsize,
965    /// Per-block fail count (reset by `ztest_run`).
966    pub ztest_fail_count: std::sync::atomic::AtomicUsize,
967    /// Per-block skip count (reset by `ztest_run`).
968    pub ztest_skip_count: std::sync::atomic::AtomicUsize,
969    /// Cumulative pass total across the run.
970    pub ztest_pass_total: std::sync::atomic::AtomicUsize,
971    /// Cumulative fail total across the run.
972    pub ztest_fail_total: std::sync::atomic::AtomicUsize,
973    /// Cumulative skip total across the run.
974    pub ztest_skip_total: std::sync::atomic::AtomicUsize,
975    /// Sticky failure flag — set by any `ztest_run` that observed fails;
976    /// the CLI runner reads this so a test that asserts then exits 0
977    /// still counts as a failed file.
978    pub ztest_run_failed: std::sync::atomic::AtomicBool,
979    /// Suppress per-assertion `✓`/`✗` lines on stderr. Set by the worker
980    /// runner inside the forked child when it has already redirected
981    /// fd 2 to a tmp file (we still want the lines, but only after the
982    /// runner re-emits them under print_lock to avoid line-tearing).
983    pub ztest_suppress_stdout: bool,
984}
985
986/// Context-isolated nested parse — the AST-path bridge for C's
987/// `parse_string` (`Src/exec.c:283`). zshrs executes via the ZshProgram
988/// AST + `compile_zsh`, not wordcode, so this can't live in `src/ported/`
989/// (C's `parse_string` returns `Eprog`, and the build.rs port-gate rejects
990/// any non-C-named fn there). It's the bridge that wraps the AST
991/// `parse_init`+`parse` in the SAME isolation `parse_string` provides.
992///
993/// Why this exists: a runtime parse — command-substitution body,
994/// process-substitution argv — must not clobber the outer
995/// `loop()`/`parse_event` reader's live input when it interleaves parsing
996/// with execution (faithful single-event mode).
997///
998/// The load-bearing piece is `strinbeg(0)`/`strinend()` (c:290/298). It
999/// sets the `strin` flag so that when the nested lexer drains `cmd_str`,
1000/// `ingetc` returns EOF (input.rs:391) instead of falling through to
1001/// `inputline()`, which would STEAL the outer reader's next SHIN line —
1002/// e.g. `echo A` / `v=$(echo hi)` / `echo B` had the cmd-subst swallow
1003/// `echo B` off stdin, and the outer loop then hit EOF after one command.
1004/// `strinbeg` also runs `hbegin`/`lexinit`, so it must execute on isolated
1005/// history+lexer state — hence the surrounding `zcontext_save`/`restore`
1006/// (c:288/300), which saves+restores `tok`/`tokstr`/`lexbuf`/`isnewlin`/
1007/// `incmdpos`/heredocs/`lexstop`/`toklineno`/history.
1008///
1009/// Two zshrs-specific globals `zcontext` doesn't cover are saved here too:
1010/// the lexer-input window (`LEX_INPUT`/`LEX_POS`/`LEX_UNGET_BUF`) that
1011/// `lex_init` overwrites with `cmd_str`, and the line counter `LEX_LINENO`
1012/// (C saves `oldlineno` explicitly at parse_string c:291/295).
1013///
1014/// NOTE: using `inpush` (the literal C input stack) instead does NOT work
1015/// in zshrs's hybrid input model — the outer piped reader pulls from SHIN
1016/// via `inputline`, and pushing/popping the `inbuf` stack severs that
1017/// continuation. The `LEX_INPUT` window + `strin` flag is the working
1018/// equivalent.
1019pub(crate) fn parse_isolated(input: &str) -> crate::parse::ZshProgram {
1020    parse_isolated_at(input, None)
1021}
1022
1023/// `parse_isolated` with C's `reset_lineno` argument inverted: `Some(n)` is
1024/// `parse_string(s, 0)` (c:Src/exec.c:291-293), which parses from the line the
1025/// shell is executing, `n`, instead of from line 1. A parse error in a body
1026/// parsed at run time (`` `…` `` in getoutput c:4778) then carries that line,
1027/// or none at all when it is 0 (c:Src/utils.c:301).
1028pub(crate) fn parse_isolated_at(input: &str, lineno: Option<u64>) -> crate::parse::ZshProgram {
1029    use crate::ported::lex::{
1030        tok, LEXERR, LEX_INPUT, LEX_LINENO, LEX_POS, LEX_UNGET_BUF, LEX_FILE_WINDOW_STRIN,
1031    };
1032
1033    // Inline Rust FFI: rewrite every `rust { ... }` block into a
1034    // `__rust_compile '<base64>' <line>` command before it reaches the lexer.
1035    // This is the shared source-string chokepoint for `-c`, script files, and
1036    // nested (command/process-substitution) parses. The `.contains("rust")`
1037    // gate keeps the common case (no FFI block) allocation-free — the vast
1038    // majority of nested parses never mention `rust`.
1039    let ffi_desugared = input
1040        .contains("rust")
1041        .then(|| crate::rust_ffi::desugar(input));
1042    let input: &str = ffi_desugared.as_deref().unwrap_or(input);
1043
1044    crate::ported::context::zcontext_save(); // c:288
1045                                             // Save the zshrs-specific lexer window + line counter that lex_init
1046                                             // overwrites but zcontext doesn't cover.
1047    let saved_input = LEX_INPUT.with_borrow(|s| s.clone());
1048    let saved_pos = LEX_POS.get();
1049    let saved_unget = LEX_UNGET_BUF.with_borrow(|b| b.clone());
1050    let saved_lineno = LEX_LINENO.get(); // c:291 oldlineno
1051    // The nested parse installs its own window; `lex_init` marks it a
1052    // string unit. Put the outer window's kind back with the window.
1053    let saved_file_window = LEX_FILE_WINDOW_STRIN.get();
1054                                         // input.rs `lexstop` is the input-side half of C's single `lexstop`;
1055                                         // draining the nested LEX_INPUT sets it true and zcontext only covers
1056                                         // the lex.rs half (LEX_LEXSTOP). Restore it so the outer reader isn't
1057                                         // left at EOF.
1058    let saved_in_lexstop = crate::ported::input::lexstop.with(|c| c.get());
1059
1060    // c:Src/exec.c:294 `inpush(s, INP_LINENO, NULL)` — the nested parse
1061    // gets its own input frame, so whatever the OUTER reader still has
1062    // queued is parked on the input stack until the inpop below. The text
1063    // itself is lexed from LEX_INPUT (parse_init), so the pushed frame is
1064    // empty: once that window drains, ingetc hits the end of THIS frame
1065    // and returns EOF. Without the frame it fell through to the outer
1066    // buffer — the rest of a multi-line command line, still unread while
1067    // `preexec` ran an `eval` or `$(…)` — and swallowed it.
1068    crate::ported::input::inpush("", crate::ported::zsh_h::INP_LINENO, None);
1069    crate::ported::hist::strinbeg(0); // c:290 — strin++ → drained nested input EOFs (no SHIN steal)
1070    crate::ported::parse::parse_init(input); // install cmd_str as LEX_INPUT (lex_init), LEX_LINENO=1
1071    if let Some(n) = lineno {
1072        LEX_LINENO.set(n); // c:292 `if (reset_lineno)` not taken: keep the running line
1073    }
1074    let program = crate::ported::parse::parse(); // c:294 (AST analog of par_list)
1075
1076    // Capture parse failure BEFORE the restores wipe the signals.
1077    let parse_err = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0 || tok() == LEXERR;
1078    if tok() == LEXERR && crate::ported::builtin::LASTVAL.load(Ordering::Relaxed) == 0 {
1079        crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:296-297
1080    }
1081
1082    crate::ported::hist::strinend(); // c:298 — strin--
1083    // c:Src/exec.c:304 `inpop()` — pops the pushed frame AND its continuations. An
1084    // alias expanded at the end of the body sits on top as an
1085    // INP_ALIAS|INP_CONT frame (c:Src/input.c:695), so this is also where its
1086    // `inuse` is cleared (c:Src/input.c:773): left behind, the alias stayed
1087    // `inuse` and every later `$(m)` / `m` reported "command not found".
1088    // Popping the frame pushed above restores the outer reader's queue.
1089    crate::ported::input::inpop();
1090                                     // Restore the zshrs window, then the token/parse/history state.
1091    LEX_INPUT.with_borrow_mut(|s| *s = saved_input);
1092    LEX_POS.set(saved_pos);
1093    LEX_UNGET_BUF.with_borrow_mut(|b| *b = saved_unget);
1094    LEX_LINENO.set(saved_lineno); // c:295
1095    LEX_FILE_WINDOW_STRIN.set(saved_file_window);
1096    crate::ported::input::lexstop.with(|c| c.set(saved_in_lexstop));
1097    crate::ported::context::zcontext_restore(); // c:300
1098                                                // zcontext_restore → parse_context_restore clears ERRFLAG_ERROR
1099                                                // (parse.c:354); re-raise so callers gating on the bit still see it.
1100    if parse_err {
1101        errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed);
1102    }
1103    program
1104}
1105
1106/// Build the `scriptname[:lineno]` prefix zsh puts on an execution error.
1107///
1108/// c:Src/utils.c:301 — `zerrmsg` prints the line number ONLY when it is
1109/// non-zero: `if ((unset(SHINSTDIN) || locallevel) && lineno) fprintf(file,
1110/// "%lld: ", lineno);`. The command-not-found / no-such-file / permission-denied
1111/// sites below emit DIRECTLY rather than through `zerr` (deliberately — see
1112/// their comments, routing through zerr would set errflag and abort a script
1113/// that zsh continues), but they hand-rolled `"{}:{}"` and so printed a bare
1114/// `:0` inside a one-line function where zsh prints no line number at all:
1115/// `f(){ nosuchcmd }; f` gave `f:0: command not found:` vs zsh's `f: command
1116/// not found:`. Mirrors the C condition exactly. Bug #1070.
1117fn zerr_prefix(sn: &str) -> String {
1118    let lineno = crate::ported::lex::lineno();
1119    let ll = crate::ported::params::locallevel.load(std::sync::atomic::Ordering::Relaxed);
1120    if (crate::ported::zsh_h::unset(crate::ported::zsh_h::SHINSTDIN) || ll != 0) && lineno != 0 {
1121        format!("{}:{}", sn, lineno)
1122    } else {
1123        sn.to_string()
1124    }
1125}
1126
1127thread_local! {
1128    /// `(function name, its source file)` while that function's AUTOLOAD body
1129    /// is being run. Empty at every other moment.
1130    pub static AUTOLOAD_DEF_FILE: std::cell::RefCell<Vec<(String, String)>> =
1131        const { std::cell::RefCell::new(Vec::new()) };
1132}
1133
1134/// Scope guard for [`AUTOLOAD_DEF_FILE`].
1135pub struct AutoloadFileGuard(bool);
1136
1137impl AutoloadFileGuard {
1138    fn enter(name: &str) -> Self {
1139        match crate::ported::hashtable::getshfuncfile(name) {
1140            Some(f) => {
1141                AUTOLOAD_DEF_FILE.with(|s| s.borrow_mut().push((name.to_string(), f)));
1142                Self(true)
1143            }
1144            None => Self(false),
1145        }
1146    }
1147}
1148
1149impl Drop for AutoloadFileGuard {
1150    fn drop(&mut self) {
1151        if self.0 {
1152            AUTOLOAD_DEF_FILE.with(|s| {
1153                s.borrow_mut().pop();
1154            });
1155        }
1156    }
1157}
1158
1159/// The file `name` is being autoloaded from, if that is happening right now.
1160pub fn autoload_def_file(name: &str) -> Option<String> {
1161    AUTOLOAD_DEF_FILE.with(|s| {
1162        s.borrow()
1163            .iter()
1164            .rev()
1165            .find(|(n, _)| n == name)
1166            .map(|(_, f)| f.clone())
1167    })
1168}
1169
1170/// !!! WARNING: RUST-ONLY HELPER — NO DIRECT C COUNTERPART !!!
1171///
1172/// C's `zexecve` (Src/exec.c:504-643) performs the whole `#!` recovery
1173/// in place: it is only ever reached in the already-forked child that is
1174/// about to BECOME the command, so at c:566/571/581/585/627 it simply
1175/// calls `execve()` a second time and never returns. zshrs reaches the
1176/// same decision from a second call site — the `std::process::Command`
1177/// spawn in `vm_helper::execute_external_bg` — which hands argv to the
1178/// kernel from the PARENT process and therefore cannot "re-exec in
1179/// place". This helper is c:534-634 verbatim with each of those five
1180/// `execve(prog, argv)` calls replaced by `Ok((prog, argv))`, so both
1181/// call sites share one implementation of the shebang rules.
1182///
1183/// `Err(eno)` is what C's `return eno` (c:643) would hand back — either
1184/// the original `eno` or the `errno` from a failed open/read (c:632/634).
1185#[allow(non_snake_case)]
1186pub fn zexecve_recover(pth: &str, argv: &[String], eno: i32) -> Result<(String, Vec<String>), i32> {
1187    if eno == libc::ENOEXEC || eno == libc::ENOENT {
1188        // c:534
1189        let cpth = match std::ffi::CString::new(pth) {
1190            Ok(c) => c,
1191            Err(_) => return Err(libc::ENOENT),
1192        };
1193        let fd = unsafe { libc::open(cpth.as_ptr(), libc::O_RDONLY | libc::O_NOCTTY) }; // c:538
1194        if fd < 0 {
1195            // c:633-634 — `} else eno = errno;` then fall through to `return eno`.
1196            return Err(std::io::Error::last_os_error()
1197                .raw_os_error()
1198                .unwrap_or(libc::ENOENT));
1199        }
1200        let mut buf = vec![0u8; crate::ported::exec::POUNDBANGLIMIT + 1]; // c:541
1201        let ct = unsafe {
1202            libc::read(
1203                fd,
1204                buf.as_mut_ptr() as *mut libc::c_void,
1205                crate::ported::exec::POUNDBANGLIMIT as libc::size_t,
1206            )
1207        }; // c:542
1208        unsafe {
1209            libc::close(fd);
1210        } // c:543
1211        if ct >= 0 {
1212            // c:544
1213            let ct = ct as usize;
1214            if ct >= 2 && buf[0] == b'#' && buf[1] == b'!' {
1215                // c:545
1216                let mut t0 = 0;
1217                while t0 < ct && buf[t0] != b'\n' {
1218                    t0 += 1;
1219                } // c:546-548
1220                if t0 == ct {
1221                    // c:549
1222                    // c:550 `zerr(...)`. C is inside the forked child that is
1223                    // about to `_exit`, so the errflag `zerr` raises is
1224                    // irrelevant there. This runs in the PARENT, where a raised
1225                    // errflag aborts the enclosing script — `( if
1226                    // bad-interp-cmd; then exit 0; else exit 1; fi )` returned
1227                    // 127 instead of running its else branch. `zwarn`
1228                    // (utils.rs:260) emits the identical text without the flag.
1229                    crate::ported::utils::zwarn(&format!(
1230                        // c:550
1231                        "{}: bad interpreter: {}: {}",
1232                        pth,
1233                        String::from_utf8_lossy(&buf[2..t0.min(ct)]),
1234                        std::io::Error::from_raw_os_error(eno)
1235                    ));
1236                } else {
1237                    // c:552
1238                    while t0 > 0 && (buf[t0] == b' ' || buf[t0] == b'\t' || buf[t0] == b'\n') {
1239                        buf[t0] = 0;
1240                        t0 -= 1;
1241                    } // c:553-554
1242                    let mut ptr_lo: usize = 2;
1243                    while ptr_lo < buf.len() && buf[ptr_lo] == b' ' {
1244                        ptr_lo += 1;
1245                    } // c:555
1246                    let ptr2_lo = ptr_lo;
1247                    let mut ptr_hi = ptr2_lo;
1248                    while ptr_hi < buf.len() && buf[ptr_hi] != 0 && buf[ptr_hi] != b' ' {
1249                        ptr_hi += 1;
1250                    } // c:556
1251                    let interp_str = String::from_utf8_lossy(&buf[ptr2_lo..ptr_hi]).into_owned();
1252                    if eno == libc::ENOENT {
1253                        // c:557 — pathprog rewrite path.
1254                        let pprog = if !interp_str.starts_with('/') {
1255                            // c:561
1256                            crate::ported::utils::pathprog(&interp_str)
1257                                .map(|p| p.display().to_string())
1258                        } else {
1259                            None
1260                        };
1261                        if let Some(pprog) = pprog {
1262                            // c:562
1263                            let mut argv_new: Vec<String> = Vec::with_capacity(argv.len() + 2);
1264                            argv_new.push(interp_str.clone()); // c:564
1265                            if ptr_hi >= buf.len() || buf[ptr_hi] == 0 {
1266                                argv_new.push(pth.to_string());
1267                            } else {
1268                                // c:567
1269                                let mut rest_lo = ptr_hi + 1;
1270                                while rest_lo < buf.len() && buf[rest_lo] == b' ' {
1271                                    rest_lo += 1;
1272                                }
1273                                let mut rest_hi = rest_lo;
1274                                while rest_hi < buf.len() && buf[rest_hi] != 0 {
1275                                    rest_hi += 1;
1276                                }
1277                                let arg_str =
1278                                    String::from_utf8_lossy(&buf[rest_lo..rest_hi]).into_owned();
1279                                argv_new.push(arg_str);
1280                                argv_new.push(pth.to_string());
1281                            }
1282                            for orig in argv.iter().skip(1) {
1283                                argv_new.push(orig.clone());
1284                            }
1285                            // c:565/c:570 — `winch_unblock(); execve(...)`.
1286                            // Deliberately NOT ported here. In C that unblock is
1287                            // the last statement before `execve` in the FORKED
1288                            // CHILD, so all it decides is the mask the new
1289                            // program starts with; the parent never sees it.
1290                            // This resolver runs in the PARENT (see the c:550
1291                            // note above) and RETURNS, so calling it dropped the
1292                            // shell's standing `winch_block()`
1293                            // (c:Src/init.c:1458) with nothing here to re-arm
1294                            // it. The next re-arm is whenever `raw_getbyte`
1295                            // returns from its read (zle_main.rs:507/654/762),
1296                            // so the leak spans the REST OF THE CURRENT COMMAND,
1297                            // not the session — and a completer that execs a
1298                            // shebang-less script therefore finishes its
1299                            // completion with the handler live. That is the one
1300                            // state c:1458 exists to prevent: `adjustwinsize(1)`
1301                            // inside a widget makes `calclist` count display
1302                            // strings against a width they were not built at,
1303                            // and repaints over the prompt row.
1304                            //
1305                            // The new image still gets SIGWINCH unblocked: the
1306                            // child-side `zexecve` unblocks at c:527 (ported at
1307                            // src/ported/exec.rs:4215), and the spawn-based
1308                            // callers of this function go through
1309                            // `std::process::Command`, whose child resets the
1310                            // signal mask to empty before `execvp`.
1311                            return Ok((pprog, argv_new)); // c:566/c:571
1312                        }
1313                        crate::ported::utils::zwarn(&format!(
1314                            // c:574 — `zerr`; see the c:550 note above for why
1315                            // this is `zwarn` in the parent-side port.
1316                            "{}: bad interpreter: {}: {}",
1317                            pth,
1318                            interp_str,
1319                            std::io::Error::from_raw_os_error(eno)
1320                        ));
1321                    } else if ptr_hi < buf.len() && buf[ptr_hi] != 0 {
1322                        // c:576
1323                        let mut rest_lo = ptr_hi + 1;
1324                        while rest_lo < buf.len() && buf[rest_lo] == b' ' {
1325                            rest_lo += 1;
1326                        }
1327                        let mut rest_hi = rest_lo;
1328                        while rest_hi < buf.len() && buf[rest_hi] != 0 {
1329                            rest_hi += 1;
1330                        }
1331                        let arg_str = String::from_utf8_lossy(&buf[rest_lo..rest_hi]).into_owned();
1332                        let mut argv_new: Vec<String> =
1333                            vec![interp_str.clone(), arg_str, pth.to_string()];
1334                        for orig in argv.iter().skip(1) {
1335                            argv_new.push(orig.clone());
1336                        }
1337                        // c:580 — `winch_unblock()` omitted: child-side in C,
1338                        // parent-side here. See the c:565/c:570 note above.
1339                        return Ok((interp_str, argv_new)); // c:581
1340                    } else {
1341                        // c:582
1342                        let mut argv_new: Vec<String> = vec![interp_str.clone(), pth.to_string()];
1343                        for orig in argv.iter().skip(1) {
1344                            argv_new.push(orig.clone());
1345                        }
1346                        // c:584 — `winch_unblock()` omitted: child-side in C,
1347                        // parent-side here. See the c:565/c:570 note above.
1348                        return Ok((interp_str, argv_new)); // c:585
1349                    }
1350                }
1351            } else if eno == libc::ENOEXEC {
1352                // c:588 — binary-safety + /bin/sh fallback.
1353                let nul_pos = buf[..ct].iter().position(|&b| b == 0); // c:597
1354                let isbinary = match nul_pos {
1355                    None => false, // c:598
1356                    Some(npos) => {
1357                        let mut has_letter = false;
1358                        let mut binary = true;
1359                        for &b in &buf[..npos] {
1360                            // c:602-609
1361                            if (b as char).is_ascii_lowercase() || b == b'$' || b == b'`' {
1362                                has_letter = true;
1363                            }
1364                            if has_letter && b == b'\n' {
1365                                binary = false; // c:606
1366                                break;
1367                            }
1368                        }
1369                        binary
1370                    }
1371                };
1372                if !isbinary {
1373                    // c:611
1374                    let mut argv_new: Vec<String> = Vec::with_capacity(argv.len() + 2);
1375                    argv_new.push("sh".to_string()); // c:625
1376                    // c:544 — `*argv = pth;`: by now argv[0] IS the path
1377                    // being executed, not the command word, so both the
1378                    // `-`/`+` guard and the operand handed to sh are `pth`.
1379                    // Testing the word let `PATH=-dir tstcmd` hand sh
1380                    // `-dir/tstcmd` with no `-` guard (sh: `-d: invalid
1381                    // option`), and ran a bare-word script as `sh tstcmd`.
1382                    if pth.starts_with('-') || pth.starts_with('+') {
1383                        argv_new.push("-".to_string()); // c:623
1384                    }
1385                    argv_new.push(pth.to_string()); // c:544
1386                    for orig in argv.iter().skip(1) {
1387                        argv_new.push(orig.clone());
1388                    }
1389                    // c:626 — `winch_unblock()` omitted: child-side in C,
1390                    // parent-side here. See the c:565/c:570 note above.
1391                    return Ok(("/bin/sh".to_string(), argv_new)); // c:627
1392                }
1393            }
1394        }
1395    }
1396    Err(eno) // c:643
1397}
1398
1399
1400/// !!! WARNING: RUST-ONLY HELPER — the non-forking form of `execute()`'s
1401/// search (Src/exec.c:791-895) !!!
1402///
1403/// C runs `execute()` in the forked child, where every `zexecve` either
1404/// replaces the process or returns an errno and the walk moves on to the
1405/// next candidate. zshrs spawns from the parent, so each `zexecve` here is a
1406/// `posix_spawn(2)` attempt (`zexecve_spawn`) and the first one that starts a
1407/// process ends the walk with its pid. The candidates, their order and the
1408/// `isgooderr` bookkeeping are C's:
1409///
1410/// * c:791-801 — an `arg0` holding a `/` is exec'd as spelled; unless it is
1411///   relative, not `./…`/`../…`, and PATH_DIRS is set, a failure is final.
1412/// * c:803-819 — `command -p`: the default-path hit alone.
1413/// * c:822-870 — the `cmdnamtab` candidate, then c:877-895
1414///   `execute_skip_exec:`, the full `$path` walk (empty and `.` entries
1415///   mean `arg0` itself).
1416///
1417/// `Err(eno)` is C's `eno` at c:871: non-zero means `zerr("%e: %s")`, zero
1418/// means nothing usable was found (`command not found`).
1419pub(crate) fn execute_spawn(
1420    arg0: &str,
1421    argv: &[String],
1422    defpath: Option<&str>,
1423    hashed: Option<&str>,
1424    drop_argv0: bool,
1425    in_place: bool,
1426) -> Result<libc::pid_t, i32> {
1427    use crate::ported::exec::isgooderr;
1428    // c:791-801
1429    if let Some(slash) = arg0.find('/') {
1430        let lerrno = match zexecve_spawn(arg0, argv, drop_argv0, in_place) {
1431            Ok(pid) => return Ok(pid),
1432            Err(e) => e,
1433        }; // c:793
1434        let dotted = arg0.starts_with('.')
1435            && (slash == 1 || (arg0.starts_with("..") && slash == 2));
1436        if slash == 0 || crate::ported::zsh_h::unset(crate::ported::zsh_h::PATHDIRS) || dotted {
1437            return Err(lerrno); // c:794-798
1438        }
1439    }
1440    let mut eno = 0;
1441    let mut attempt = |pth: &str, dir: &str| -> Option<libc::pid_t> {
1442        match zexecve_spawn(pth, argv, drop_argv0, in_place) {
1443            Ok(pid) => Some(pid),
1444            Err(ee) => {
1445                if isgooderr(ee, dir) {
1446                    eno = ee;
1447                }
1448                None
1449            }
1450        }
1451    };
1452    let dir_of = |p: &str| -> String {
1453        // c:817-818 / c:866-867 — `if ((dptr = strrchr(nn, '/'))) *dptr =
1454        // '\0'; isgooderr(ee, *nn ? nn : "/")`.
1455        match p.rfind('/') {
1456            Some(0) | None => "/".to_string(),
1457            Some(i) => p[..i].to_string(),
1458        }
1459    };
1460    if let Some(pbuf) = defpath {
1461        // c:815 — `ee = zexecve(pbuf, argv, newenvp);`
1462        if let Some(pid) = attempt(pbuf, &dir_of(pbuf)) {
1463            return Ok(pid);
1464        }
1465        return Err(eno);
1466    }
1467    if let Some(nn) = hashed {
1468        // c:863 — `ee = zexecve(nn, argv, newenvp);`
1469        if let Some(pid) = attempt(nn, &dir_of(nn)) {
1470            return Ok(pid);
1471        }
1472    }
1473    // c:877 `execute_skip_exec:` — `for (pp = path; *pp; pp++)`.
1474    let path = crate::ported::params::getaparam("path").unwrap_or_default();
1475    for pp in &path {
1476        let pth = if pp.is_empty() || pp == "." {
1477            arg0.to_string() // c:879-880
1478        } else {
1479            format!("{}/{}", pp, arg0) // c:884-888
1480        };
1481        if pth.len() >= libc::PATH_MAX as usize {
1482            continue; // c:887-888
1483        }
1484        if let Some(pid) = attempt(&pth, pp) {
1485            return Ok(pid); // c:890 `ee = zexecve(buf, argv, newenvp);`
1486        }
1487    }
1488    Err(eno)
1489}
1490
1491/// !!! WARNING: RUST-ONLY HELPER — `zexecve()` (Src/exec.c:504-643) from the
1492/// parent via `posix_spawn(2)` !!!
1493///
1494/// Same steps as the forked-child port (`crate::ported::exec::zexecve`), with
1495/// each `execve` a `posix_spawn` of the SAME path — never `posix_spawnp`,
1496/// whose libc-internal `/bin/sh` fallback on ENOEXEC runs a shebang-less
1497/// script without C's `-`/`+` operand guard (c:611-624) and so hands
1498/// `-dir/tstcmd` to sh as an option. The kernel's refusal comes back here
1499/// and `zexecve_recover` makes C's decision.
1500///
1501/// The environment is the child's `environ` as C leaves it right before
1502/// `execve`, built as a copy so the parent's own array is untouched (C does
1503/// these writes in the child that is about to vanish):
1504///
1505/// * c:514-520 — `zputenv("_=<pth>")`: an existing `_` entry is replaced in
1506///   place, otherwise the entry is appended.
1507/// * c:765-768 (`execute`) — `unsetenv("ARGV0")` when ARGV0 supplied argv[0].
1508///
1509/// The array keeps `environ`'s order: `std::process::Command` rebuilds any
1510/// environment it modifies from a sorted map, so externals saw the variables
1511/// alphabetically instead of in the order the shell added them.
1512fn zexecve_spawn(pth: &str, argv: &[String], drop_argv0: bool, in_place: bool) -> Result<libc::pid_t, i32> {
1513    use std::ffi::CString;
1514    use std::os::unix::ffi::OsStrExt as _;
1515    // c:514-520 — `_=pth`, made absolute against `pwd`.
1516    let underscore = if pth.starts_with('/') {
1517        pth.to_string() // c:516-517
1518    } else {
1519        format!("{}/{}", crate::ported::params::getsparam("PWD").unwrap_or_default(), pth) // c:519
1520    };
1521    let mut underscore_entry = b"_=".to_vec();
1522    underscore_entry.extend_from_slice(&crate::ported::utils::unmetafy_str(&underscore));
1523    let mut envp: Vec<CString> = Vec::new();
1524    let mut have_underscore = false;
1525    for (k, v) in std::env::vars_os() {
1526        let k = k.as_bytes();
1527        if drop_argv0 && k == b"ARGV0" {
1528            continue; // c:768 `unsetenv("ARGV0")`
1529        }
1530        let entry = if k == b"_" {
1531            have_underscore = true;
1532            underscore_entry.clone() // c:520 — replaced in place
1533        } else {
1534            let mut e = k.to_vec();
1535            e.push(b'=');
1536            e.extend_from_slice(v.as_bytes());
1537            e
1538        };
1539        if let Ok(c) = CString::new(entry) {
1540            envp.push(c);
1541        }
1542    }
1543    if !have_underscore {
1544        if let Ok(c) = CString::new(underscore_entry) {
1545            envp.push(c); // c:520 — appended
1546        }
1547    }
1548    // c:528 — `execve(pth, argv, newenvp);`
1549    let eno = match posix_spawn_argv(pth, argv, &envp, in_place) {
1550        Ok(pid) => return Ok(pid),
1551        Err(eno) => eno,
1552    };
1553    // c:534-634 — the kernel refused the file: the `#!` line's interpreter,
1554    // or `/bin/sh` for a shebang-less script.
1555    match zexecve_recover(pth, argv, eno) {
1556        // c:566/571/581/585/627 — the second `execve`; when it fails too, C
1557        // falls through to c:643 `return eno` with the ORIGINAL errno.
1558        Ok((prog, newargv)) => posix_spawn_argv(&prog, &newargv, &envp, in_place).map_err(|_| eno),
1559        Err(e) => Err(e), // c:632/634/643
1560    }
1561}
1562
1563/// !!! WARNING: RUST-ONLY HELPER — the dispositions `entersubsh` resets in the
1564/// child C forks for an external, applied at the spawn instead !!!
1565///
1566/// c:Src/exec.c:1185-1193 —
1567/// ```c
1568/// if (interact) {
1569///     signal_default(SIGTERM);
1570///     if (!(sigtrapped[SIGINT] & ZSIG_IGNORED))
1571///         signal_default(SIGINT);
1572///     if (!(sigtrapped[SIGPIPE]))
1573///         signal_default(SIGPIPE);
1574/// }
1575/// if (!(sigtrapped[SIGQUIT] & ZSIG_IGNORED))
1576///     signal_default(SIGQUIT);
1577/// ```
1578/// The shell ignores SIGQUIT always and SIGTERM when interactive
1579/// (c:Src/init.c:1448/1463), and SIG_IGN survives `execve`, so without this a
1580/// spawned `sh -c 'kill -QUIT $$'` survived its own signal. SIGPIPE is reset
1581/// unless a `trap '' PIPE` ignores it: C never ignores it otherwise, but the
1582/// Rust runtime does. The job-control stops (c:1174-1183) stay as they are: a foreground
1583/// wait here does not watch for a stopped child, so a ^Z'd child would hang
1584/// the shell.
1585fn entersubsh_sigdef() -> Vec<i32> {
1586    use crate::ported::zsh_h::{isset, INTERACTIVE, ZSIG_IGNORED};
1587    let trapped = |sig: i32| -> i32 {
1588        crate::ported::signals::sigtrapped
1589            .lock()
1590            .ok()
1591            .and_then(|t| t.get(sig as usize).copied())
1592            .unwrap_or(0)
1593    };
1594    let mut sigs = Vec::with_capacity(4);
1595    if trapped(libc::SIGPIPE) & ZSIG_IGNORED == 0 {
1596        sigs.push(libc::SIGPIPE); // c:1189-1190, and the Rust runtime's ignore
1597    }
1598    if isset(INTERACTIVE) {
1599        sigs.push(libc::SIGTERM); // c:1186
1600        if trapped(libc::SIGINT) & ZSIG_IGNORED == 0 {
1601            sigs.push(libc::SIGINT); // c:1187-1188
1602        }
1603    }
1604    if trapped(libc::SIGQUIT) & ZSIG_IGNORED == 0 {
1605        sigs.push(libc::SIGQUIT); // c:1192-1193
1606    }
1607    sigs
1608}
1609
1610/// !!! WARNING: RUST-ONLY HELPER — the signal mask the child C forks for an
1611/// external carries into `execve`, computed in the parent instead !!!
1612///
1613/// The child inherits the shell's mask minus what C unblocks on the way to
1614/// the exec:
1615///
1616/// * c:Src/exec.c:1162-1166 (`entersubsh`) — while `intrap`, every trapped,
1617///   non-ignored signal is unblocked: the trap dispatcher blocks the signal
1618///   being handled, and a command run from the trap body must not inherit it.
1619/// * c:Src/exec.c:751 (`execute`) — `child_unblock()` right before the
1620///   `zexecve` walk, so SIGCHLD is never blocked in an external.
1621///
1622/// Without this a command run from a `trap '...' USR2` handler started with
1623/// SIGUSR2 blocked, and every external started with SIGCHLD blocked.
1624fn execute_sigmask() -> libc::sigset_t {
1625    use crate::ported::signals_h::SIGCOUNT;
1626    use crate::ported::zsh_h::ZSIG_IGNORED;
1627    unsafe {
1628        let mut mask: libc::sigset_t = std::mem::zeroed();
1629        libc::sigemptyset(&mut mask);
1630        libc::pthread_sigmask(libc::SIG_BLOCK, std::ptr::null(), &mut mask);
1631        if crate::ported::signals::intrap.load(std::sync::atomic::Ordering::Relaxed) != 0 {
1632            // c:1163-1166 — `for (sig = 1; sig <= SIGCOUNT; sig++) if
1633            // (sigtrapped[sig] && sigtrapped[sig] != ZSIG_IGNORED)
1634            // signal_unblock(signal_mask(sig));`
1635            let trapped = crate::ported::signals::sigtrapped
1636                .lock()
1637                .map(|t| t.clone())
1638                .unwrap_or_default();
1639            for sig in 1..=SIGCOUNT {
1640                let st = trapped.get(sig as usize).copied().unwrap_or(0);
1641                if st != 0 && st != ZSIG_IGNORED {
1642                    libc::sigdelset(&mut mask, sig);
1643                }
1644            }
1645        }
1646        libc::sigdelset(&mut mask, libc::SIGCHLD); // c:751 `child_unblock();`
1647        mask
1648    }
1649}
1650
1651/// !!! WARNING: RUST-ONLY HELPER — the `posix_spawn(2)` call standing in for
1652/// `execve(2)` in `zexecve_spawn` !!!
1653///
1654/// argv and path are unmetafied first, as C's `zexecve` does at c:510-513
1655/// (`$'\xff'` reaches the child as the raw byte). The child starts with
1656/// `execute_sigmask`'s mask and `entersubsh_sigdef`'s dispositions.
1657///
1658/// `in_place` is c:Src/exec.c:4369 in a child already forked for the command
1659/// (`last1 = forked = 1`, c:3063): `execve(2)` replaces this process instead
1660/// of spawning, and returns only on failure.
1661fn posix_spawn_argv(
1662    pth: &str,
1663    argv: &[String],
1664    envp: &[std::ffi::CString],
1665    in_place: bool,
1666) -> Result<libc::pid_t, i32> {
1667    use std::ffi::CString;
1668    let cstr = |s: &str| {
1669        let mut b = crate::ported::utils::unmetafy_str(s);
1670        if let Some(n) = b.iter().position(|c| *c == 0) {
1671            b.truncate(n); // a C string ends at its first NUL
1672        }
1673        CString::new(b).unwrap_or_default()
1674    };
1675    let cpth = cstr(pth);
1676    let cargs: Vec<CString> = argv.iter().map(|a| cstr(a)).collect();
1677    let mut argv_ptrs: Vec<*mut libc::c_char> = cargs.iter().map(|c| c.as_ptr() as *mut _).collect();
1678    argv_ptrs.push(std::ptr::null_mut());
1679    let mut envp_ptrs: Vec<*mut libc::c_char> = envp.iter().map(|c| c.as_ptr() as *mut _).collect();
1680    envp_ptrs.push(std::ptr::null_mut());
1681    let sigdef = entersubsh_sigdef();
1682    let sigmask = execute_sigmask();
1683    unsafe {
1684        if in_place {
1685            for &sig in &sigdef {
1686                libc::signal(sig, libc::SIG_DFL);
1687            }
1688            libc::pthread_sigmask(libc::SIG_SETMASK, &sigmask, std::ptr::null_mut());
1689            libc::execve(cpth.as_ptr(), argv_ptrs.as_ptr() as *const *const _, envp_ptrs.as_ptr() as *const *const _);
1690            return Err(std::io::Error::last_os_error().raw_os_error().unwrap_or(libc::ENOEXEC));
1691        }
1692        let mut attrs: libc::posix_spawnattr_t = std::mem::zeroed();
1693        let rc = libc::posix_spawnattr_init(&mut attrs);
1694        if rc != 0 {
1695            return Err(rc);
1696        }
1697        let mut dfl: libc::sigset_t = std::mem::zeroed();
1698        libc::sigemptyset(&mut dfl);
1699        for &sig in &sigdef {
1700            libc::sigaddset(&mut dfl, sig);
1701        }
1702        libc::posix_spawnattr_setsigdefault(&mut attrs, &dfl);
1703        libc::posix_spawnattr_setsigmask(&mut attrs, &sigmask);
1704        libc::posix_spawnattr_setflags(
1705            &mut attrs,
1706            (libc::POSIX_SPAWN_SETSIGDEF | libc::POSIX_SPAWN_SETSIGMASK) as _,
1707        );
1708        let mut pid: libc::pid_t = 0;
1709        let rc = libc::posix_spawn(
1710            &mut pid,
1711            cpth.as_ptr(),
1712            std::ptr::null(),
1713            &attrs,
1714            argv_ptrs.as_ptr(),
1715            envp_ptrs.as_ptr(),
1716        );
1717        libc::posix_spawnattr_destroy(&mut attrs);
1718        if rc != 0 {
1719            return Err(rc);
1720        }
1721        Ok(pid)
1722    }
1723}
1724
1725impl ShellExecutor {
1726    /// Set a scalar parameter via the canonical `paramtab`
1727    /// (`Src/params.c:3350 setsparam`). The single store.
1728    pub fn set_scalar(&mut self, name: String, value: String) {
1729        setsparam(&name, &value); // c:params.c:3350
1730    }
1731
1732    /// Read positional parameters from canonical `PPARAMS`
1733    /// `Mutex<Vec<String>>` (Src/init.c:pparams). The single store.
1734    pub fn pparams(&self) -> Vec<String> {
1735        crate::ported::builtin::PPARAMS
1736            .lock()
1737            .map(|p| p.clone())
1738            .unwrap_or_default()
1739    }
1740
1741    /// Write positional parameters to canonical `PPARAMS`.
1742    pub fn set_pparams(&mut self, params: Vec<String>) {
1743        if let Ok(mut p) = crate::ported::builtin::PPARAMS.lock() {
1744            *p = params;
1745        }
1746    }
1747
1748    /// Read PM_* type flags from the paramtab Param entry. Used by
1749    /// SET_VAR / `+=` arms (case-fold, integer-add, readonly guard).
1750    /// Returns 0 when the name isn't in paramtab. Mirrors the C
1751    /// source's direct `pm->node.flags & PM_INTEGER` checks.
1752    pub fn param_flags(&self, name: &str) -> i32 {
1753        paramtab()
1754            .read()
1755            .ok()
1756            .and_then(|t| t.get(name).map(|p| p.node.flags))
1757            .unwrap_or(0)
1758    }
1759
1760    /// `readonly` / `typeset -r` / read-only-by-design (LINENO, PPID,
1761    /// $$, $?, $!, ...) — match user-side rejection in C's
1762    /// assignstrvalue at `Src/params.c:2699-2703` which gates on
1763    /// `pm->node.flags & PM_READONLY` where the IPDEF4 family declares
1764    /// `PM_READONLY_SPECIAL = PM_SPECIAL | PM_READONLY | PM_RO_BY_DESIGN`
1765    /// (all three bits set together), which `init_partab_params` now
1766    /// stamps in full. The PM_RO_BY_DESIGN arm below is therefore no
1767    /// longer the IPDEF4 rows' only read-only marker — it remains
1768    /// because `private` params (c:Src/Modules/param_private.c:174)
1769    /// carry PM_RO_BY_DESIGN WITHOUT PM_READONLY and need the
1770    /// scope-gated test. Bug #418-family / test_lineno_intrinsic_readonly.
1771    pub fn is_readonly_param(&self, name: &str) -> bool {
1772        // c:Src/params.c:2235 fetchvalue looks the name up through
1773        // realparamtab->getnode, i.e. getprivatenode (Src/Modules/
1774        // param_private.c:678): a private of an enclosing scope with nothing
1775        // beneath it is ABSENT, and createparam reports the write.
1776        let (flags, pm_level) = crate::ported::params::paramtab()
1777            .read()
1778            .ok()
1779            .and_then(|t| {
1780                t.get(name).and_then(|p| {
1781                    let vis = crate::ported::modules::param_private::getprivatenode(&**p as *const _);
1782                    (!vis.is_null()).then(|| unsafe { ((*vis).node.flags as u32, (*vis).level) })
1783                })
1784            })
1785            .unwrap_or((0, 0));
1786        // c:Src/params.c:2264-2265 — assignsparam reaches its c:3216 PM_READONLY
1787        // test only through fetchvalue, which reports a node that is PM_UNSET
1788        // without PM_DECLARED as ABSENT; createparam then decides (for an
1789        // outer-scope private, hidden by scopeprivate's PM_UNSET|PM_READONLY,
1790        // that is "can't modify read-only parameter"). So such a node is not
1791        // a read-only rejection here; assignsparam reports it.
1792        if (flags & crate::ported::zsh_h::PM_UNSET) != 0 && (flags & crate::ported::zsh_h::PM_DECLARED) == 0 {
1793            return false;
1794        }
1795        // c:Src/params.c assignsparam — a real PM_READONLY param always
1796        // rejects writes.
1797        if (flags & PM_READONLY) != 0 {
1798            return true;
1799        }
1800        if (flags & crate::ported::zsh_h::PM_RO_BY_DESIGN) != 0 {
1801            // c:Src/Modules/param_private.c pps_setfn (c:300-307) — a
1802            // PRIVATE param (PM_RO_BY_DESIGN + PM_REMOVABLE) is NOT blanket
1803            // read-only: a write is permitted iff it is in the SAME scope
1804            // (`locallevel == pm->level`, e.g. `() { private p=1; p=2 }`
1805            // → 2) or above the wrap level (`locallevel >
1806            // private_wraplevel`). A deeper nested-scope write is rejected
1807            // (setfn_error) — that is how a nested fn writing an OUTER
1808            // function's private still errors and aborts. zshrs never
1809            // wires the private GSU, so the level gate is enforced here.
1810            if (flags & crate::ported::zsh_h::PM_REMOVABLE) != 0 {
1811                let ll = crate::ported::params::locallevel.load(Ordering::Relaxed);
1812                let wrap = crate::ported::modules::param_private::private_wraplevel
1813                    .load(Ordering::Relaxed);
1814                return !(ll == pm_level || ll > wrap); // c:304 (negated: blocked)
1815            }
1816            // Non-removable PM_RO_BY_DESIGN = IPDEF4-family special
1817            // (LINENO/$?/$$…). These now also carry PM_READONLY and so
1818            // return true from the branch above; this arm stays as the
1819            // c:Src/zsh.h:1923 "readonly by design" fallback for any row
1820            // reached before `init_partab_params` has stamped the flag.
1821            return true;
1822        }
1823        false
1824    }
1825
1826    /// Most-recent-command exit status. Reads canonical
1827    /// `builtin::LASTVAL` AtomicI32 (`Src/builtin.c:6443`).
1828    pub fn last_status(&self) -> i32 {
1829        crate::ported::builtin::LASTVAL.load(Ordering::Relaxed)
1830    }
1831
1832    /// Write the most-recent-command exit status. The canonical
1833    /// store is `builtin::LASTVAL`; this is the single setter.
1834    /// Used everywhere `$?` / `%?` / errexit / ZERR trap read.
1835    pub fn set_last_status(&mut self, status: i32) {
1836        crate::ported::builtin::LASTVAL.store(status, Ordering::Relaxed);
1837    }
1838
1839    /// Set an indexed array parameter via canonical paramtab
1840    /// (`assignaparam`, `Src/params.c:3357`). The single store.
1841    ///
1842    /// Callers mean "make NAME this array" — `typeset -a h=(…)` over an
1843    /// existing assoc among them, whose type conversion C does in
1844    /// typeset_single before it assigns. `setaparam` now keeps an existing
1845    /// assoc hashed (C's c:3341-3342), so go one level down to the
1846    /// type-resetting store with the same `ASSPM_WARN` flag setaparam used.
1847    pub fn set_array(&mut self, name: String, value: Vec<String>) {
1848        assignaparam(&name, value, crate::ported::zsh_h::ASSPM_WARN); // c:params.c:3766
1849    }
1850
1851    /// Set an associative array parameter via canonical
1852    /// `sethparam` (`Src/params.c:3602`). The single store.
1853    pub fn set_assoc(&mut self, name: String, value: IndexMap<String, String>) {
1854        let mut flat: Vec<String> = Vec::with_capacity(value.len() * 2);
1855        for (k, v) in &value {
1856            flat.push(k.clone());
1857            flat.push(v.clone());
1858        }
1859        sethparam(&name, flat); // c:params.c:3602
1860    }
1861
1862    /// Read a scalar parameter. Mirrors C `getsparam` at
1863    /// `Src/params.c:3076` — reads through paramtab, falls back to
1864    /// special-var hooks and env.
1865    pub fn scalar(&self, name: &str) -> Option<String> {
1866        getsparam(name)
1867    }
1868
1869    /// Read an array parameter via canonical `getaparam`
1870    /// (`Src/params.c:3101`).
1871    pub fn array(&self, name: &str) -> Option<Vec<String>> {
1872        getaparam(name)
1873    }
1874
1875    /// Read an associative array parameter from canonical
1876    /// `paramtab_hashed_storage`. Mirrors C `gethparam` at
1877    /// `Src/params.c:3115` — returns the typed `IndexMap`.
1878    pub fn assoc(&self, name: &str) -> Option<IndexMap<String, String>> {
1879        // c:Src/params.c:570-575 — nameref deref before the read.
1880        let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
1881            // c:2264-2267 + c:2297 — a reference to an ELEMENT leaves its
1882            // `[sub]` unparsed for a bracks-0 getvalue, so it is no hash.
1883            crate::ported::params::nameref_resolution::Target { subscript: Some(_), .. } => {
1884                return Default::default()
1885            }
1886            crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
1887            _ => name.to_string(),
1888        };
1889        // The live param's TYPE is authoritative — paramtab_hashed_storage is
1890        // keyed by NAME ONLY (no scope), so a local ARRAY that shadows a special
1891        // assoc (`local -a options` / `local -a commands` over the hidden
1892        // `options`/`commands` specials) leaves a stale (emptied) hashed_storage
1893        // entry behind. Without this guard `exec.assoc("options")` returned
1894        // Some(empty), so a subsequent bare `options=(-a -b -c)` routed through
1895        // sethparam → "bad set of key/value pairs for associative array"
1896        // (odd count), breaking `_sqlite`'s `local -a options; options=(…)`
1897        // (separate statements — the one-statement `local -a commands=(…)` form
1898        // that openssl uses is typed correctly by bin_typeset). Consult the
1899        // current param: if it exists and is NOT PM_HASHED, it's an array/scalar
1900        // shadow and the stale assoc storage must not be seen.
1901        if let Some(flags) = crate::ported::params::paramtab()
1902            .read()
1903            .ok()
1904            .and_then(|t| t.get(resolved.as_str()).map(|p| p.node.flags as u32))
1905        {
1906            if (flags & PM_HASHED) == 0 {
1907                return None;
1908            }
1909        }
1910        paramtab_hashed_storage()
1911            .lock()
1912            .ok()
1913            .and_then(|m| m.get(resolved.as_str()).cloned())
1914    }
1915
1916    /// Test whether a scalar parameter exists in paramtab.
1917    /// Mirrors the C `paramtab->getnode(name) != NULL` check.
1918    pub fn has_scalar(&self, name: &str) -> bool {
1919        getsparam(name).is_some()
1920    }
1921
1922    /// Test whether an array parameter exists in paramtab. Mirrors
1923    /// `getaparam(name).is_some()` (PM_ARRAY + populated `u_arr`, with
1924    /// digit-first-name rejection and nameref deref) WITHOUT cloning the
1925    /// backing vector — `getaparam` returns an owned `Vec<String>`, so a
1926    /// bare existence probe on a large array copied every element. Hot in
1927    /// the subscript-store dispatch (`a[i]=v` in a loop), so keep it a
1928    /// flag read.
1929    pub fn has_array(&self, name: &str) -> bool {
1930        if name.starts_with(|c: char| c.is_ascii_digit()) {
1931            return false;
1932        }
1933        let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
1934            crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
1935            _ => name.to_string(),
1936        };
1937        crate::ported::params::paramtab()
1938            .read()
1939            .ok()
1940            .and_then(|t| {
1941                t.get(resolved.as_str()).map(|p| {
1942                    (p.node.flags as u32 & crate::ported::zsh_h::PM_ARRAY) != 0 && p.u_arr.is_some()
1943                })
1944            })
1945            .unwrap_or(false)
1946    }
1947
1948    /// Test whether an associative array parameter exists. Reads
1949    /// canonical `paramtab_hashed_storage` (Src/params.c hashed
1950    /// PM_HASHED slot).
1951    pub fn has_assoc(&self, name: &str) -> bool {
1952        // c:Src/params.c:570-575 — nameref deref before the read.
1953        let resolved = match crate::ported::params::resolve_nameref_name(name, None) {
1954            // c:2264-2267 + c:2297 — a reference to an ELEMENT leaves its
1955            // `[sub]` unparsed for a bracks-0 getvalue, so it is no hash.
1956            crate::ported::params::nameref_resolution::Target { subscript: Some(_), .. } => {
1957                return Default::default()
1958            }
1959            crate::ported::params::nameref_resolution::Target { name: t_, .. } => t_,
1960            _ => name.to_string(),
1961        };
1962        // Live-param type is authoritative (see `assoc` above): a non-PM_HASHED
1963        // shadow (e.g. `local -a options`) hides the stale name-keyed
1964        // hashed_storage entry.
1965        if let Some(flags) = crate::ported::params::paramtab()
1966            .read()
1967            .ok()
1968            .and_then(|t| t.get(resolved.as_str()).map(|p| p.node.flags as u32))
1969        {
1970            if (flags & PM_HASHED) == 0 {
1971                return false;
1972            }
1973        }
1974        paramtab_hashed_storage()
1975            .lock()
1976            .ok()
1977            .map(|m| m.contains_key(resolved.as_str()))
1978            .unwrap_or(false)
1979    }
1980
1981    /// Unset an associative array parameter via canonical
1982    /// `unsetparam` (Src/params.c:3819) — PM_READONLY rejection,
1983    /// stdunsetfn dispatch, env clear. Also clears the zshrs-side
1984    /// `paramtab_hashed_storage` parallel IndexMap shadow.
1985    pub fn unset_assoc(&mut self, name: &str) {
1986        unsetparam(name);
1987        let _ = paramtab_hashed_storage()
1988            .lock()
1989            .ok()
1990            .as_deref_mut()
1991            .map(|m| m.remove(name));
1992    }
1993
1994    /// Set a regular alias. Writes canonical aliastab with
1995    /// ALIAS_GLOBAL bit cleared.
1996    pub fn set_alias(&mut self, name: String, value: String) {
1997        if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
1998            tab.add(crate::ported::hashtable::createaliasnode(&name, &value, 0));
1999        }
2000    }
2001
2002    /// Set a global alias (`alias -g`). Writes canonical aliastab
2003    /// with ALIAS_GLOBAL bit set.
2004    pub fn set_global_alias(&mut self, name: String, value: String) {
2005        if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
2006            tab.add(crate::ported::hashtable::createaliasnode(
2007                &name,
2008                &value,
2009                crate::ported::zsh_h::ALIAS_GLOBAL as u32,
2010            ));
2011        }
2012    }
2013
2014    /// Set a suffix alias (`alias -s ext=cmd`). Writes canonical
2015    /// sufaliastab with ALIAS_SUFFIX node flag — mirrors C
2016    /// Src/builtin.c:4480-4481 (`flags1 |= ALIAS_SUFFIX; ht =
2017    /// sufaliastab;`) → c:4527 (`createaliasnode(value, flags1)`).
2018    /// Without ALIAS_SUFFIX in node.flags, `${saliases[k]}` /
2019    /// `${(k)saliases}` introspection (parameter.c:1953/2018) fails
2020    /// because both paths strict-equality-match flags == ALIAS_SUFFIX.
2021    pub fn set_suffix_alias(&mut self, name: String, value: String) {
2022        if let Ok(mut tab) = crate::ported::hashtable::sufaliastab_lock().write() {
2023            tab.add(crate::ported::hashtable::createaliasnode(
2024                &name,
2025                &value,
2026                crate::ported::zsh_h::ALIAS_SUFFIX as u32,
2027            ));
2028        }
2029    }
2030
2031    /// Snapshot the alias map as a sorted `Vec<(name, value)>`,
2032    /// only entries WITHOUT the ALIAS_GLOBAL flag (regular aliases).
2033    pub fn alias_entries(&self) -> Vec<(String, String)> {
2034        if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
2035            tab.iter_sorted()
2036                .into_iter()
2037                .filter(|(_, a)| (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) == 0)
2038                .map(|(k, a)| (k.clone(), a.text.clone()))
2039                .collect()
2040        } else {
2041            Vec::new()
2042        }
2043    }
2044
2045    /// Snapshot the global-alias entries (ALIAS_GLOBAL flag set).
2046    pub fn global_alias_entries(&self) -> Vec<(String, String)> {
2047        if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
2048            tab.iter_sorted()
2049                .into_iter()
2050                .filter(|(_, a)| (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0)
2051                .map(|(k, a)| (k.clone(), a.text.clone()))
2052                .collect()
2053        } else {
2054            Vec::new()
2055        }
2056    }
2057
2058    /// Snapshot the suffix-alias entries.
2059    pub fn suffix_alias_entries(&self) -> Vec<(String, String)> {
2060        if let Ok(tab) = crate::ported::hashtable::sufaliastab_lock().read() {
2061            tab.iter_sorted()
2062                .into_iter()
2063                .map(|(k, a)| (k.clone(), a.text.clone()))
2064                .collect()
2065        } else {
2066            Vec::new()
2067        }
2068    }
2069
2070    /// Unset an array parameter via canonical `unsetparam`
2071    /// (Src/params.c:3819). Routes through the C-faithful port
2072    /// that runs PM_NAMEREF skip + PM_READONLY rejection via
2073    /// unsetparam_pm + stdunsetfn dispatch + pm.old scope restore.
2074    /// Inline `tab.remove(name)` skipped all four.
2075    pub fn unset_array(&mut self, name: &str) {
2076        unsetparam(name);
2077    }
2078
2079    /// Unset a scalar parameter via canonical `unsetparam`. Same
2080    /// C-faithful path as `unset_array`; the C `unsetparam` itself
2081    /// is type-agnostic and dispatches through PM_TYPE inside.
2082    pub fn unset_scalar(&mut self, name: &str) {
2083        unsetparam(name);
2084    }
2085    /// Lightweight executor for a POOL WORKER THREAD. Unlike [`new`] (a full
2086    /// session bootstrap that re-derives PWD, imports the environment, seeds
2087    /// `OPTS_LIVE`, and writes ~30 default params into the GLOBAL param table),
2088    /// this constructs ONLY the per-executor struct fields and touches NO
2089    /// global state. A worker shares the already-populated, `RwLock`-synchronized
2090    /// globals (params / functions / options); re-seeding them here would clobber
2091    /// the live main session's values (IFS, OPTIND, `$_`, user options, …).
2092    ///
2093    /// The worker pool is shared (Arc) — a worker never spins up its own pool.
2094    /// Per-worker SQLite caches (compsys / plugin) and the history engine are
2095    /// left `None`: a worker runs short compute bodies, not interactive editing.
2096    ///
2097    /// Phase 1 of the in-process thread-execution model (replaces the
2098    /// subprocess-forking parallel builtins). The caller runs the body under
2099    /// `ExecutorContext::enter(&mut wex)` so the VM's thread_local executor
2100    /// resolves on the worker thread; param writes flow to the shared globals.
2101    pub fn new_worker(pool: std::sync::Arc<crate::worker::WorkerPool>) -> Self {
2102        // fpath from the inherited env, same as new() — pure read, no global write.
2103        let raw_fpath: Vec<PathBuf> = env::var("FPATH")
2104            .unwrap_or_default()
2105            .split(':')
2106            .filter(|s| !s.is_empty())
2107            .map(PathBuf::from)
2108            .collect();
2109        // c:Src/init.c:1132-1143 seeds `<prefix>/share/zsh/<ver>/functions` at
2110        // a FIXED position, and every `#compdef` claim is resolved against
2111        // `$fpath` ORDER. The bundled tree is that tree's replacement — a
2112        // byte-identical superset of it — so it belongs AT ITS INDEX, not at
2113        // either end.
2114        let host_at = raw_fpath.iter().position(|d| is_host_zsh_function_tree(d));
2115        let mut fpath: Vec<PathBuf> = raw_fpath
2116            .into_iter()
2117            .filter(|d| !is_host_zsh_function_tree(d))
2118            .collect();
2119        if fpath.is_empty() {
2120            fpath = default_fpath(); // c:Src/init.c:1143
2121        }
2122        // Same directory as the main constructor, and LAST for the same
2123        // reason, but never written from a pool worker -- materialisation
2124        // is the session's job.
2125        if let Some(d) = crate::bundled_functions::functions_dir() {
2126            if d.is_dir() && !fpath.contains(&d) {
2127                // Insert AT the host tree's inherited index, not at either
2128                // end. `host_at` is that index in the raw FPATH; the filter
2129                // above removed only host-tree entries, all of which are at or
2130                // after it, so the index still addresses the same slot.
2131                //
2132                // Both ends are wrong, and each was tried. LAST (the previous
2133                // behaviour) let `zsh-more-completions`' appended dirs
2134                // out-rank the distribution for 343 files / 729 commands —
2135                // the opposite of real zsh, where the tree sits at index 24
2136                // and those plugins at 40-49, and the opposite of what
2137                // `zsh-more-completions.plugin.zsh` itself asks for: it
2138                // PREPENDS `override_src` and APPENDS `src`/`more_src*`/
2139                // `man_src`. FIRST shadowed everything the user curated,
2140                // `override_src` included (that is the `ls -<TAB>` regression
2141                // recorded in this comment's earlier revision). The host
2142                // tree's own index is after `override_src` and before the
2143                // appended plugin dirs, which is exactly where zsh has it.
2144                //
2145                // Measured on a 60-cell corpus of contested commands:
2146                // 1 PASS -> 57 PASS, zero regressions.
2147                match host_at {
2148                    Some(i) if i <= fpath.len() => fpath.insert(i, d),
2149                    _ => fpath.push(d),
2150                }
2151            }
2152        }
2153        Self {
2154            scriptname: Some("zsh".to_string()),
2155            scriptfilename: Some("zsh".to_string()),
2156            subshell_snapshots: Vec::new(),
2157            inline_env_stack: Vec::new(),
2158            current_command_glob_failed: std::cell::Cell::new(false),
2159            jobs: JobTable::new(),
2160            fpath,
2161            // Worker: no interactive history engine, ever — a pre-filled
2162            // cell so `history()` cannot open one from a pool thread.
2163            history: std::cell::OnceCell::from(None),
2164            completions: HashMap::new(),
2165            process_sub_counter: 0,
2166            zstyles: Vec::new(),
2167            local_scope_depth: 0,
2168            pending_underscore: None,
2169            in_dq_context: 0,
2170            in_scalar_assign: 0,
2171            profiling_enabled: false,
2172            compsys_cache: std::cell::OnceCell::from(None), // worker: no per-thread SQLite mirror
2173            compinit_pending: None,
2174            plugin_cache: None, // worker: no per-thread plugin cache
2175            deferred_compdefs: Vec::new(),
2176            returning: None,
2177            zsh_compat: false,
2178            bash_compat: false,
2179            posix_mode: false,
2180            worker_pool: pool, // SHARED — never spawn a nested pool
2181            intercepts: Vec::new(),
2182            async_jobs: HashMap::new(),
2183            next_async_id: 1,
2184            redirect_scope_stack: Vec::new(),
2185            multios_scope_stack: Vec::new(),
2186            exec_redirs_permanent: false,
2187            pipe_output_pending: false,
2188            pipe_output_scope: None,
2189            redirect_failed: false,
2190            functions_compiled: crate::cow_map::CowHashMap::new(),
2191            function_source: crate::cow_map::CowHashMap::new(),
2192            function_line_base: HashMap::new(),
2193            function_def_file: HashMap::new(),
2194            prompt_funcstack: Vec::new(),
2195            tied_array_to_scalar: HashMap::new(),
2196            ztest_pass_count: std::sync::atomic::AtomicUsize::new(0),
2197            ztest_fail_count: std::sync::atomic::AtomicUsize::new(0),
2198            ztest_skip_count: std::sync::atomic::AtomicUsize::new(0),
2199            ztest_pass_total: std::sync::atomic::AtomicUsize::new(0),
2200            ztest_fail_total: std::sync::atomic::AtomicUsize::new(0),
2201            ztest_skip_total: std::sync::atomic::AtomicUsize::new(0),
2202            ztest_run_failed: std::sync::atomic::AtomicBool::new(false),
2203            ztest_suppress_stdout: false,
2204        }
2205    }
2206
2207    /// `new` — see implementation.
2208    pub fn new() -> Self {
2209        tracing::debug!("ShellExecutor::new() initializing");
2210
2211        // zsh's man/info pages ride along with the function tree: same
2212        // reason (zshrs is not installed under a zsh prefix), same
2213        // one-stamp-read steady-state cost. This runs FIRST because it
2214        // edits MANPATH/INFOPATH in the OS environment, and the env is
2215        // read into paramtab further down -- publishing after that point
2216        // left an inherited $INFOPATH untouched in the shell while the
2217        // process environment carried the new value.
2218        crate::bundled_docs::install_and_publish();
2219
2220        // c:Src/init.c:1236-1259 — setupvals' pwd/oldpwd init, ported
2221        // here because the bin entry skips setupvals (see the
2222        // init_bltinmods note below). The validated value lands in the
2223        // live OS env: the bin entry's `$PWD` carrier (the analog of
2224        // C's `pwd` global — see the subshell-snapshot comment at
2225        // fusevm_bridge.rs `cwd:` field). set_pwd_env() pours it into
2226        // paramtab after the env-import loop, same order as C
2227        // (params.c:955).
2228        //
2229        // c:1242-1245 — "Try a cheap test to see if we can initialize
2230        // `PWD' from `HOME'." EMULATE_ZSH reads the `home` global,
2231        // which setupvals derives from getpwuid(getuid())->pw_dir
2232        // (c:1222-1225), falling back to "/" (c:1230-1232).
2233        let home = unsafe {
2234            let pw = libc::getpwuid(libc::getuid());
2235            if pw.is_null() {
2236                None
2237            } else {
2238                Some(
2239                    std::ffi::CStr::from_ptr((*pw).pw_dir)
2240                        .to_string_lossy()
2241                        .into_owned(),
2242                )
2243            }
2244        }
2245        .unwrap_or_else(|| "/".to_string()); // c:1230-1232 EMULATE_ZSH home = "/"
2246                                             // ispwd (src/zsh/Src/utils.c:809-829): a candidate is honored
2247                                             // only when it (a) is absolute, (b) stat's to the same
2248                                             // dev+inode as ".", and (c) has no `.`/`..` components.
2249                                             // Without this chain, a child that inherits $PWD from a parent
2250                                             // run in a different directory (cargo test setting
2251                                             // current_dir(tempdir) while leaking PWD=/project/root) treats
2252                                             // the stale PWD as the logical-path base, so `cd sub` resolves
2253                                             // against the wrong directory.
2254        let pwd_val = if ispwd(&home) {
2255            home // c:1245-1246 — pwd = ztrdup(ptr) [HOME]
2256        } else if let Some(p) = env::var("PWD")
2257            .ok()
2258            .filter(|p| p.len() < libc::PATH_MAX as usize && ispwd(p))
2259        {
2260            p // c:1247-1249 — pwd = ztrdup(getenv("PWD"))
2261        } else {
2262            crate::ported::compat::zgetcwd() // c:1250-1252 — pwd = zgetcwd()
2263        };
2264        // c:1255-1259 — oldpwd = getenv("OLDPWD") ?: ztrdup(pwd).
2265        let oldpwd_val = env::var("OLDPWD").unwrap_or_else(|_| pwd_val.clone()); // c:1257
2266        // Both stay out of the environment until set_pwd_env below: in C they
2267        // are the `pwd`/`oldpwd` globals, and only set_pwd_env's addenv
2268        // (c:Src/builtin.c:821-826) exports them, after HOME, LOGNAME and
2269        // SHLVL (c:Src/params.c:960-977). Publishing them here put a
2270        // non-inherited PWD/OLDPWD ahead of those in every child's env.
2271
2272        // Initialize fpath from FPATH env var, or from the compiled-in
2273        // defaults when it is absent (c:Src/init.c:1132-1143).
2274        let raw_fpath: Vec<PathBuf> = env::var("FPATH")
2275            .unwrap_or_default()
2276            .split(':')
2277            .filter(|s| !s.is_empty())
2278            .map(PathBuf::from)
2279            .collect();
2280        // c:Src/init.c:1132-1143 seeds `<prefix>/share/zsh/<ver>/functions` at
2281        // a FIXED position, and every `#compdef` claim is resolved against
2282        // `$fpath` ORDER. The bundled tree is that tree's replacement — a
2283        // byte-identical superset of it — so it belongs AT ITS INDEX, not at
2284        // either end.
2285        let host_at = raw_fpath.iter().position(|d| is_host_zsh_function_tree(d));
2286        let mut fpath: Vec<PathBuf> = raw_fpath
2287            .into_iter()
2288            .filter(|d| !is_host_zsh_function_tree(d))
2289            .collect();
2290        if fpath.is_empty() {
2291            fpath = default_fpath();
2292        }
2293        // zshrs ships zsh's function tree with the binary; its directory
2294        // goes LAST, so it supplies only what nothing else on fpath does.
2295        //
2296        // It was first at one point, mirroring where zsh's own
2297        // <prefix>/share/zsh/<ver>/functions sits. That is wrong here: zsh
2298        // puts its tree ahead of nothing the user curated, because the
2299        // user's own directories are prepended to it, whereas an inherited
2300        // FPATH arrives ALREADY assembled. Leading it shadowed every
2301        // curated completion of the same name -- 242 of
2302        // zsh-more-completions' files on this author's setup, `_ls` among
2303        // them, which is how `ls -<TAB>` started diverging from zsh.
2304        //
2305        // Being last costs nothing: the only tree that could out-rank the
2306        // bundle is a host zsh's own, and `is_host_zsh_function_tree`
2307        // already removes that from fpath entirely. Materialised here (not
2308        // only when FPATH is absent) so an inherited FPATH from a shell
2309        // that lacks these still resolves is-at-least/colors/add-zsh-hook.
2310        if let Some(d) = crate::bundled_functions::functions_dir() {
2311            let _ = crate::bundled_functions::ensure_installed();
2312            if d.is_dir() && !fpath.contains(&d) {
2313                // Insert AT the host tree's inherited index, not at either
2314                // end. `host_at` is that index in the raw FPATH; the filter
2315                // above removed only host-tree entries, all of which are at or
2316                // after it, so the index still addresses the same slot.
2317                //
2318                // Both ends are wrong, and each was tried. LAST (the previous
2319                // behaviour) let `zsh-more-completions`' appended dirs
2320                // out-rank the distribution for 343 files / 729 commands —
2321                // the opposite of real zsh, where the tree sits at index 24
2322                // and those plugins at 40-49, and the opposite of what
2323                // `zsh-more-completions.plugin.zsh` itself asks for: it
2324                // PREPENDS `override_src` and APPENDS `src`/`more_src*`/
2325                // `man_src`. FIRST shadowed everything the user curated,
2326                // `override_src` included (that is the `ls -<TAB>` regression
2327                // recorded in this comment's earlier revision). The host
2328                // tree's own index is after `override_src` and before the
2329                // appended plugin dirs, which is exactly where zsh has it.
2330                //
2331                // Measured on a 60-cell corpus of contested commands:
2332                // 1 PASS -> 57 PASS, zero regressions.
2333                match host_at {
2334                    Some(i) if i <= fpath.len() => fpath.insert(i, d),
2335                    _ => fpath.push(d),
2336                }
2337            }
2338        }
2339
2340        let history = std::cell::OnceCell::new();
2341
2342        // Seed canonical OPTS_LIVE with defaults BEFORE any setsparam
2343        // call. assignstrvalue early-returns when `unset(EXECOPT)`
2344        // (c:2701 guard); without the option table populated, EXECOPT
2345        // reads false and every paramtab write below is a silent no-op.
2346        if opt_state_len() == 0 {
2347            for (k, v) in Self::default_options() {
2348                opt_state_set(&k, v);
2349            }
2350        }
2351
2352        // c:Src/params.c:838-847 — `for (ip = special_params; ip->node.nam;
2353        //     ip++) paramtab->addnode(paramtab, ztrdup(ip->node.nam), ip);`
2354        // The specials go into paramtab FIRST, ahead of every non-special
2355        // seed below, because creation ORDER is observable: a new key is
2356        // front-inserted into its bucket chain (c:Src/hashtable.c:214-215)
2357        // and `${(k)parameters}` prints that chain walk verbatim
2358        // (c:Src/hashtable.c:420-434). Seeding NULLCMD / FUNCNEST / PS1 /
2359        // … before this loop put them AHEAD of the specials they follow in
2360        // the C table (`#` at c:304 vs NULLCMD at c:378; UID at c:312 vs
2361        // FUNCNEST at c:366), which is exactly where zshrs's parameter
2362        // order diverged from zsh's. With the table seeded first, those
2363        // later `setsparam`/`setiparam` calls hit an existing node and
2364        // replace it IN PLACE (c:187-203 `replacing:`), keeping C's slot.
2365        // c:Src/params.c:384-394 — IPDEF8/IPDEF9 macros stamp
2366        // `PM_SCALAR|PM_SPECIAL` (IPDEF8 for `PATH`/`FPATH`/etc.) and
2367        // `PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT` (IPDEF9 for `path`/
2368        // `fpath`/etc.) on every entry in the createparamtable table.
2369        // setsparam/setaparam above create plain PM_SCALAR/PM_ARRAY
2370        // entries; this loop applies the PM_SPECIAL + PM_TIED bits
2371        // (plus the IPDEF9 PM_DONTIMPORT bit on the array side) so
2372        // `${(t)PATH}` reads `scalar-tied-export-special` and
2373        // `${(t)path}` reads `array-tied-special`.
2374        //
2375        // Walks the `special_params` table (params.rs:464+) which is
2376        // the Rust port of the C IPDEF list. For each entry: OR the
2377        // declared pm_flags onto the existing paramtab entry. The
2378        // tied-pair entries (PM_TIED) also need PM_SPECIAL OR'd in
2379        // since the IPDEF8/IPDEF9 macros add PM_SPECIAL implicitly;
2380        // the table declares only the per-entry-distinct flags.
2381        let stamp_special_params = || {
2382            use crate::ported::params::{paramtab, special_params};
2383            use crate::ported::zsh_h::{PM_ARRAY, PM_DONTIMPORT, PM_SCALAR, PM_SPECIAL, PM_TIED};
2384            if let Ok(mut tab) = paramtab().write() {
2385                // Stamp PM_SPECIAL onto every entry the special_params
2386                // table declares. For tied scalars (PATH/FPATH/etc),
2387                // also walks `tied_name` to apply IPDEF9-flag bits
2388                // (PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT|PM_TIED) onto the
2389                // partner array entry (path/fpath/etc) — those array
2390                // names aren't in the special_params table directly
2391                // but C zsh's createparamtable emits IPDEF9 rows for
2392                // them at Src/params.c:425-432.
2393                use crate::ported::zsh_h::{hashnode, param, PM_DONTIMPORT as PM_DI, PM_UNSET};
2394                // c:Src/params.c:838-847 — the first section is always
2395                // loaded; past it, sh/ksh emulation loads the untied scalar
2396                // `special_params_sh` rows INSTEAD of the zsh-only tail
2397                // (array-tied pairs, lowercase aliases, pipestatus):
2398                //     if (EMULATION(EMULATE_SH|EMULATE_KSH))
2399                //         for (ip = special_params_sh; ...) addnode(...);
2400                //     else
2401                //         while ((++ip)->node.nam) addnode(...);
2402                // `zsh --emulate sh -c 'typeset -p PATH'` therefore prints
2403                // `export PATH=…`, never `export -T PATH path=( … )`.
2404                let head = &special_params[..crate::ported::params::SPECIAL_PARAMS_ZSH_START];
2405                let tail: &[crate::ported::params::special_paramdef] = if crate::ported::zsh_h::EMULATION(
2406                    crate::ported::zsh_h::EMULATE_SH | crate::ported::zsh_h::EMULATE_KSH,
2407                ) {
2408                    crate::ported::params::special_params_sh // c:841-843
2409                } else {
2410                    &special_params[crate::ported::params::SPECIAL_PARAMS_ZSH_START..] // c:845-847
2411                };
2412                for entry in head.iter().chain(tail.iter()) {
2413                    // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash has no
2414                    // $RANDOM / $SECONDS: they are ordinary variables there.
2415                    if crate::dash_mode::dash_faithful()
2416                        && matches!(entry.name, "RANDOM" | "SECONDS")
2417                    {
2418                        continue;
2419                    }
2420                    // c:384/394 IPDEF8/9 — `D|PM_SCALAR|PM_SPECIAL` or
2421                    // `D|PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT`.
2422                    //
2423                    // Mask `entry.pm_flags` to the attribute bits that
2424                    // may be OR'd onto an existing Param.
2425                    //
2426                    // PM_READONLY IS included. C declares it on 16 rows
2427                    // via `PM_READONLY_SPECIAL` (c:Src/zsh.h:1925 —
2428                    // `PM_SPECIAL|PM_READONLY|PM_RO_BY_DESIGN`): the
2429                    // IPDEF1 pair `#`/`TTYIDLE` (c:304,314), IPDEF2 `-`
2430                    // (c:318), the IPDEF4 block `!`/`$`/`?`/`HISTCMD`/
2431                    // `LINENO`/`PPID`/`ZSH_SUBSHELL` (c:351-358) plus
2432                    // `status` (c:424), IPDEF9 `*`/`@` (c:392-393) and
2433                    // `zsh_eval_context` (c:438), and IPDEF8
2434                    // `ZSH_EVAL_CONTEXT` (c:408). `special_params`
2435                    // (params.rs:477+) declares exactly those 16 and no
2436                    // others, so the bit lands on precisely C's set.
2437                    //
2438                    // An earlier revision stripped PM_READONLY here to
2439                    // keep internal-runtime writes from tripping
2440                    // `assignstrvalue`'s guard. Nearly all of those
2441                    // writers already mutate the paramtab node in place
2442                    // — exactly as C writes the backing C global behind
2443                    // a no-op GSU (c:Src/params.c:351, IPDEF4 uses
2444                    // `varint_readonly_gsu` =
2445                    // `{intvargetfn, nullintsetfn, stdunsetfn}`). The
2446                    // in-place sites are `fusevm_bridge.rs:242,261,13045`
2447                    // (ZSH_SUBSHELL bump on `u_val`) and
2448                    // `fusevm_bridge.rs:12590,12594,12714,12718` +
2449                    // `exec.rs:7651,7655` (zsh_eval_context /
2450                    // ZSH_EVAL_CONTEXT push+pop). None call
2451                    // `setsparam`/`setiparam`.
2452                    //
2453                    // The ONE writer that did route through `setsparam`
2454                    // was `endparamscope`'s deferred scope-pop restore
2455                    // (`params.rs`, the `None =>` arm of the `deferred`
2456                    // loop): with no GSU wired it name-routes the
2457                    // restore and so re-entered the guard, emitting a
2458                    // spurious `read-only variable: NAME` while
2459                    // unwinding a scope that had shadowed one. C cannot
2460                    // reach the guard there because c:5915-5933 calls
2461                    // the setfn directly. That arm now drops the bit for
2462                    // the duration of the restore, matching C.
2463                    //
2464                    // With that handled, restoring the flag costs the
2465                    // runtime nothing while making `typeset X=v`,
2466                    // `readonly X=v` and `X+=v` reject the way
2467                    // c:Src/params.c:3216 does, and making
2468                    // `paramtypestr` (c:Src/Modules/parameter.c:75-76)
2469                    // emit the `-readonly` component that
2470                    // `${parameters[X]}` is read for.
2471                    //
2472                    // PM_UNSET is included: lookup_special_var arms for
2473                    // TRY_BLOCK_ERROR / TRY_BLOCK_INTERRUPT (and other
2474                    // PM_UNSET entries with sentinel defaults) check
2475                    // this bit to decide between "stored value" vs
2476                    // "uninitialized → return -1 sentinel". The flag
2477                    // gets cleared by assignstrvalue at c:3660 on any
2478                    // write, so it correctly tracks "ever assigned".
2479                    // Bug #143 in docs/BUGS.md.
2480                    let safe_pm_flags = entry.pm_flags
2481                        & (PM_TIED | PM_DI | PM_UNSET | crate::ported::zsh_h::PM_READONLY);
2482                    // c:Src/params.c — IPDEF macros set PM_TYPE bits
2483                    // (PM_INTEGER for IPDEF5/6, PM_ARRAY for IPDEF9,
2484                    // PM_HASHED for IPDEF-hash) along with PM_SPECIAL.
2485                    // zshrs's previous init only ORed PM_SPECIAL +
2486                    // tied/di/unset/readonly — never the type bit. If
2487                    // setsparam ran BEFORE init_partab_params (it does
2488                    // for OPTIND/SHLVL at vm_helper.rs:874/878), the
2489                    // param entry stayed PM_SCALAR and `typeset -p
2490                    // OPTIND` emitted `typeset OPTIND=1` instead of
2491                    // zsh's `typeset -i10 OPTIND=1`. OR the pm_type
2492                    // into the bits so the type attribute lands.
2493                    let mut bits = safe_pm_flags | PM_SPECIAL | entry.pm_type;
2494                    // c:Src/zsh.h:1925 — `PM_READONLY_SPECIAL` is the
2495                    // three-bit set `PM_SPECIAL|PM_READONLY|
2496                    // PM_RO_BY_DESIGN`. `special_params` stores only
2497                    // PM_READONLY per row (the other two are implied by
2498                    // the IPDEF macro), so complete the triple here:
2499                    // PM_SPECIAL is already OR'd into `bits` above, and
2500                    // this adds the PM_RO_BY_DESIGN companion that
2501                    // distinguishes a by-design readonly special from a
2502                    // user `readonly` (c:Src/zsh.h:1923).
2503                    if (entry.pm_flags & crate::ported::zsh_h::PM_READONLY) != 0 {
2504                        bits |= crate::ported::zsh_h::PM_RO_BY_DESIGN;
2505                    }
2506                    if entry.pm_type == PM_ARRAY {
2507                        bits |= PM_DI;
2508                    }
2509                    let _ = PM_SCALAR;
2510                    let _ = PM_DONTIMPORT;
2511                    if let Some(pm) = tab.get_mut(entry.name) {
2512                        let was_integer =
2513                            (pm.node.flags as u32 & crate::ported::zsh_h::PM_INTEGER) != 0;
2514                        pm.node.flags |= bits as i32;
2515                        // c:Src/params.c:344 IPDEF4 / c:353 IPDEF5 — the
2516                        // C struct literal initialises the `base` field
2517                        // to 10 for every PM_INTEGER special. zshrs's
2518                        // initial paramtab seeding doesn't carry that
2519                        // through (the special_paramdef table has no
2520                        // `base` field). Set the default here so
2521                        // `printparamnode`'s PMTF_USE_BASE arm at
2522                        // params.rs:9341 emits "10" between
2523                        // `integer` and the name (`integer 10 readonly
2524                        // !=0`). Bug #297 in docs/BUGS.md.
2525                        if entry.pm_type == crate::ported::zsh_h::PM_INTEGER && pm.base == 0 {
2526                            pm.base = 10;
2527                        }
2528                        // When OR-ing PM_INTEGER onto a param that
2529                        // was previously PM_SCALAR (i.e. setsparam ran
2530                        // BEFORE init_partab_params, storing the value
2531                        // in u_str), parse the u_str into u_val so the
2532                        // integer getter reads the correct value. C
2533                        // zsh's setsparam-equivalent path detects the
2534                        // pm's PM_TYPE first and routes through
2535                        // intsetfn, but zshrs's setsparam at the bin
2536                        // entry point predates init_partab_params, so
2537                        // it lands as PM_SCALAR storage that the
2538                        // type-flip needs to migrate.
2539                        if !was_integer
2540                            && entry.pm_type == crate::ported::zsh_h::PM_INTEGER
2541                            && pm.u_val == 0
2542                        {
2543                            if let Some(ref s) = pm.u_str {
2544                                pm.u_val = s.parse::<i64>().unwrap_or(0);
2545                                pm.u_str = None;
2546                            }
2547                        }
2548                        // c:Src/zsh.h IPDEF8/IPDEF9 — the third macro
2549                        // arg is the tied partner name; mapped into
2550                        // `pm->ename` so `typeset -p` can find the
2551                        // peer for the PM_TIED swap. Bug #410.
2552                        if let Some(peer) = entry.tied_name {
2553                            pm.ename = Some(peer.to_string());
2554                        }
2555                    } else {
2556                        // Param hasn't been created yet (e.g. PATH gets
2557                        // imported lazily via the env fallback in
2558                        // getsparam at params.rs:4104; array specials
2559                        // like `pipestatus` / `funcstack` / `dirstack`
2560                        // / `zsh_scheduled_events` aren't pre-populated).
2561                        // Seed an empty placeholder carrying the
2562                        // canonical flag set so subsequent setsparam /
2563                        // `(t)X` / `${+X}` observers see the IPDEF
2564                        // attribute bits AND `${+X}` returns 1.
2565                        let u_arr = if entry.pm_type == PM_ARRAY {
2566                            Some(Vec::new())
2567                        } else {
2568                            None
2569                        };
2570                        let pm: crate::ported::zsh_h::Param = Box::new(param {
2571                            node: hashnode {
2572                                next: None,
2573                                nam: entry.name.to_string(),
2574                                flags: (entry.pm_type as i32) | bits as i32,
2575                            },
2576                            u_data: 0,
2577                            u_tied: None,
2578                            u_arr,
2579                            u_str: None,
2580                            u_val: 0,
2581                            u_dval: 0.0,
2582                            u_hash: None,
2583                            gsu_s: None,
2584                            gsu_i: None,
2585                            gsu_f: None,
2586                            gsu_a: None,
2587                            gsu_h: None,
2588                            // c:Src/params.c:344 IPDEF4 / c:353 IPDEF5 —
2589                            // PM_INTEGER specials default base=10.
2590                            base: if entry.pm_type == crate::ported::zsh_h::PM_INTEGER {
2591                                10
2592                            } else {
2593                                0
2594                            },
2595                            width: 0,
2596                            env: None,
2597                            // c:Src/zsh.h IPDEF8/IPDEF9 — tied partner
2598                            // name. Bug #410.
2599                            ename: entry.tied_name.map(|s| s.to_string()),
2600                            old: None,
2601                            level: 0,
2602                        });
2603                        tab.insert(entry.name.to_string(), pm);
2604                    }
2605                    // Tied partner side. The previous loop body ORed
2606                    // PM_ARRAY|PM_SPECIAL|PM_DONTIMPORT|PM_TIED onto the
2607                    // partner indiscriminately, but for a SCALAR ↔
2608                    // ARRAY tied pair (PATH ↔ path, FIGNORE ↔ fignore),
2609                    // that incorrectly stamped PM_ARRAY onto the scalar
2610                    // partner (FIGNORE, PATH, FPATH, MAILPATH, MANPATH,
2611                    // PSVAR, CDPATH, MODULE_PATH). Result: `(t)PATH`
2612                    // returned `array-tied-export-special` instead of
2613                    // `scalar-tied-export-special`.
2614                    //
2615                    // Both partners are already listed in `special_params`
2616                    // (the scalar at the IPDEF8 block, the array at the
2617                    // IPDEF9 block past the sentinel), so each gets its
2618                    // own pass through this loop and ends up with the
2619                    // correct flags. No cross-stamping needed.
2620                    let _ = entry.tied_name;
2621                }
2622            }
2623        };
2624        // c:Src/init.c:1277 — `inittyptab();  /* initialize the ztypes table */`
2625        // runs inside setupvals BEFORE `createparamtable()` (c:Src/init.c:1286).
2626        // This executor is the fusevm runtime's createparamtable entry point,
2627        // and the seeding below reaches `isident()` (WORDCHARS, …), which is
2628        // typtab-driven — with a zeroed typtab every name fails IIDENT and the
2629        // seed aborts with "not an identifier: WORDCHARS".
2630        crate::ported::utils::inittyptab(); // c:1277
2631        crate::startup_trace::mark("inittyptab");
2632        stamp_special_params(); // c:838-847 — create in C's order
2633                                // Standard zsh scalar param defaults — direct port of
2634                                // `createparamtable` (Src/params.c:817-988) + the `setupvals`
2635                                // tail. Writes through canonical `setsparam` (Src/params.c:3350).
2636                                //
2637                                // c:params.c:972-973 — ZSH_VERSION / ZSH_PATCHLEVEL.
2638                                // `zsh_version::ZSH_VERSION` (emitted by build.rs from the
2639                                // vendored `Config/version.mk`) is the development snapshot
2640                                // tag `5.9.0.3-test`; shipped zsh binaries report the clean
2641                                // release form (`5.9`). Bug #73 in docs/BUGS.md — cross-shell
2642                                // scripts that gate on `[[ $ZSH_VERSION = 5.9 ]]` or split on
2643                                // `.` expecting MAJOR.MINOR break on the `-test` suffix.
2644                                //
2645                                // Use the cleaned `patchlevel::ZSH_VERSION` here ("5.9") and
2646                                // surface the full snapshot tag as `$ZSHRS_VERSION` for
2647                                // zshrs-specific identity checks.
2648                                // ZSH_VERSION / ZSH_PATCHLEVEL / ZSHRS_VERSION / ZSH_NAME /
2649                                // ZSH_ARGZERO are NOT seeded here: C creates them at the END of
2650                                // `createparamtable` (c:970-973, after the environ import) and
2651                                // ZSH_NAME at `Src/init.c:1364` (setupvals, later still). They
2652                                // are seeded at those C positions further down, because a name
2653                                // created before the import lands in a different chain slot —
2654                                // `ZSH_NAME` seeded here came out BEHIND every same-bucket
2655                                // environment variable in `${(k)parameters}` instead of ahead
2656                                // of them (c:Src/hashtable.c:214-215 front-insert).
2657        setsparam("WORDCHARS", "*?_-.[]~=/&;!#$%^(){}<>");
2658        // SHLVL is NOT seeded here. c:Src/params.c:948-951 increments it
2659        // AFTER the environ-import loop, so the +1 lives at the end of that
2660        // loop below — see the `c:948-951` block. Doing it here instead meant
2661        // parsing the raw env string with `parse::<i32>()`, which mis-read
2662        // every non-decimal form C accepts via zstrtol_underscore
2663        // (SHLVL=0x10 must give 17, 010 → 9, 1_0 → 11, 9abc → 10, abc → 1).
2664        // POSIX/zsh default IFS: space + tab + newline + NUL.
2665        setsparam("IFS", " \t\n\0");
2666        // POSIX getopts: OPTIND starts at 1.
2667        setsparam("OPTIND", "1");
2668        // Note: OPTERR is NOT pre-initialised. zsh leaves it unset
2669        // even after `getopts` calls (verified: `getopts ":a" opt -a`
2670        // does not set it). It's a user-writable variable that
2671        // starts unset. Bug #150 in docs/BUGS.md.
2672        // zsh wipes inherited `$_` (unlike bash).
2673        setsparam("_", "");
2674        // c:params.c:5064 — histchars derives from bangchar+hatchar+
2675        // hashchar (defaults `!`, `^`, `#`). At init the special
2676        // entry may not exist yet — fall back to the literal default.
2677        let histchars_val = paramtab()
2678            .read()
2679            .ok()
2680            .and_then(|t| {
2681                t.get("histchars")
2682                    .or_else(|| t.get("HISTCHARS"))
2683                    .map(|pm| histcharsgetfn(pm))
2684            })
2685            .unwrap_or_else(|| "!^#".to_string());
2686        setsparam("histchars", &histchars_val);
2687
2688        // c:Src/params.c:870-871 — `setsparam("TIMEFMT", ...)` etc.
2689        // Seed TIMEFMT explicitly so `${(k)parameters}` lists it
2690        // (the createparamtable() ported in ported::params isn't
2691        // invoked from this bin entry — its setsparam calls don't
2692        // run, so TIMEFMT only existed via the lookup_special_var
2693        // fallback, which scanpmparameters can't see).
2694        setsparam("TIMEFMT", crate::ported::zsh_system_h::DEFAULT_TIMEFMT);
2695        // c:Src/params.c:892 — `setsparam("TMPPREFIX",
2696        // ztrdup_metafy(DEFAULT_TMPPREFIX));`, the line immediately
2697        // before the TIMEFMT seed above. `DEFAULT_TMPPREFIX` is
2698        // "/tmp/zsh" (c:configure.ac:3030 → config.h). Same reason as
2699        // TIMEFMT: createparamtable() is not reached from this bin
2700        // entry, so without this seed `$TMPPREFIX` existed only when
2701        // the environment happened to export it — every scrubbed-env
2702        // launch (cron, launchd/systemd unit, container entrypoint,
2703        // `env -i`) left it unset and every temp-file path derived
2704        // from it fell back per-call-site.
2705        //
2706        // C seeds unconditionally BEFORE the import loop (c:870 vs
2707        // c:893+) and the import then overwrites via assignsparam, so
2708        // an exported $TMPPREFIX still wins. zshrs's import only
2709        // rewrites an entry that is still PM_UNSET, so the env value is
2710        // resolved HERE instead — same end state, and the node is
2711        // created at C's position in the bucket chain. Skipping the
2712        // seed when the environment had TMPPREFIX (the previous shape)
2713        // deferred creation into the import loop, which put TMPPREFIX
2714        // behind every environment variable that hashes to its bucket.
2715        //
2716        // The lookup reads the process-entry environ snapshot, the same
2717        // source the import loop below walks (see the `environ` static
2718        // in ported::params for why the live environment is not it).
2719        let env_at_entry = |name: &str| -> Option<String> {
2720            crate::ported::params::environ
2721                .get()
2722                .and_then(|v| {
2723                    v.iter()
2724                        .find(|(k, _)| k == name)
2725                        .map(|(_, val)| val.clone())
2726                })
2727                .or_else(|| std::env::var(name).ok())
2728        };
2729        setsparam(
2730            "TMPPREFIX",
2731            env_at_entry("TMPPREFIX")
2732                .as_deref()
2733                .unwrap_or(crate::ported::config_h::DEFAULT_TMPPREFIX),
2734        ); // c:870
2735           // c:Src/init.c:1214-1215 — `nullcmd = ztrdup("cat");
2736           // readnullcmd = ztrdup(DEFAULT_READNULLCMD);`. Real paramtab
2737           // seeds (NOT read-time fallbacks) so `unset NULLCMD` truly
2738           // unsets — the bare-redirect "redirection with no command"
2739           // diagnostic depends on getsparam returning None afterwards.
2740           // c:config.h:48 DEFAULT_READNULLCMD "more" — the parity
2741           // floor agrees: scrubbed-env Homebrew zsh 5.9.1 -fc reports
2742           // READNULLCMD=more (probed; the previous macOS arm's "less"
2743           // guess came from the USER's env exporting READNULLCMD=less
2744           // — zpwr sets it). This block runs AFTER the env import, so
2745           // these are DEFAULT seeds only: an env-imported value must
2746           // win (C seeds before the import loop, c:854-885 vs c:893+).
2747        if getsparam("NULLCMD").map_or(true, |v| v.is_empty()) {
2748            setsparam("NULLCMD", "cat");
2749        }
2750        if getsparam("READNULLCMD").map_or(true, |v| v.is_empty()) {
2751            setsparam("READNULLCMD", crate::ported::config_h::DEFAULT_READNULLCMD);
2752        }
2753        // c:Src/params.c:873-876 — `gethostname(hostnam, 256);
2754        //                            setsparam("HOST", ztrdup_metafy(hostnam));`
2755        // Seeded HERE, before the import loop, exactly like C; it used
2756        // to run at the very end of this constructor, which put HOST
2757        // ahead of same-bucket specials (PROMPT) that C creates first.
2758        // The env value is resolved up front for the same reason as
2759        // TMPPREFIX above (C's import would overwrite it).
2760        let mut host_buf = [0u8; 256];
2761        let host_rc = unsafe { libc::gethostname(host_buf.as_mut_ptr() as *mut libc::c_char, 256) }; // c:874
2762        let hostname = if host_rc == 0 {
2763            std::ffi::CStr::from_bytes_until_nul(&host_buf)
2764                .ok()
2765                .and_then(|c| c.to_str().ok())
2766                .unwrap_or("")
2767                .to_string()
2768        } else {
2769            String::new()
2770        };
2771        setsparam("HOST", env_at_entry("HOST").as_deref().unwrap_or(&hostname)); // c:875
2772                                                                                 // c:Src/params.c:878-882 — `setsparam("LOGNAME", (str = getlogin())
2773                                                                                 //     && *str ? ztrdup_metafy(str) : ztrdup(cached_username));`
2774                                                                                 // Also pre-import in C (c:878 vs c:893+); creating it during the
2775                                                                                 // import instead put LOGNAME behind the environment variables
2776                                                                                 // sharing its bucket.
2777        let logname_default = {
2778            let from_getlogin = unsafe {
2779                let p = libc::getlogin(); // c:880
2780                if p.is_null() {
2781                    String::new()
2782                } else {
2783                    std::ffi::CStr::from_ptr(p).to_string_lossy().into_owned()
2784                }
2785            };
2786            if from_getlogin.is_empty() {
2787                crate::ported::utils::get_username() // c:882 cached_username
2788            } else {
2789                from_getlogin
2790            }
2791        };
2792        setsparam(
2793            "LOGNAME",
2794            env_at_entry("LOGNAME")
2795                .as_deref()
2796                .unwrap_or(&logname_default),
2797        ); // c:878
2798           // c:Src/init.c:1186-1193 — default prompt strings. zsh sets
2799           // PS4 to "+%N:%i> " for ZSH emulation ("+ " for KSH/SH).
2800           // Without seeding, PS4 reads empty and `set -x` output has
2801           // no prefix at all. Bug #92 in docs/BUGS.md.
2802           //
2803           // C zsh runs createparamtable's env-import loop (c:893-924)
2804           // BEFORE init.c:1186 fires, so an exported $PS4 in the parent
2805           // env wins over the default seed. zshrs's env import happens
2806           // further down in ShellExecutor::new() (at the createparamtable
2807           // call site), so getsparam() reads None here even when env has
2808           // a value, and the default would clobber the user's PS4.
2809           //
2810           // Additional wrinkle: C zsh's PROMPT / PROMPT2 / PROMPT3 /
2811           // PROMPT4 params are ALIASES for PS1..PS4 (Src/params.c:381,
2812           // 415-421 — both IPDEF7R entries bind to the same `prompt*`
2813           // global). So `export PROMPT4=...` in the parent env sets the
2814           // shared global, and `$PS4` reads the same string. The user's
2815           // interactive shell exports PROMPT4 (the form zsh's prompt
2816           // theme system uses), so when zshrs -x runs, PROMPT4 is in
2817           // env but PS4 is not. Without aliasing in the env-probe step,
2818           // zshrs seeds default PS4 and ignores the user's customised
2819           // prefix.
2820           //
2821           // Probe env::var directly for the name AND its alias; first
2822           // non-empty wins. Only fall through to the default seed when
2823           // every candidate is empty. Mirrors C zsh's behavior without
2824           // reshuffling the rest of new(). Bug: `zshrs -x` ignored the
2825           // user's custom PS4/PROMPT4 unless re-forwarded with
2826           // `PS4=$PROMPT4 zshrs -x`.
2827        // !!! DASH-FAITHFUL GATE (no C counterpart) !!! dash has no `PROMPTn` aliases
2828        // (an imported `PROMPT4` is an ordinary variable) and always starts with
2829        // `PS1='$ '` (`# ` for root), `PS2='> '`, `PS4='+ '`, interactive or not.
2830        let dash_prompts = crate::extensions::dash_mode::dash_faithful();
2831        // c:Src/init.c:1212-1213 — `ifs = EMULATION(EMULATE_KSH|EMULATE_SH) ?
2832        // ztrdup(DEFAULT_IFS_SH) : ztrdup(DEFAULT_IFS)`: the sh default has no
2833        // Meta+space tail. An inherited `IFS` still wins (importenv).
2834        if dash_prompts && std::env::var_os("IFS").is_none() {
2835            *crate::ported::params::ifs_lock().lock().unwrap() =
2836                Some(crate::ported::zsh_h::DEFAULT_IFS_SH.to_string());
2837        }
2838        let seed_prompt = |name: &str, alias: Option<&str>, default: &str| {
2839            let alias = if dash_prompts { None } else { alias };
2840            let default = match (dash_prompts, name) {
2841                (true, "PS1") => {
2842                    if crate::ported::utils::privasserted() {
2843                        "# "
2844                    } else {
2845                        "$ "
2846                    }
2847                }
2848                (true, "PS2") => "> ",
2849                (true, "PS4") => "+ ",
2850                _ => default,
2851            };
2852            let cur = crate::ported::params::getsparam(name);
2853            let have_param = cur.as_deref().map_or(false, |s| !s.is_empty());
2854            if have_param {
2855                return;
2856            }
2857            // Probe primary name first, then the C-side alias.
2858            // An EMPTY exported value counts: C's env import (c:893-924)
2859            // assigns whatever `environ` holds, empty string included, and
2860            // it runs before the c:1196 defaults, so `export PS1=` yields
2861            // an empty prompt rather than `%m%# `. Testing only for a
2862            // NON-empty value skipped that case and re-seeded the default.
2863            for candidate in std::iter::once(name).chain(alias.into_iter()) {
2864                if let Ok(env_val) = std::env::var(candidate) {
2865                    setsparam(name, &env_val);
2866                    return;
2867                }
2868            }
2869            setsparam(name, default);
2870        };
2871        seed_prompt("PS4", Some("PROMPT4"), "+%N:%i> ");
2872        // c:Src/init.c:1181-1190 —
2873        //     if(unset(INTERACTIVE)) {
2874        //         prompt = ztrdup("");
2875        //         prompt2 = ztrdup("");
2876        //     } else ... {
2877        //         prompt  = ztrdup("%m%# ");
2878        //         prompt2 = ztrdup("%_> ");
2879        //     }
2880        // Non-interactive shells get EMPTY primary/secondary prompts
2881        // — `zsh -fc 'typeset'` lists PS1='' — while interactive ones
2882        // get the %m%# defaults. PS3/PS4/SPROMPT are seeded
2883        // unconditionally in C (c:1191-1194). PS1 may be reset by the
2884        // prompt-theme layer; only seed when the slot is empty so any
2885        // prior theme write wins.
2886        let interactive = crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE);
2887        seed_prompt(
2888            "PS1",
2889            Some("PROMPT"),
2890            if interactive { "%m%# " } else { "" },
2891        );
2892        seed_prompt(
2893            "PS2",
2894            Some("PROMPT2"),
2895            if interactive { "%_> " } else { "" },
2896        );
2897        // c:Src/init.c:1191 — `prompt3 = ztrdup("?# ");`
2898        seed_prompt("PS3", Some("PROMPT3"), "?# ");
2899        // c:Src/init.c:1194 — `sprompt = ztrdup("zsh: correct '%R'
2900        // to '%r' [nyae]? ");` — spelling-correction prompt.
2901        seed_prompt("SPROMPT", None, "zsh: correct '%R' to '%r' [nyae]? ");
2902        // c:Src/params.c:417-422 — `PROMPT*` aliases for `PS*`.
2903        // C zsh's IPDEF7("PROMPT", &prompt), IPDEF7("PROMPT2",
2904        // &prompt2), IPDEF7("PROMPT3", &prompt3), IPDEF7("PROMPT4",
2905        // &prompt4) all point to the same C globals as the matching
2906        // IPDEF7("PS{1..4}", ...) entries — they're aliases in C,
2907        // sharing storage. zshrs's paramtab keeps them as separate
2908        // entries; mirror the alias by mirroring the value here.
2909        // Bug #274 in docs/BUGS.md (PROMPT3 was the visible report;
2910        // PROMPT/PROMPT2/PROMPT4 had the same gap silently).
2911        for (alias, source) in &[
2912            ("PROMPT", "PS1"),
2913            ("PROMPT2", "PS2"),
2914            ("PROMPT3", "PS3"),
2915            ("PROMPT4", "PS4"),
2916        ] {
2917            if dash_prompts {
2918                continue;
2919            }
2920            if crate::ported::params::getsparam(alias).map_or(true, |s| s.is_empty()) {
2921                if let Some(v) = crate::ported::params::getsparam(source) {
2922                    setsparam(alias, &v);
2923                }
2924            }
2925        }
2926        // c:params.c:858-860 — standard non-special param defaults.
2927        // C uses `setiparam(...)` (PM_INTEGER) for these so
2928        // `(t)MAILCHECK` etc. report `integer`. zshrs previously
2929        // routed through `setsparam` (PM_SCALAR) — the value worked
2930        // but the type bit was wrong, breaking
2931        // `case "${(t)LISTMAX}" in *integer*)` and any path that
2932        // gates on arithmetic-typed semantics. Bug #268 in
2933        // docs/BUGS.md.
2934        crate::ported::params::setiparam("MAILCHECK", 60); // c:858
2935        crate::ported::params::setiparam("KEYTIMEOUT", 40); // c:859
2936        crate::ported::params::setiparam("LISTMAX", 100); // c:860
2937                                                          // c:config.h:1004 — MAX_FUNCTION_DEPTH=500. Advisory cap;
2938                                                          // dispatch_function_call enforces against this.
2939        crate::ported::params::setiparam("FUNCNEST", 500);
2940
2941        // Run setlocale(LC_ALL, "") so nl_langinfo() (used by the
2942        // `langinfo` module) returns the host's actual locale instead
2943        // of the C/POSIX default ("US-ASCII"). Direct port of zsh's
2944        // Src/init.c:1208 setlocale call. unsafe { } around libc is
2945        // standard for this exact use-case — setlocale is process-
2946        // global and must run once at startup.
2947        unsafe {
2948            libc::setlocale(libc::LC_ALL, c"".as_ptr());
2949        }
2950
2951        // c:hashtable.c:1206 createaliastables() — seeds aliastab with
2952        // the `run-help` / `which-command` defaults. Run once at shell
2953        // init so the canonical port owns the default-alias set; the
2954        // Executor's `aliases` HashMap then mirrors aliastab.
2955        crate::ported::hashtable::createaliastables();
2956        crate::startup_trace::mark("createaliastables");
2957        // Build the initial $path tied array as a local — fans out
2958        // to paramtab below; no ShellExecutor mirror anymore.
2959        let mut arrays: HashMap<String, Vec<String>> = HashMap::new();
2960        // c:Src/init.c:1125-1130 — `path` starts as the compiled-in default;
2961        // an inherited $PATH replaces it in the environ import. With no PATH
2962        // in the environment the default stands and is not exported.
2963        let path_dirs: Vec<String> = match env::var("PATH") {
2964            Ok(p) => p.split(':').map(|s| s.to_string()).collect(),
2965            Err(_) => ["/bin", "/usr/bin", "/usr/ucb", "/usr/local/bin"] // c:1126-1129
2966                .iter()
2967                .map(|s| s.to_string())
2968                .collect(),
2969        };
2970        arrays.insert("path".to_string(), path_dirs);
2971        let mut exec = Self {
2972            // c:Src/init.c:479 — `-c` mode: scriptname = scriptfilename
2973            // = ztrdup("zsh"). Both start at the literal "zsh".
2974            // dispatch_function_call overrides scriptname per c:5903;
2975            // scriptfilename stays at the outer file.
2976            scriptname: Some("zsh".to_string()),
2977            scriptfilename: Some("zsh".to_string()),
2978            subshell_snapshots: Vec::new(),
2979            inline_env_stack: Vec::new(),
2980            current_command_glob_failed: std::cell::Cell::new(false),
2981            jobs: JobTable::new(),
2982            fpath,
2983            history,
2984            completions: HashMap::new(),
2985            process_sub_counter: 0,
2986            zstyles: Vec::new(),
2987            local_scope_depth: 0,
2988            pending_underscore: None,
2989            in_dq_context: 0,
2990            in_scalar_assign: 0,
2991            profiling_enabled: false,
2992            compsys_cache: std::cell::OnceCell::new(),
2993            compinit_pending: None, // (receiver, start_time)
2994            plugin_cache: {
2995                let pc_path = crate::plugin_cache::default_cache_path();
2996                if let Some(parent) = pc_path.parent() {
2997                    let _ = fs::create_dir_all(parent);
2998                }
2999                match crate::plugin_cache::PluginCache::open(&pc_path) {
3000                    Ok(pc) => {
3001                        let (plugins, functions) = pc.stats();
3002                        tracing::info!(
3003                            plugins,
3004                            cached_functions = functions,
3005                            path = %pc_path.display(),
3006                            "plugin_cache: sqlite opened"
3007                        );
3008                        Some(pc)
3009                    }
3010                    Err(e) => {
3011                        // A corrupt cache file is not a permanent condition:
3012                        // `plugins.db` is derived data, rebuilt by the next
3013                        // plugin scan. SQLite answers a clobbered header with
3014                        // SQLITE_NOTADB ("file is not a database"), and the
3015                        // old behaviour logged that and moved on — so the
3016                        // cache stayed dead for every future shell too, with
3017                        // nothing but a log line to say why plugin lookups
3018                        // were slow. Discard the file and open once more;
3019                        // this is the same "drop it and rebuild silently"
3020                        // rule the shard cache already applies to a version
3021                        // mismatch. A second failure keeps the old
3022                        // behaviour, so a permissions problem still degrades
3023                        // instead of looping.
3024                        let corrupt = matches!(
3025                            e.sqlite_error_code(),
3026                            Some(rusqlite::ErrorCode::NotADatabase)
3027                                | Some(rusqlite::ErrorCode::DatabaseCorrupt)
3028                        );
3029                        if corrupt {
3030                            tracing::warn!(
3031                                error = %e,
3032                                path = %pc_path.display(),
3033                                "plugin_cache: corrupt — discarding and rebuilding"
3034                            );
3035                            let _ = fs::remove_file(&pc_path);
3036                            // The -wal/-shm side files belong to the database
3037                            // that just went away; leaving them makes the
3038                            // fresh open inherit a journal for a file that no
3039                            // longer exists.
3040                            for side in ["-wal", "-shm"] {
3041                                let mut p = pc_path.clone().into_os_string();
3042                                p.push(side);
3043                                let _ = fs::remove_file(PathBuf::from(p));
3044                            }
3045                            match crate::plugin_cache::PluginCache::open(&pc_path) {
3046                                Ok(pc) => {
3047                                    tracing::info!(
3048                                        path = %pc_path.display(),
3049                                        "plugin_cache: rebuilt after corruption"
3050                                    );
3051                                    Some(pc)
3052                                }
3053                                Err(e2) => {
3054                                    tracing::warn!(error = %e2, "plugin_cache: reopen after discard failed");
3055                                    None
3056                                }
3057                            }
3058                        } else {
3059                            tracing::warn!(error = %e, "plugin_cache: failed to open");
3060                            None
3061                        }
3062                    }
3063                }
3064            },
3065            deferred_compdefs: Vec::new(),
3066            returning: None,
3067            zsh_compat: false,
3068            bash_compat: false,
3069            posix_mode: false,
3070            worker_pool: {
3071                let config = crate::config::load();
3072                let pool_size = crate::config::resolve_pool_size(&config.worker_pool);
3073                std::sync::Arc::new(crate::worker::WorkerPool::new(pool_size))
3074            },
3075            intercepts: Vec::new(),
3076            async_jobs: HashMap::new(),
3077            next_async_id: 1,
3078            redirect_scope_stack: Vec::new(),
3079            multios_scope_stack: Vec::new(),
3080            exec_redirs_permanent: false,
3081            pipe_output_pending: false,
3082            pipe_output_scope: None,
3083            redirect_failed: false,
3084            functions_compiled: crate::cow_map::CowHashMap::new(),
3085            function_source: crate::cow_map::CowHashMap::new(),
3086            function_line_base: HashMap::new(),
3087            function_def_file: HashMap::new(),
3088            prompt_funcstack: Vec::new(),
3089            tied_array_to_scalar: HashMap::new(),
3090            ztest_pass_count: std::sync::atomic::AtomicUsize::new(0),
3091            ztest_fail_count: std::sync::atomic::AtomicUsize::new(0),
3092            ztest_skip_count: std::sync::atomic::AtomicUsize::new(0),
3093            ztest_pass_total: std::sync::atomic::AtomicUsize::new(0),
3094            ztest_fail_total: std::sync::atomic::AtomicUsize::new(0),
3095            ztest_skip_total: std::sync::atomic::AtomicUsize::new(0),
3096            ztest_run_failed: std::sync::atomic::AtomicBool::new(false),
3097            ztest_suppress_stdout: false,
3098        };
3099        // Publish the session worker pool so preprompt-time async hooks
3100        // (async_precmd) can reach it without an entered executor context.
3101        crate::async_precmd::set_session_pool(std::sync::Arc::clone(&exec.worker_pool));
3102        crate::startup_trace::mark("worker pool ready");
3103        // Mirror env-derived path arrays into the `arrays` table so
3104        // user-level `fpath` / `path` array reads see the inherited
3105        // entries. zsh: `fpath+=…` should append to the inherited
3106        // 43-entry array, not replace it. Same for `path` (PATH).
3107        crate::startup_trace::mark("exec struct built");
3108        let fpath_arr: Vec<String> = exec
3109            .fpath
3110            .iter()
3111            .map(|p| p.to_string_lossy().to_string())
3112            .collect();
3113        if !fpath_arr.is_empty() {
3114            exec.set_array("fpath".to_string(), fpath_arr);
3115        }
3116        if let Ok(path) = env::var("PATH") {
3117            let path_arr: Vec<String> = path
3118                .split(':')
3119                .filter(|s| !s.is_empty())
3120                .map(String::from)
3121                .collect();
3122            if !path_arr.is_empty() {
3123                exec.set_array("path".to_string(), path_arr);
3124            }
3125        }
3126        // Register the standard tied path-family pairs so `path+=` /
3127        // `fpath+=` / etc. mirror through the array→scalar sync hook
3128        // in BUILTIN_APPEND_ARRAY (and the SET_ARRAY tied path).
3129        // Direct port of the implicit ties that zsh wires up at
3130        // startup for PATH/path, FPATH/fpath, etc. Source-of-truth
3131        // for the pairs is Src/init.c's `setupvals()` PM_TIED entries.
3132        // c:Src/params.c:395-422 IPDEF8 — full PM_TIED colonarr list:
3133        // CDPATH, FIGNORE, FPATH, MAILPATH, PATH, PSVAR, MODULE_PATH,
3134        // MANPATH (ZSH_EVAL_CONTEXT is readonly-special, excluded).
3135        for (scalar, arr) in [
3136            ("PATH", "path"),
3137            ("FPATH", "fpath"),
3138            ("MANPATH", "manpath"),
3139            ("CDPATH", "cdpath"),
3140            ("MODULE_PATH", "module_path"),
3141            ("PSVAR", "psvar"),
3142            ("FIGNORE", "fignore"),
3143            ("MAILPATH", "mailpath"),
3144        ] {
3145            exec.tied_array_to_scalar
3146                .insert(arr.to_string(), (scalar.to_string(), ":".to_string()));
3147        }
3148
3149        // Pour `path` (from env PATH split) into paramtab. The IPDEF9
3150        // flag set was stamped by the c:838-847 pass above and survives
3151        // the assignment (`assignaparam` keeps PM_DONTIMPORT on a
3152        // PM_SPECIAL node — c:3374 + params.rs), so no re-stamp is
3153        // needed here.
3154        for (k, v) in &arrays {
3155            setaparam(k, v.clone()); // c:params.c:3595
3156        }
3157
3158        // c:Src/params.c:893-924 — the environment import runs AFTER the
3159        // specials table (moved above, c:838-847) and after the c:854-885
3160        // non-special seeds, exactly as `createparamtable` sequences them.
3161        //
3162        // Carries c:951's `addenv(pm, buf)` argument out of the paramtab
3163        // write-lock below: `addenv` takes that same lock itself, so the call
3164        // cannot be made while the guard is alive.
3165        let mut shlvl_env: Option<String> = None;
3166        // !!! LOCK DISCIPLINE — NO GSU CALLBACK MAY RUN UNDER THE PARAMTAB GUARD !!!
3167        //
3168        // C's import loop calls `assignsparam(..., ASSPM_ENV_IMPORT)` (c:907-908)
3169        // and holds no lock at all, so a setfn reached from there is free to read
3170        // any parameter — and several do. `intsetfn` name-dispatches `$COLUMNS` /
3171        // `$LINES` to `zlevarsetfn` (c:4226), which re-enters `adjustwinsize`,
3172        // which falls back to `getsparam("COLUMNS")` when its ioctl comes back
3173        // empty. `paramtab()` is a non-reentrant `std::sync::RwLock`, so running
3174        // that under the write guard below parked the shell on itself:
3175        //
3176        //     COLUMNS=200 zshrs -f -c 'print $COLUMNS'
3177        //
3178        // never printed anything and never exited, with `sample` showing
3179        // 2665/2665 samples in `semaphore_wait_trap` down
3180        // `ShellExecutor::new → intsetfn → zlevarsetfn → adjustwinsize →
3181        // adjustlines → getsparam → RwLock::lock_contended`. That is the same
3182        // chain 5929e82b81 diagnosed on the startup path; that fix removed a
3183        // Rust-only return value from `adjustwinsize` so the ONE caller it knew
3184        // about stopped re-entering, and left the pattern in place for this one.
3185        //
3186        // So the dispatches are queued here and replayed after the guard is
3187        // released, the way `shlvl_env` and `tied_env_arrays` already are. One
3188        // queue, not one per signature, because C's order is per-variable and
3189        // must survive: `termsetfn` populates the termcap geometry that a later
3190        // `$LINES` import reads back through `adjustwinsize`.
3191        enum DeferredEnvSetfn {
3192            /// c:2774 — `v->pm->gsu.i->setfn(v->pm, ival)`.
3193            Int(String, i64),
3194            /// c:907-908 — the cached-storage scalar specials' setfn.
3195            Scalar(String, String),
3196        }
3197        let mut deferred_env_setfns: Vec<DeferredEnvSetfn> = Vec::new();
3198        {
3199            use crate::ported::params::paramtab;
3200            if let Ok(mut tab) = paramtab().write() {
3201                use crate::ported::zsh_h::{param, PM_UNSET};
3202                // c:Src/params.c:893-924 environment-import loop —
3203                // every env var gets either a fresh exported paramtab
3204                // entry OR (when the entry pre-exists from
3205                // special_params) PM_EXPORTED OR'd onto its flags.
3206                // Without this, `declare -p PATH` printed `typeset -T
3207                // PATH=''` and `declare -p USER` printed nothing at
3208                // all because USER was never in paramtab.
3209                use crate::ported::zsh_h::hashnode as _hn;
3210                use crate::ported::zsh_h::{PM_EXPORTED, PM_SCALAR};
3211                // c:Src/params.c:4329-4342 colonarrsetfn — assigning a
3212                // tied IPDEF8 scalar (MANPATH, CDPATH, MODULE_PATH, …)
3213                // colonsplit()s the value into the partner array,
3214                // preserving empty components. The env import below
3215                // bypasses the GSU setfn, so collect the tied pairs
3216                // here and pour them through setaparam after the
3217                // paramtab lock drops. PATH→path / FPATH→fpath are
3218                // seeded earlier (vm_helper ~1160-1199) and skipped.
3219                let mut tied_env_arrays: Vec<(String, Vec<String>)> = Vec::new();
3220                // c:Src/params.c:893 — walk the process-entry environ
3221                // snapshot, not the live env (frameworks can mutate it
3222                // before init — see params.rs `environ` static).
3223                let environ_vars: Vec<(String, String)> = crate::ported::params::environ
3224                    .get()
3225                    .cloned()
3226                    .unwrap_or_else(|| std::env::vars().collect());
3227                for (env_name, env_value) in environ_vars {
3228                    if env_name.is_empty() || env_name.contains('[') {
3229                        continue;
3230                    }
3231                    if env_name.as_bytes()[0].is_ascii_digit() {
3232                        continue;
3233                    }
3234                    if !crate::ported::params::isident(&env_name) {
3235                        continue;
3236                    }
3237                    if let Some(pm) = tab.get_mut(&env_name) {
3238                        // c:Src/params.c:902-906 — the import loop runs
3239                        // `dontimport(pm->node.flags)` BEFORE doing
3240                        // anything to the entry; PM_DONTIMPORT names
3241                        // (`_`, IFS, GID/EGID, KEYBOARD_HACK — the
3242                        // IPDEF7/IPDEF2 rows, c:796-800) are skipped
3243                        // ENTIRELY: no PM_EXPORTED stamp, no value
3244                        // seed. zshrs previously OR'd PM_EXPORTED
3245                        // first, so an inherited env `_` made the
3246                        // special `_` exported and it leaked into
3247                        // `typeset +x -r` / `export -p` listings where
3248                        // zsh shows nothing.
3249                        if (pm.node.flags as u32 & crate::ported::zsh_h::PM_DONTIMPORT) != 0 {
3250                            continue; // c:905 `continue;`
3251                        }
3252                        pm.node.flags |= PM_EXPORTED as i32;
3253                        // c:Src/params.c:2769-2776 — assignstrvalue's
3254                        // PM_INTEGER arm, which C's env import reaches via
3255                        // `assignsparam(..., ASSPM_ENV_IMPORT)` (c:907-908;
3256                        // assignsparam forwards its `flags` verbatim at
3257                        // c:params.c assignstrvalue(v, val, flags)):
3258                        //     if (flags & ASSPM_ENV_IMPORT) {
3259                        //         char *ptr;
3260                        //         ival = zstrtol_underscore(val, &ptr, 0, 1);
3261                        //     } else
3262                        //         ival = mathevali(val);
3263                        //     v->pm->gsu.i->setfn(v->pm, ival);
3264                        // An integer param keeps its value in `u.val`, NOT in
3265                        // the scalar slot, so the `pm.u_str = env_value` seed
3266                        // below stored the digits somewhere no integer reader
3267                        // ever looks and EVERY pre-existing PM_INTEGER param
3268                        // silently ignored the environment: COLUMNS/LINES
3269                        // (IPDEF5, c:355-356) read back 0, while HISTSIZE,
3270                        // SAVEHIST, LISTMAX, MAILCHECK, KEYTIMEOUT and
3271                        // FUNCNEST kept their built-in defaults — i.e.
3272                        // `HISTSIZE=5000 zshrs -c ...` was a no-op.
3273                        //
3274                        // Base 0 + underscore=1 is not incidental: it is what
3275                        // makes `COLUMNS=0x10` 16, `COLUMNS=0b101` 5,
3276                        // `COLUMNS=010` 8 (octal — c:utils.c:2452-2461 takes
3277                        // the leading `0` then falls to `base = 8`),
3278                        // `COLUMNS=1_0` 10, and a trailing-garbage value like
3279                        // `9abc` a silent 9. mathevali would instead ERROR on
3280                        // `9abc`, which is precisely why C splits the two
3281                        // paths — importing a hostile environment must not
3282                        // abort the shell (upstream 546203a770, "33276: safer
3283                        // import of numerical variables from environment").
3284                        if (pm.node.flags as u32 & crate::ported::zsh_h::PM_INTEGER) != 0 {
3285                            let (ival, _) =
3286                                crate::ported::utils::zstrtol_underscore(&env_value, 0, true); // c:2773
3287                                                                                               // c:3660 — any assignstrvalue write clears PM_UNSET.
3288                            pm.node.flags &= !(PM_UNSET as i32);
3289                            // c:2774 — `v->pm->gsu.i->setfn(v->pm, ival)`.
3290                            // intsetfn is this port's stand-in for the gsu_i
3291                            // vtable: it name-dispatches the specials whose
3292                            // setter has side effects (SECONDS, RANDOM,
3293                            // HISTSIZE, …) and writes u.val otherwise. Queued,
3294                            // not called: see the lock-discipline note above.
3295                            //
3296                            // The plain storage half of the setfn still lands
3297                            // HERE, synchronously, because C's write is
3298                            // synchronous and code further down this same guard
3299                            // reads it back: the c:948-951 SHLVL block computes
3300                            // `++shlvl` from the value this loop just imported,
3301                            // and the C comment on it ("the increment must
3302                            // observe the imported value") is load-bearing.
3303                            // Deferring the storage too made `SHLVL=5 zshrs -f
3304                            // -c 'print $SHLVL'` answer 1 instead of 6. The
3305                            // queued dispatch then re-applies it along with the
3306                            // side effects only it can produce.
3307                            pm.u_val = ival; // c:2774 (the `pm->u.val = x` half)
3308                            deferred_env_setfns
3309                                .push(DeferredEnvSetfn::Int(env_name.clone(), ival)); // c:2774
3310                            pm.env = Some(format!("{env_name}={env_value}"));
3311                            continue;
3312                        }
3313                        // c:Src/params.c:893-924 — C's env-import calls
3314                        // `assignsparam(..., ASSPM_ENV_IMPORT)` which
3315                        // routes through the param's GSU setfn. For
3316                        // SPECIAL scalars with cached storage (HOME,
3317                        // USERNAME, TERM, WORDCHARS, TERMINFO,
3318                        // TERMINFO_DIRS, KEYBOARD_HACK, histchars) the
3319                        // setfn writes to a separate `*_lock` global
3320                        // (e.g. home_lock). Just OR'ing PM_EXPORTED
3321                        // leaves those globals empty, so `$HOME` reads
3322                        // back "" even though HOME is in env. Mirror
3323                        // C by copying the env value into pm.u_str and
3324                        // (for cached specials) the matching global.
3325                        // Only seed cached state when the param was
3326                        // still marked PM_UNSET — i.e. nothing has set
3327                        // it yet. ShellExecutor::new's earlier init
3328                        // block (vm_helper line 837+) already ran
3329                        // setsparam for a few names (ZSH_ARGZERO,
3330                        // WORDCHARS, SHLVL with the +1 increment, IFS,
3331                        // OPTIND, …); those calls clear PM_UNSET so we
3332                        // must not overwrite them with the raw env
3333                        // value here. The PM_UNSET-still-set case is
3334                        // the "C zsh would have called
3335                        // assignsparam(...,ASSPM_ENV_IMPORT) and ours
3336                        // didn't yet" gap that bug #599 (HOME=` `) and
3337                        // %~ prompt expansion need.
3338                        let still_unset =
3339                            (pm.node.flags as u32 & crate::ported::zsh_h::PM_UNSET) != 0;
3340                        if still_unset {
3341                            pm.u_str = Some(env_value.clone());
3342                            pm.env = Some(format!("{}={}", env_name, env_value));
3343                            // c:Src/params.c:3660 — `assignstrvalue`
3344                            // clears PM_UNSET on any write. HOME / TERM
3345                            // / TERMINFO / TERMINFO_DIRS / WORDCHARS
3346                            // start life with PM_UNSET in
3347                            // `special_params` (params.rs SPECIAL_PARAMS
3348                            // table) so `lookup_special_var` skips the
3349                            // getfn for uninitialized specials; env
3350                            // import is the canonical "now it's set"
3351                            // event, so clear the bit.
3352                            pm.node.flags &= !(PM_UNSET as i32);
3353                            // Cached-state specials: route through
3354                            // the matching setfn so the global cache
3355                            // (home_lock / wordchars_lock / etc.)
3356                            // reflects the env value. Queued, not
3357                            // called: see the lock-discipline note
3358                            // above. `termsetfn` in particular reaches
3359                            // the terminal setup, which is exactly the
3360                            // kind of callee that reads parameters.
3361                            if matches!(
3362                                env_name.as_str(),
3363                                "HOME"
3364                                    | "USERNAME"
3365                                    | "TERM"
3366                                    | "WORDCHARS"
3367                                    | "TERMINFO"
3368                                    | "TERMINFO_DIRS"
3369                            ) {
3370                                deferred_env_setfns.push(DeferredEnvSetfn::Scalar(
3371                                    env_name.clone(),
3372                                    env_value.clone(),
3373                                )); // c:907-908
3374                            }
3375                        }
3376                        // c:Src/params.c:907-908 — env import always
3377                        // assigns through the GSU setfn; for tied
3378                        // IPDEF8 scalars that is colonarrsetfn
3379                        // (c:4329-4342), which colonsplit()s the value
3380                        // into the partner array, empties preserved.
3381                        // Not gated on still_unset: C re-assigns on
3382                        // import regardless.
3383                        if (pm.node.flags as u32 & crate::ported::zsh_h::PM_TIED) != 0 {
3384                            if let Some(ref peer) = pm.ename {
3385                                if peer != "path" && peer != "fpath" {
3386                                    tied_env_arrays.push((
3387                                        peer.clone(),
3388                                        env_value.split(':').map(String::from).collect(), // c:4339 colonsplit
3389                                    ));
3390                                }
3391                            }
3392                        }
3393                    } else {
3394                        // Fresh entry — PM_SCALAR + PM_EXPORTED, value
3395                        // taken from env. Mirrors C zsh's c:907-908
3396                        // `assignsparam(..., ASSPM_ENV_IMPORT)` for
3397                        // names not already in the special table.
3398                        let pm: crate::ported::zsh_h::Param = Box::new(param {
3399                            node: _hn {
3400                                next: None,
3401                                nam: env_name.clone(),
3402                                flags: (PM_SCALAR | PM_EXPORTED) as i32,
3403                            },
3404                            u_data: 0,
3405                            u_tied: None,
3406                            u_arr: None,
3407                            u_str: Some(env_value.clone()),
3408                            u_val: 0,
3409                            u_dval: 0.0,
3410                            u_hash: None,
3411                            gsu_s: None,
3412                            gsu_i: None,
3413                            gsu_f: None,
3414                            gsu_a: None,
3415                            gsu_h: None,
3416                            base: 0,
3417                            width: 0,
3418                            env: Some(format!("{}={}", env_name, env_value)),
3419                            ename: None,
3420                            old: None,
3421                            level: 0,
3422                        });
3423                        tab.insert(env_name, pm);
3424                    }
3425                }
3426                // Apply the collected tied-pair splits after the env
3427                // walk. setaparam (the canonical store) needs the
3428                // same paramtab write lock held here, so write u_arr
3429                // directly on the peer entry — array reads route
3430                // through paramtab so this is the single store.
3431                for (peer, parts) in tied_env_arrays {
3432                    if let Some(apm) = tab.get_mut(peer.as_str()) {
3433                        apm.u_arr = Some(parts); // c:4339 — `*dptr = colonsplit(x, …)`
3434                        apm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
3435                    }
3436                }
3437                // c:Src/params.c:948-951 — runs AFTER the import loop:
3438                //     pm = (Param) paramtab->getnode(paramtab, "SHLVL");
3439                //     sprintf(buf, "%d", (int)++shlvl);
3440                //     /* shlvl value in environment needs updating unconditionally */
3441                //     addenv(pm, buf);
3442                // SHLVL is `IPDEF5("SHLVL", &shlvl, varinteger_gsu)` (c:358), so
3443                // the loop above has already parsed any inherited value into it
3444                // with zstrtol_underscore; C then increments THAT, in place.
3445                // Ordering is the whole point: the increment must observe the
3446                // imported value, and the import must not clobber the
3447                // increment. When SHLVL is absent from the environment the
3448                // param is still 0 here, so ++ yields 1 — matching C, whose
3449                // `shlvl` global starts at 0.
3450                //
3451                // addenv also exports the INCREMENTED value, which is why a
3452                // forked child sees 6 for `SHLVL=5 zsh -fc 'printenv SHLVL; true'`.
3453                // (A bare `printenv SHLVL` shows 5 because zsh exec's the last
3454                // command in place and backs the increment out — the shell is
3455                // being replaced, not nested. That is a separate mechanism,
3456                // ported for the explicit `exec` form at
3457                // fusevm_bridge.rs's BUILTIN_EXEC.)
3458                if let Some(pm) = tab.get_mut("SHLVL") {
3459                    let next = pm.u_val + 1; // c:949 `++shlvl`
3460                    // Queued like the import loop's dispatches above — SHLVL's
3461                    // setfn is a leaf today, but the rule is the pattern, not
3462                    // the current callee list. Storage half applied here for
3463                    // the same reason it is up there: c:951's `addenv` and
3464                    // everything after this guard must see the incremented
3465                    // value.
3466                    pm.u_val = next; // c:949 (the `*pm->u.valptr = x` half)
3467                    deferred_env_setfns.push(DeferredEnvSetfn::Int("SHLVL".to_string(), next)); // c:949
3468                    pm.node.flags &= !(PM_UNSET as i32);
3469                    // c:951 — the argument of the unconditional `addenv`.
3470                    // C renders it with `sprintf(buf, "%d", (int)shlvl)`
3471                    // AFTER the increment, so it is the incremented value.
3472                    shlvl_env = Some(next.to_string()); // c:950
3473                }
3474            }
3475        }
3476        // ---- paramtab guard released; replay the queued dispatches ----
3477        //
3478        // This is the c:907-908 / c:2774 / c:949 setfn call, moved to where no
3479        // lock is held, in the order C makes it. Each one runs against a
3480        // detached node — which is what C's pointer-to-the-live-node amounts to
3481        // once the lock is out of the way — and the integer arm publishes the
3482        // union slot the setfn wrote back onto the table afterwards. The scalar
3483        // specials need no publish: their setfns write a cached global
3484        // (`home_lock`, `wordchars_lock`, …) and ignore `pm`, matching C's
3485        // `UNUSED(Param pm)`. A node the setfn removed outright is simply
3486        // skipped.
3487        for deferred in deferred_env_setfns {
3488            match deferred {
3489                DeferredEnvSetfn::Int(name, ival) => {
3490                    let staged = crate::ported::params::paramtab()
3491                        .read()
3492                        .ok()
3493                        .and_then(|tab| tab.get(name.as_str()).map(|pm| (**pm).clone()));
3494                    let Some(mut staged) = staged else { continue };
3495                    crate::ported::params::intsetfn(&mut staged, ival); // c:2774
3496                    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
3497                        if let Some(pm) = tab.get_mut(name.as_str()) {
3498                            pm.u_val = staged.u_val;
3499                        }
3500                    }
3501                }
3502                DeferredEnvSetfn::Scalar(name, value) => {
3503                    let staged = crate::ported::params::paramtab()
3504                        .read()
3505                        .ok()
3506                        .and_then(|tab| tab.get(name.as_str()).map(|pm| (**pm).clone()));
3507                    let Some(mut staged) = staged else { continue };
3508                    match name.as_str() {
3509                        "HOME" => crate::ported::params::homesetfn(&mut staged, value),
3510                        "USERNAME" => crate::ported::params::usernamesetfn(&mut staged, value),
3511                        "TERM" => crate::ported::params::termsetfn(&mut staged, value),
3512                        "WORDCHARS" => crate::ported::params::wordcharssetfn(&mut staged, value),
3513                        "TERMINFO" => crate::ported::params::terminfosetfn(&mut staged, value),
3514                        "TERMINFO_DIRS" => {
3515                            crate::ported::params::terminfodirssetfn(&mut staged, value)
3516                        }
3517                        _ => {}
3518                    }
3519                }
3520            }
3521        }
3522        // c:Src/params.c:960-965 — HOME wiring, which C runs right
3523        // after the environment-import loop:
3524        //     pm = (Param) realparamtab->getnode2(realparamtab, "HOME");
3525        //     if (EMULATION(EMULATE_ZSH))
3526        //     {
3527        //         pm->node.flags &= ~PM_UNSET;
3528        //         if (!(pm->node.flags & PM_EXPORTED))
3529        //             addenv(pm, home);
3530        //     } else if (!home)
3531        //         pm->node.flags |= PM_UNSET;
3532        // `home` itself was synthesised from the password database
3533        // back in setupvals (c:Src/init.c:1237-1250) BEFORE the import
3534        // loop, so an inherited $HOME wins: the import calls
3535        // `homesetfn` and overwrites the synthesised value.
3536        //
3537        // zshrs reaches neither of those C sites from this bin entry,
3538        // so `$HOME` was whatever the environment supplied and nothing
3539        // else — a scrubbed launch (cron, launchd/systemd unit,
3540        // container entrypoint, `env -i`) got no $HOME at all, and
3541        // every `~`, rc-file path and cache path derived from it
3542        // silently resolved to "" (`~/x` expanded to `/x`).
3543        //
3544        // Ordering is preserved by only synthesising when the import
3545        // produced nothing: `var_os` is the exact "was it in the
3546        // environment" test C's loop keys off, so an explicitly empty
3547        // `HOME=` still stays empty (reference binary: `env -i … HOME=
3548        // zsh -f -c 'print -r -- "[${HOME-UNSET}]"'` prints `[]`).
3549        //
3550        // `home` itself comes from c:Src/init.c:1237-1250, inlined here
3551        // because C has no function to port — it is straight-line code
3552        // inside `setupvals()`:
3553        //     #ifdef USE_GETPWUID
3554        //         if ((pswd = getpwuid(cached_uid))) {
3555        //             if (EMULATION(EMULATE_ZSH))
3556        //                 home = ztrdup_metafy(pswd->pw_dir);
3557        //             cached_username = ztrdup_metafy(pswd->pw_name);
3558        //         }
3559        //         else
3560        //     #endif /* USE_GETPWUID */
3561        //         {
3562        //             if (EMULATION(EMULATE_ZSH))
3563        //                 home = ztrdup("/");
3564        //             cached_username = ztrdup("");
3565        //         }
3566        // Both arms are guarded on EMULATE_ZSH: under sh/ksh emulation
3567        // the C global stays NULL and `$HOME` can only come from the
3568        // environment. Reference binary agrees — `env -i TERM=dumb
3569        // PATH=/usr/bin:/bin zsh --emulate sh -f -c 'echo
3570        // "HOME=${HOME-UNSET}"'` prints `HOME=UNSET`, while the same
3571        // command without `--emulate sh` prints the password-database
3572        // home. `cached_username` is seeded separately (see the
3573        // getlogin() block below), so only the `home` half is here.
3574        if std::env::var_os("HOME").is_none()
3575            && crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_ZSH)
3576        {
3577            // c:Src/init.c:1239 — `getpwuid(cached_uid)`, cached_uid
3578            // being `getuid()` from c:1235.
3579            let pswd = unsafe { libc::getpwuid(libc::getuid()) };
3580            let pw_dir = if pswd.is_null() {
3581                std::ptr::null()
3582            } else {
3583                unsafe { (*pswd).pw_dir }
3584            };
3585            let h = if pw_dir.is_null() {
3586                // c:1248 — password lookup failed: `home = ztrdup("/")`.
3587                // A NULL `pw_dir` on a present entry is the same
3588                // "no usable home" case.
3589                "/".to_string()
3590            } else {
3591                // c:1241 — `home = ztrdup_metafy(pswd->pw_dir)`.
3592                crate::ported::utils::metafy(
3593                    &unsafe { std::ffi::CStr::from_ptr(pw_dir) }.to_string_lossy(),
3594                )
3595            };
3596            // Routes through `homesetfn`, so the `home` global and the
3597            // paramtab entry agree (c:Src/params.c:5118).
3598            crate::ported::params::setsparam("HOME", &h);
3599            // c:964-965 — the param is not PM_EXPORTED (it was not
3600            // imported), so C addenv's it. The reference binary
3601            // confirms the synthesised value reaches children:
3602            // `env -i TERM=dumb PATH=/usr/bin:/bin zsh -f -c
3603            // '/usr/bin/env'` lists `HOME=/Users/…`.
3604            crate::ported::params::addenv("HOME", &h);
3605        }
3606
3607        // c:Src/params.c:968-970 — `pm = realparamtab->getnode2(realparamtab,
3608        // "LOGNAME"); if (!(pm->node.flags & PM_EXPORTED)) addenv(pm, pm->u.str);`
3609        //
3610        // Unconditional in C, right after the HOME block above, and the same
3611        // shape: the param was SEEDED (c:878-882, from `getlogin()`) rather
3612        // than imported, so it carries no PM_EXPORTED and C exports it here.
3613        // The faithful port of this lives in `createparamtable`
3614        // (ported/params.rs, c:968-970) but that function is not on this
3615        // entry path — `ShellExecutor::new` is — so the seeded value never
3616        // reached the environment. Measured under a scrubbed env:
3617        //
3618        //   env -i HOME=$HOME TERM=xterm PATH=... <shell> -f -c '${(t)LOGNAME}'
3619        //     zsh  : scalar-export      zshrs: scalar
3620        //
3621        // and a child of zshrs saw no LOGNAME at all where a child of zsh did.
3622        // Like the SHLVL case above, C exports a value the import loop did not
3623        // supply; unlike it, there is no paired exec-time adjustment.
3624        if let Some(v) = crate::ported::params::getsparam("LOGNAME") {
3625            crate::ported::params::addenv("LOGNAME", &v); // c:970
3626        }
3627
3628        // c:Src/params.c:951 — `addenv(pm, buf)`. Unconditional: the C comment
3629        // on the line above it is "shlvl value in environment needs updating
3630        // unconditionally". `addenv` both zputenv's the string and stamps
3631        // `pm->node.flags |= PM_EXPORTED` (c:Src/params.c:5482-5484), so this
3632        // one call is what makes `${(t)SHLVL}` read `integer-export-special`
3633        // AND what puts the incremented number in a forked child's
3634        // environment.
3635        //
3636        // Omitting it left $SHLVL unable to count nesting at all — measured
3637        // with each shell nesting itself twice, the inner shells FORKED so
3638        // that no exec-in-place decrement applies:
3639        //     zsh    L1=1 L2=2 L3=3
3640        //     zshrs  L1=1 L2=1 L3=1
3641        // because every nested zshrs re-read the grandparent's stale
3642        // environment entry instead of the parent's incremented one. It also
3643        // left the parameter unexported under a scrubbed environment
3644        // (`env -i … -f -c '${(t)SHLVL}'`: zsh `integer-export-special`,
3645        // zshrs `integer-special`), which a completion listing sees directly:
3646        // `env <TAB>` runs `_parameters -g "*export*"` and zsh offered SHLVL
3647        // there where zshrs did not.
3648        // It follows the HOME and LOGNAME exports, as in C (c:960-974), so a
3649        // child sees them in zsh's environment order.
3650        if let Some(v) = shlvl_env {
3651            crate::ported::params::addenv("SHLVL", &v); // c:951
3652        }
3653
3654        // SHLVL's paired exec-time half — c:Src/exec.c:
3655        // 4332-4336, "for either implicit or explicit exec, decrease $SHLVL as
3656        // we're now done as a shell", guarded by `!subsh && !forked` — is
3657        // ported for the EXPLICIT `exec cmd` form at fusevm_bridge.rs's
3658        // BUILTIN_EXEC, the one place fusevm replaces its own process.
3659        //
3660        // Still open: C also execs IN PLACE for the LAST command of a `-c`
3661        // script, and fusevm always forks there (`zsh -fc 'echo $$; /bin/sh -c
3662        // "echo \$\$"'` reports one pid on zsh, two on zshrs). So
3663        // `SHLVL=5 <shell> -fc '/usr/bin/env'` shows SHLVL=5 on zsh (exec'd,
3664        // decremented) and SHLVL=6 on zshrs (forked, so C's own rule says do
3665        // not decrement). That divergence belongs to the unported implicit
3666        // exec-in-place, not to the SHLVL wiring: fixing it means teaching
3667        // fusevm C's `do_exec` for the final command, and until then the
3668        // forked answer is the one C would give for a forked command.
3669        // c:Src/init.c:1907-1909 — `SHTTY = -1; init_io(cmd); setupvals(...)`.
3670        // zsh_main runs those three in that order, and this constructor stands
3671        // in for setupvals's param setup on the drivers that never reach
3672        // zsh_main: `zshrs -c CODE` dispatches at bins/zshrs.rs:1716 (after
3673        // --zsh/-f are stripped) straight into ShellExecutor::new + exit. So
3674        // init_io has to happen here, or SHTTY is still -1 and the winsize
3675        // probe below silently no-ops (adjustwinsize early-returns at
3676        // c:1900-1901). setupvals's own adjustwinsize(0) covers the zsh_main
3677        // path; init_io is idempotent (c:615-618 closes and reopens SHTTY), so
3678        // that path just re-establishes it a moment later.
3679        crate::ported::init::init_io(None); // c:1908
3680        crate::startup_trace::mark("exec: init_io");
3681
3682        // c:Src/init.c:1274-1276 — `adjustwinsize(0)`, the first thing after
3683        // createparamtable (c:1270). Probes the tty via TIOCGWINSZ and
3684        // publishes the geometry to $COLUMNS/$LINES.
3685        //
3686        // Ordering is C's, and load-bearing in both directions: it must FOLLOW
3687        // init_io (which sets SHTTY) and it must FOLLOW the environ import
3688        // above, because the tty geometry OVERRIDES an inherited COLUMNS — a
3689        // 97-column terminal reports 97 even when COLUMNS=10 was exported in.
3690        // (C gets that via the c:1906-1907 "Signal missed while a job owned the
3691        // tty?" promotion of from=0 to from=1, which makes adjustcolumns take
3692        // the signalled path and overwrite zterm_columns with ws_col.)
3693        //
3694        // With no terminal at all, SHTTY stays -1 and the imported value (or 0)
3695        // survives untouched — which is what C's zterm_columns does there, and
3696        // why a piped `COLUMNS=20 zshrs -c` still reports 20. Note SHTTY does
3697        // NOT require stdin/stdout to be a tty: init_io's last resort is
3698        // `open("/dev/tty")` (c:667-670), so a piped-but-still-attached shell
3699        // gets the real width, matching `zsh -fc 'print $COLUMNS | cat'`.
3700        let _ = crate::ported::utils::adjustwinsize(0); // c:1276
3701        crate::startup_trace::mark("exec: adjustwinsize");
3702
3703        // c:Src/params.c:955 — `set_pwd_env();` runs AFTER the environ
3704        // import loop, overwriting the imported $PWD/$OLDPWD paramtab
3705        // entries with the ispwd()-validated values computed above
3706        // (c:Src/init.c:1242-1259). Without this, a stale inherited
3707        // $PWD (env-import snapshot taken at process entry) survives
3708        // in paramtab even though the live env was corrected.
3709        // The live environment is the carrier set_pwd_env reads for C's
3710        // `pwd`/`oldpwd` globals (see the c:Src/init.c:1242-1259 block above).
3711        env::set_var("PWD", &pwd_val);
3712        env::set_var("OLDPWD", &oldpwd_val);
3713        crate::dash_mode::set_oldpwd_global(&oldpwd_val); // c:1255-1259
3714        crate::ported::builtin::set_pwd_env();
3715        crate::startup_trace::mark("exec: set_pwd_env");
3716
3717        // c:Src/params.c:975-992 — host/arch identification params:
3718        // CPUTYPE / MACHTYPE / OSTYPE / VENDOR. C zsh reads from
3719        // compile-time `#define`s (set by ./configure) for MACHTYPE /
3720        // OSTYPE / VENDOR, and from uname().machine at runtime for
3721        // CPUTYPE.
3722        //
3723        // Rust port: probe uname() at startup for CPUTYPE, and use
3724        // const strings parameterized by build-target for the
3725        // others. Match homebrew zsh's values where possible.
3726        let mut uname_buf: libc::utsname = unsafe { std::mem::zeroed() };
3727        let _ = unsafe { libc::uname(&mut uname_buf) };
3728        let to_str = |b: &[libc::c_char]| -> String {
3729            // c-string → owned String, truncated at first NUL.
3730            let bytes: Vec<u8> = b
3731                .iter()
3732                .take_while(|&&c| c != 0)
3733                .map(|&c| c as u8)
3734                .collect();
3735            String::from_utf8_lossy(&bytes).into_owned()
3736        };
3737        let cputype = to_str(&uname_buf.machine);
3738        crate::ported::params::setsparam("CPUTYPE", &cputype); // c:961
3739                                                               // OSTYPE: configure's `$host_os`, resolved on the build host and
3740                                                               // frozen into config.h — C never re-derives it from uname() at
3741                                                               // startup. Deriving it here made the two writers disagree, so the
3742                                                               // same binary answered `darwin25.5.0` under -c and `darwin23.6.0`
3743                                                               // under -i. Single source of truth: config_h::OSTYPE, exactly as
3744                                                               // MACHTYPE below.
3745        crate::ported::params::setsparam("OSTYPE", crate::ported::config_h::OSTYPE); // c:990
3746                                                                                     // MACHTYPE: configure's `$host_cpu`, i.e. the config.guess
3747                                                                                     // canonical arch name — NOT uname's `machine`. The two differ
3748                                                                                     // on Apple Silicon (uname says `arm64`, config.guess says
3749                                                                                     // `aarch64`), and zsh reports the latter. Single source of
3750                                                                                     // truth: config_h::MACHTYPE (= build target arch).
3751        crate::ported::params::setsparam("MACHTYPE", crate::ported::config_h::MACHTYPE); // c:967
3752                                                                                         // VENDOR: configure's `$host_vendor`. Deriving it from uname's
3753                                                                                         // `sysname` here was a second, non-C writer that disagreed with
3754                                                                                         // `config_h::VENDOR` off Darwin: config.guess emits `pc` for x86_64
3755                                                                                         // Linux (config.guess:1222) and `unknown` for aarch64 Linux
3756                                                                                         // (config.guess:1009), a distinction `sysname` cannot make. Single
3757                                                                                         // source of truth, exactly as OSTYPE/MACHTYPE above.
3758        crate::ported::params::setsparam("VENDOR", crate::ported::config_h::VENDOR); // c:992
3759
3760        // c:Src/init.c:963 — `setsparam("TTY", ttyname(0) ?: "")`, which
3761        // C reaches at c:969 in the createparamtable tail. Even a
3762        // non-interactive -fc shell creates the param.
3763        let tty_str = unsafe {
3764            let p = libc::ttyname(0);
3765            if p.is_null() {
3766                String::new()
3767            } else {
3768                std::ffi::CStr::from_ptr(p)
3769                    .to_str()
3770                    .unwrap_or("")
3771                    .to_string()
3772            }
3773        };
3774        crate::ported::params::setsparam("TTY", &tty_str); // c:969
3775                                                           // c:Src/params.c:971 — `setsparam("ZSH_ARGZERO", ztrdup(posixzero))`:
3776                                                           // the kernel-supplied argv[0] of THIS binary, in --zsh parity mode
3777                                                           // too. The bin entrypoint overrides this with the script path for
3778                                                           // -c / runscript invocations. (A previous revision probed the
3779                                                           // system zsh install path and reported THAT as ZSH_ARGZERO for
3780                                                           // byte-parity — faking the shell's identity. Parity tests that
3781                                                           // compare the value must normalize the machine-specific binary
3782                                                           // path in the test row instead.)
3783        let argzero_default = env::args().next().unwrap_or_else(|| "zsh".to_string());
3784        crate::ported::params::setsparam("ZSH_ARGZERO", &argzero_default); // c:971
3785                                                                           // c:Src/params.c:972 — ZSH_VERSION. `zsh_version::ZSH_VERSION`
3786                                                                           // (emitted by build.rs from the vendored `Config/version.mk`) is
3787                                                                           // the development snapshot tag `5.9.0.3-test`; shipped zsh
3788                                                                           // binaries report the clean release form (`5.9`). Bug #73 in
3789                                                                           // docs/BUGS.md — cross-shell scripts that gate on
3790                                                                           // `[[ $ZSH_VERSION = 5.9 ]]` or split on `.` expecting
3791                                                                           // MAJOR.MINOR break on the `-test` suffix. Use the cleaned
3792                                                                           // `patchlevel::ZSH_VERSION` here ("5.9") and surface the full
3793                                                                           // snapshot tag as `$ZSHRS_VERSION` for zshrs identity checks.
3794        crate::ported::params::setsparam("ZSH_VERSION", crate::ported::patchlevel::ZSH_VERSION); // c:972
3795                                                                                                 // c:Src/params.c:973 + Src/patchlevel.h — `ZSH_PATCHLEVEL` is a
3796                                                                                                 // git-describe-style identifier (`zsh-MAJOR.MINOR-N-gHASH`) of
3797                                                                                                 // the upstream commit zshrs targets. `build.rs` emits "unknown"
3798                                                                                                 // because the vendored zsh tarball ships no CUSTOM_PATCHLEVEL
3799                                                                                                 // define; use the canonical const in `patchlevel.rs` instead.
3800                                                                                                 // Bug #90 in docs/BUGS.md — scripts that fingerprint by
3801                                                                                                 // $ZSH_PATCHLEVEL fell to the wildcard arm under "unknown".
3802        crate::ported::params::setsparam(
3803            "ZSH_PATCHLEVEL",
3804            crate::ported::patchlevel::ZSH_PATCHLEVEL,
3805        ); // c:973
3806           // Skip ZSHRS_VERSION whenever the zsh-compatible namespace must
3807           // stay free of zshrs-original names, so `${(k)parameters}`
3808           // doesn't carry a name zsh doesn't ship — same predicate and
3809           // reasoning as the guard in `ported::params::createparamtable`.
3810           // `hide_ext_builtins()` is `--zsh` OR `ZSHRS_HIDE_EXT_BUILTINS`
3811           // (the parity harnesses' knob). Scripts can still detect zshrs
3812           // via `$ZSH_VERSION`, which carries a `-test` suffix.
3813        if !crate::ext_builtins::hide_ext_builtins() {
3814            crate::ported::params::setsparam(
3815                "ZSHRS_VERSION",
3816                crate::ported::patchlevel::ZSHRS_VERSION,
3817            );
3818        }
3819        // `$HELPDIR` points at the bundled help tree that
3820        // `bundled_docs::install_and_publish` (top of this constructor)
3821        // materialised, so `run-help` works on a host with no zsh install.
3822        // It is seeded HERE, after the environment-import loop above, for
3823        // two reasons: the table has to exist to hold it, and an exported
3824        // `HELPDIR` the user supplied is already imported by now, so the
3825        // seed's own guard leaves that value (and its PM_EXPORTED) alone.
3826        //
3827        // The parameter is NOT exported. `run-help` is a shell function
3828        // that reads `${HELPDIR:-…}` out of the shell's own table, so the
3829        // environment entry this used to create bought nothing and leaked
3830        // the name into every child process — `env <TAB>`, which lists
3831        // exported names, offered a name zsh does not have.
3832        crate::bundled_docs::seed_helpdir_param();
3833        // c:Src/params.c:974-979 — `setaparam("signals", …)`.
3834        {
3835            use crate::ported::signals_h::SIGS;
3836            // c:signames.c sigs[] (generated) — index 0 is "EXIT",
3837            // entries 1..=SIGCOUNT are in PLATFORM SIGNAL-NUMBER
3838            // order, tail is "ZERR", "DEBUG" (zsh.h SIGZERR/SIGDEBUG).
3839            // SIGS is declared in Linux textual order, so sort by the
3840            // libc number to reproduce the generated table's order on
3841            // every platform. Same construction as params.rs — keep
3842            // in sync.
3843            let mut by_num: Vec<(&str, i32)> = SIGS.to_vec();
3844            by_num.sort_by_key(|&(_, n)| n);
3845            let mut signals_arr: Vec<String> = Vec::with_capacity(by_num.len() + 3);
3846            signals_arr.push("EXIT".to_string()); // c:sigs[0]
3847            signals_arr.extend(by_num.iter().map(|(n, _)| n.to_string()));
3848            // c:Src/params.c:1018-1021 — the real-time names follow.
3849            #[cfg(target_os = "linux")]
3850            signals_arr.extend(
3851                (libc::SIGRTMIN()..=libc::SIGRTMAX()).map(crate::ported::signals::rtsigname),
3852            );
3853            signals_arr.push("ZERR".to_string()); // c:sigs tail
3854            signals_arr.push("DEBUG".to_string()); // c:sigs tail
3855            crate::ported::params::setaparam("signals", signals_arr); // c:974
3856        }
3857        // c:Src/init.c:1364 — `setsparam("ZSH_NAME", ztrdup(zsh_name))`,
3858        // which setupvals runs AFTER createparamtable, so the node lands
3859        // ahead of the imported environment in its bucket chain.
3860        crate::ported::params::setsparam("ZSH_NAME", "zsh"); // c:Src/init.c:1364
3861        crate::startup_trace::mark("exec: base params set");
3862                                                             // LOGNAME is seeded pre-import now (c:878) — see the block by the
3863                                                             // TMPPREFIX/HOST seeds above.
3864                                                             //
3865                                                             // DO NOT setsparam("USERNAME", ...) anywhere in init. `$USERNAME`
3866                                                             // is a special parameter whose SETTER (`usernamesetfn` in
3867                                                             // params.rs) performs setgid(2) + setuid(2) to actually change
3868                                                             // the effective user — a deliberate upstream zsh feature for
3869                                                             // `USERNAME=other-user cmd`. Calling it at init seeds the value
3870                                                             // AND tries to change uid/gid; when the resolved pwd's pw_uid
3871                                                             // differs from `getuid()` (sudo launches, macOS Keychain-helper
3872                                                             // inherited env, container entry points, etc.) the setgid call
3873                                                             // fails with EPERM and emits `zsh:1: failed to change group ID:
3874                                                             // Operation not permitted`. Upstream seeds `$USERNAME` via the
3875                                                             // GETTER path (`usernamegetfn` reads through `cached_username`
3876                                                             // populated by `inittyptab` → `get_username`), no setter call.
3877
3878        // c:Src/init.c:1176 — `module_path = mkarray(MODULE_DIR)`.
3879        // The canonical init lives in `init::setupvals` (port of
3880        // `Src/init.c:setupvals`); the bin entry skips setupvals (per
3881        // the init_bltinmods comment above), so call the lightweight
3882        // module_path bootstrap exposed by init.rs from here. This
3883        // mirrors the HOST gethostname seeding pattern above:
3884        // duplicated init that should collapse into a full setupvals
3885
3886        // c:Src/init.c:1945 init_bltinmods — runs right after setupvals
3887        // (c:1942), i.e. after createparamtable's import, so the module
3888        // autoload stubs (`WATCH`, `watch`, …) are created HERE. The bin
3889        // entry skips zsh_main → init_bltinmods, so run it from
3890        // ShellExecutor::new for the same effect. Bug #270.
3891        // c:Src/init.c:1085 — `(void)addhookdefs(NULL, zshhooks, ...)`, a
3892        // setupvals step. The bin entry skips setupvals, so without this
3893        // hooktab stayed empty: `zmodload zsh/nearcolor`'s boot_
3894        // (nearcolor.c:199) found no "get_color_attr" hookdef to attach
3895        // getnearestcolor to, and `%F{#hex}` never mapped to the palette.
3896        // Shared once-guard with setupvals (see init::ZSHHOOKS_ADDED).
3897        crate::ported::init::ZSHHOOKS_ADDED.call_once(|| {
3898            let base = crate::ported::init::zshhooks.load(std::sync::atomic::Ordering::SeqCst);
3899            let _ = crate::ported::module::addhookdefs(std::ptr::null(), base, 4);
3900        });
3901        crate::ported::init::init_bltinmods(); // c:Src/init.c:1945
3902        crate::startup_trace::mark("exec: init_bltinmods");
3903
3904        // Populate paramtab with PM_SPECIAL Params for every PARTAB /
3905        // PARTAB_ARRAY magic-assoc name. Mirrors what C's zsh/parameter
3906        // module boot_ → handlefeatures chain does — which happens when
3907        // the module LOADS, after init_bltinmods planted its autoload
3908        // stubs, and `addparamdef` unsets the stub before creating the
3909        // real param (c:Src/module.c addparamdef → unsetparam_pm +
3910        // createparam), so these names take a FRESH chain slot ahead of
3911        // the stubs. Running this before init_bltinmods put `usergroups`
3912        // and friends behind `WATCH` in `${(k)parameters}`.
3913        init_partab_params(); // c:Src/Modules/parameter.c:2341 boot_/enables_ chain
3914        crate::startup_trace::mark("exec: init_partab_params");
3915
3916        // HOST is seeded pre-import now (c:875) — see the block next to
3917        // the TMPPREFIX/LOGNAME seeds above.
3918        // bash startup delta: bash defines TERM itself when the
3919        // environment does not carry one, and exports it. zsh leaves
3920        // TERM unset in that case, so `zshrs --bash` inherited zsh's
3921        // behavior and diverged from the reference shell:
3922        //
3923        //   $ env -u TERM /bin/bash -c 'printf "%s\n" "${TERM+set}"'
3924        //   set
3925        //   $ env -u TERM /bin/bash -c 'echo "$TERM"'
3926        //   dumb
3927        //   $ env -u TERM /bin/zsh -f -c 'printf "%s\n" "${TERM+set}"'
3928        //                                  (empty — zsh leaves it unset)
3929        //
3930        // Same on bash 3.2.57 (macOS /bin/bash) and 5.3.15, so it is
3931        // not a version artifact. Only the bare `--bash` drop-in takes
3932        // it: `--bash --zsh` asks for zsh-STYLE emulation, where zsh's
3933        // leave-it-unset behavior is the correct answer. Guarded on the
3934        // environment so an inherited TERM always wins.
3935        if crate::extensions::dash_mode::bash_mode() && std::env::var_os("TERM").is_none() {
3936            crate::ported::params::setsparam("TERM", "dumb");
3937            // bash exports it (`declare -x TERM` shows up in `export -p`);
3938            // addenv stamps PM_EXPORTED and pushes it into the child env.
3939            crate::ported::params::addenv("TERM", "dumb");
3940        }
3941
3942        // c:Src/init.c:479 — `-c` mode: scriptname = scriptfilename
3943        // = ztrdup("zsh"). Both globals start as the literal "zsh"
3944        // (not the binary path) so PS4's %x / %N print "zsh" not
3945        // "/path/to/zshrs" at the top level. Function dispatch
3946        // overrides scriptname per c:5903; scriptfilename stays.
3947        crate::startup_trace::mark("exec: pre-scriptname");
3948        crate::ported::utils::set_scriptname(Some("zsh".to_string()));
3949        // c:Src/init.c:470-479 — `scriptname = scriptfilename =
3950        // ztrdup("zsh")` sits INSIDE the `-c` branch of the option parse.
3951        // An interactive shell (or one running a script file) leaves
3952        // `scriptfilename` NULL, and exec.c:5383 copies it onto every
3953        // Shfunc it defines — which is why zsh reports an EMPTY
3954        // `$functions_source[f]` for a function typed at the prompt.
3955        // Stamping "zsh" unconditionally made zshrs answer "zsh" there.
3956        let dash_c = std::env::args_os()
3957            .skip(1)
3958            .map(|a| a.to_string_lossy().into_owned())
3959            .any(|a| a.starts_with('-') && !a.starts_with("--") && a.contains('c'));
3960        if dash_c {
3961            crate::ported::utils::set_scriptfilename(Some("zsh".to_string())); // c:479
3962        }
3963
3964        // call once that port is complete.
3965        crate::startup_trace::mark("exec: pre-module_path_init");
3966        crate::ported::init::module_path_init();
3967
3968        // Publish `fpath` LAST, once every special has been seeded.
3969        //
3970        // The earlier `set_array("fpath", …)` above lands before the tied
3971        // FPATH/fpath special is created, and creating it resets the node
3972        // to empty -- so an INTERACTIVE shell reached its first prompt with
3973        //     typeset -aT FPATH fpath=(  )
3974        // while `$FPATH` still read the full four-entry string. `path`
3975        // hid the bug: `PATH` is always in the environ, so the env import
3976        // refills it, whereas zsh never exports `FPATH` and nothing
3977        // refilled that one.
3978        //
3979        // The damage was not a missing default: a `.zshrc` that does
3980        // `fpath=( mydir $fpath )` -- the standard idiom -- appended to
3981        // NOTHING, so the shell ended up with only what the rc file added
3982        // and lost the bundled tree entirely.
3983        //
3984        // `-c` was unaffected, which is why every non-interactive probe of
3985        // this passed.
3986        let fpath_final: Vec<String> = exec
3987            .fpath
3988            .iter()
3989            .map(|p| p.to_string_lossy().to_string())
3990            .collect();
3991        if !fpath_final.is_empty() {
3992            // The ENVIRONMENT has to carry the final value too, not just
3993            // paramtab. `setupvals` re-seeds the tied specials and the
3994            // split that refills `fpath` reads the FPATH string; with an
3995            // inherited FPATH that string was still the one this process
3996            // started with, so the bundled tree -- appended to the param
3997            // only -- vanished the moment a shell was interactive AND had
3998            // FPATH set. `-c` never re-seeds, which is why it kept showing
3999            // the correct list.
4000            unsafe { std::env::set_var("FPATH", fpath_final.join(":")) };
4001            exec.set_array("fpath".to_string(), fpath_final);
4002        }
4003        crate::startup_trace::mark("new() end");
4004
4005        exec
4006    }
4007
4008    /// The SQLite history index, opened on first call.
4009    ///
4010    /// Startup no longer pays for it: only the three diagnostic readers
4011    /// (`--doctor`, the cache report, `dbview history`) reach this, and a
4012    /// pool worker's cell is pre-filled with `None` so it stays closed
4013    /// there.
4014    pub fn history(&self) -> Option<&HistoryEngine> {
4015        self.history
4016            .get_or_init(|| HistoryEngine::new().ok())
4017            .as_ref()
4018    }
4019
4020    /// Execute a script file with bytecode caching — skips lex+parse+compile on cache hit.
4021    /// Bytecode is stored in rkyv keyed by (path, mtime).
4022    pub fn execute_script_file(&mut self, file_path: &str) -> Result<i32, String> {
4023        let path = Path::new(file_path);
4024        let abs_path = path
4025            .canonicalize()
4026            .unwrap_or_else(|_| path.to_path_buf())
4027            .to_string_lossy()
4028            .to_string();
4029
4030        // Try bytecode cache first — rkyv shard at ~/.zshrs/scripts.rkyv.
4031        // The cache validates path + mtime + zshrs binary mtime; on any
4032        // miss we fall through to lex/parse/compile. Cached path uses
4033        // `run_chunk` (the shared VM-execution helper); script-eval
4034        // path delegates to `execute_script_zsh_pipeline` so the
4035        // full parse/compile/cache-save/run flow stays in one place.
4036        if let Some(bc_blob) = crate::script_cache::try_load_bytes(path) {
4037            if let Ok(chunk) = bincode::deserialize::<fusevm::Chunk>(&bc_blob) {
4038                if !chunk.ops.is_empty() {
4039                    tracing::trace!(
4040                        path = %abs_path,
4041                        ops = chunk.ops.len(),
4042                        "execute_script_file: bytecode cache hit"
4043                    );
4044                    return self.run_chunk(chunk, &format!("execute_script_file:cache:{abs_path}"));
4045                }
4046            }
4047        }
4048
4049        // Cache miss.
4050        // c:Src/init.c:1394-1395 — `SHIN = movefd(open(funmeta, …))`, then
4051        // zsh_main's `loop(1,0)` (c:1963) reads the script ONE EVENT AT A
4052        // TIME: `lexinit(); parse_event(ENDINPUT); … execode(prog, …)`
4053        // (c:155-220). Every complete command runs before the next is
4054        // lexed, so an `alias` a line installs is in force for the next
4055        // line, and a syntax error on line N leaves lines 1..N-1 already
4056        // executed. A whole-file compile lexed every line with the state
4057        // the file started with and ran nothing when any line failed to
4058        // parse. The event loop is the one `source` uses (c:1626-1627
4059        // `loop(0, 0)`): with `interact` unset, c:234's
4060        // `(!interact || sourcelevel) && errflag` break is the same test.
4061        // c:Src/init.c:1566 / Src/input.c — the file is read as RAW BYTES and
4062        // metafied (Src/utils.c:4856), never rejected for non-UTF-8.
4063        let content = crate::script_bytes::read_script_file(file_path)
4064            .map_err(|e| format!("{}: {}", file_path, e))?;
4065        let mut events: Vec<crate::parse::ZshList> = Vec::new();
4066        // c:Src/init.c:1963 — zsh_main runs the script through `loop(1, 0)`:
4067        // toplevel.
4068        let mut status = self.run_events_per_command(&content, Some(&mut events), true)?;
4069        // c:Src/init.c:1969-1974 — `if (tok == LEXERR || errexit) { if
4070        // (!lastval) lastval = 1; stopmsg = 1; zexit(lastval, …); }`: a
4071        // parse error, or an error abort in a non-interactive shell, exits
4072        // non-zero even when the last command that ran succeeded.
4073        if (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0 {
4074            if status == 0 {
4075                status = 1; // c:1971-1972
4076                self.set_last_status(status);
4077            }
4078            // c:Src/builtin.c:6006 zexit — `errflag = 0;` before the EXIT trap
4079            // runs, so the trap body is not itself aborted by the error.
4080            errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
4081        }
4082        let status = self.fire_script_exit_hooks(status)?;
4083
4084        // Best-effort cache save — failures don't block execution. The
4085        // cached program is the events exactly as the loop lexed them, so
4086        // a hit replays what this run parsed; `events` is empty unless the
4087        // loop drained the whole file without an error.
4088        if !events.is_empty() {
4089            let program = crate::parse::ZshProgram { lists: events };
4090            let compiler = crate::compile_zsh::ZshCompiler::new();
4091            let chunk = compiler.compile(&program);
4092            if let Ok(blob) = bincode::serialize(&chunk) {
4093                let _ = crate::script_cache::try_save_bytes(path, &blob);
4094                tracing::trace!(
4095                    path = %abs_path,
4096                    bytes = blob.len(),
4097                    "execute_script_file: bytecode cached"
4098                );
4099            }
4100        }
4101
4102        Ok(status)
4103    }
4104
4105    /// Run a compiled `fusevm::Chunk` to completion inside this
4106    /// executor's context. Shared by `execute_script_zsh_pipeline`,
4107    /// `execute_script_file`'s bytecode-cache hit path, and the
4108    /// function-dispatch body_runner. Centralises the VM setup so
4109    /// `register_builtins` and `ExecutorContext::enter` invariants
4110    /// stay in lockstep.
4111    fn run_chunk(&mut self, chunk: fusevm::Chunk, label: &str) -> Result<i32, String> {
4112        if chunk.ops.is_empty() {
4113            return Ok(self.last_status());
4114        }
4115        crate::fusevm_disasm::maybe_print_stdout(label, &chunk);
4116        let mut vm = crate::vm_pool::acquire(chunk);
4117        // Seed vm.last_status with the executor's current LASTVAL so
4118        // sub-VMs (EXIT trap bodies, eval, source) see the inherited
4119        // `$?` from the caller's last command — matching C zsh where
4120        // lastval is a process global. Without this, the new VM
4121        // started at 0 and BUILTIN_GET_VAR's sync_status would write
4122        // 0 back into LASTVAL on the first `$?` read.
4123        vm.last_status = self.last_status();
4124        let _ctx = ExecutorContext::enter(self);
4125        // c:Src/loop.c — `loops` is bracketed by the C interpreter's own
4126        // recursion, so a `return` or an errflag abort out of a loop
4127        // unwinds it for free. A compiled chunk instead jumps straight to
4128        // its end, skipping the loop's `loops--`. Restoring the count the
4129        // chunk started with makes that structurally impossible to leak:
4130        // whatever loops this chunk opened are closed when it finishes.
4131        let loops_entry = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
4132        // c:Src/exec.c:4364 `done:` → `fixfds(save)` — every execcmd puts
4133        // back the fds its redirections replaced, whether it ran or was
4134        // abandoned on errflag. A chunk abandoned mid-command (`eval 'echo >
4135        // nx(N)'`: the target's expansion raises errflag after the scope
4136        // opened) jumped past its WithRedirectsEnd, so the caller kept
4137        // writing into the failed redirection's sink. Same unwind the
4138        // function-call path does (`unwind_redirect_scopes_to`).
4139        let redir_depth = self.redirect_scope_stack.len();
4140        // Same for the job slots of pipelines the chunk was running when it
4141        // was abandoned (see exec_jobs::execpline_slot_open).
4142        let slots_entry = crate::exec_jobs::execpline_slots_mark();
4143        let result = vm.run();
4144        crate::ported::builtin::LOOPS.store(loops_entry, Ordering::Relaxed);
4145        crate::exec_jobs::execpline_slots_release_to(slots_entry);
4146        self.unwind_redirect_scopes_to(redir_depth);
4147        match result {
4148            fusevm::VMResult::Ok(_) | fusevm::VMResult::Halted => {
4149                self.set_last_status(vm.last_status);
4150            }
4151            fusevm::VMResult::Error(e) => return Err(format!("VM error: {}", e)),
4152        }
4153        Ok(self.last_status())
4154    }
4155
4156    /// Execute via the lex+parse free ported + ZshCompiler pipeline.
4157    /// This is the only execution path; `execute_script` delegates here.
4158    /// Parse + compile `script` in an isolated lexer context, without
4159    /// running it.
4160    ///
4161    /// Split out of [`ShellExecutor::execute_script_zsh_pipeline`] so the
4162    /// autoload loader can get its hands on the compiled chunk: that chunk
4163    /// is what lands in `~/.zshrs/autoloads.rkyv`, so the next process can
4164    /// install the same function without re-parsing the definition file.
4165    fn compile_script_isolated(&mut self, script: &str) -> Result<fusevm::Chunk, String> {
4166        self.compile_script_isolated_at(script, None)
4167    }
4168
4169    /// `compile_script_isolated` with C's `parse_string(s, reset_lineno)`
4170    /// argument: `Some(n)` is `parse_string(s, 0)` (c:Src/exec.c:291-300),
4171    /// the body parsed from the running line `n` (see `parse_isolated_at`).
4172    fn compile_script_isolated_at(
4173        &mut self,
4174        script: &str,
4175        lineno: Option<u64>,
4176    ) -> Result<fusevm::Chunk, String> {
4177        // Skip history expansion for non-interactive script execution
4178        // (`zsh -c '…'`, internal eval, sourced files). zsh's `!`
4179        // history sub only fires on the REPL command line, never on
4180        // a pre-parsed script body. The interactive REPL has its
4181        // own dedicated path that calls expand_history before
4182        // dispatching here.
4183        // Save & clear errflag around the parse so a fresh syntax
4184        // error is distinguishable from one already in flight. Mirrors
4185        // Src/init.c loop()'s pre-parse `errflag &= ~ERRFLAG_ERROR;`.
4186        let saved_errflag = errflag.load(Ordering::Relaxed);
4187        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
4188        // Context-isolated parse (c:Src/exec.c:283 parse_string). eval /
4189        // source / autoload-register / trap bodies all reach here and run
4190        // DURING execution; on the faithful single-event loop()/parse_event
4191        // reader, a bare parse_init/lex_init would steal the outer's next
4192        // SHIN line into this nested program (e.g. `eval "x=5"` swallowed the
4193        // following `echo $x` off stdin). parse_isolated sets `strin` so the
4194        // string drains to EOF; execution below stays in the current shell.
4195        let program = parse_isolated_at(script, lineno);
4196        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
4197        errflag.store(saved_errflag, Ordering::Relaxed);
4198        if parse_failed {
4199            // c:Src/init.c — when the parser fires `zerr(...)`, the C
4200            // shell's `loop()` body skips the eval pass and continues;
4201            // there's no second "parse error" diagnostic. The Rust
4202            // binary's call sites print `zshrs: <e>` on Err, doubling
4203            // up on the message the parser already emitted via zerr.
4204            // Use a `__SILENCED__` sentinel that the binary's
4205            // execute_script wrapper recognizes as "already reported,
4206            // exit silently". Bug #142 in docs/BUGS.md (double-print
4207            // half).
4208            // !!! DASH-FAITHFUL GATE (no C counterpart) !!! a dash syntax error is
4209            // `sh_error` -> `exraise(EXERROR)`: it ends a non-interactive shell with
4210            // status 2, wherever the text came from (`-c`, `eval`, `.`, a script),
4211            // instead of zsh's "report, status 1, carry on".
4212            if crate::extensions::dash_mode::dash_faithful()
4213                && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
4214            {
4215                let st = crate::extensions::dash_mode::fatal_error_status().unwrap_or(2);
4216                crate::ported::builtin::LASTVAL.store(st, Ordering::Relaxed);
4217                crate::ported::builtin::EXIT_VAL.store(st, Ordering::Relaxed);
4218                crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
4219            }
4220            return Err("__SILENCED__".to_string());
4221        }
4222
4223        let mut compiler = crate::compile_zsh::ZshCompiler::new();
4224        compiler.nested_lineno_base = lineno; // c:Src/exec.c:297 reset_lineno == 0
4225        if CMDARG_EXITING.with(|c| c.replace(false)) {
4226            // c:Src/init.c:1568 — `execstring(cmd, 0, 1, "cmdarg")`: the `-c`
4227            // string runs as an EXITING list, so its last command is the
4228            // fake exec's candidate (BUILTIN_EXEC_FORKED_SIMPLE mode 2).
4229            compiler.forked_tail_list = crate::compile_zsh::program_exiting_tail(&program);
4230            compiler.exiting_tail_mode = 2;
4231        }
4232        Ok(compiler.compile(&program))
4233    }
4234
4235    /// `execstring(cmd, 0, 1, "cmdarg")` (c:Src/init.c:1568) — run the `-c`
4236    /// string as the shell's last act: [`Self::execute_script`] with the
4237    /// string compiled as an exiting list (see CMDARG_EXITING).
4238    pub fn execute_cmdarg(&mut self, script: &str) -> Result<i32, String> {
4239        CMDARG_EXITING.with(|c| c.set(true));
4240        let chunk = self.compile_script_isolated(script);
4241        CMDARG_EXITING.with(|c| c.set(false));
4242        let status = self.run_chunk(chunk?, "cmdarg")?;
4243        // c:Src/init.c:1572 — `zexit(lastval, ZEXIT_NORMAL)` follows the
4244        // `-c` string, and c:Src/builtin.c:6012-6015 clears the error state
4245        // before the EXIT trap (`errflag = intrap = 0; if
4246        // (sigtrapped[SIGEXIT]) dotrap(SIGEXIT);`): dotrapargs refuses to
4247        // run anything while errflag is set (c:Src/signals.c:1101), so a
4248        // script that ended on an error abort (`ro=2` on a readonly, `${x?}`,
4249        // `$((1/0))`) would otherwise skip its EXIT trap. The flag is put
4250        // back afterwards because the caller still reads it to pick the
4251        // exit status of the dash-family drop-ins.
4252        let saved_errflag = errflag.load(Ordering::Relaxed);
4253        errflag.store(0, Ordering::Relaxed); // c:6012
4254        let result = self.fire_script_exit_hooks(status);
4255        errflag.fetch_or(saved_errflag, Ordering::Relaxed);
4256        result
4257    }
4258
4259    /// Run an already-compiled top-level chunk, then fire the end-of-script
4260    /// hooks (`EXIT` trap, `TRAPEXIT`, `zshexit` + `zshexit_functions`) the
4261    /// script pipeline owes them.
4262    fn run_chunk_with_exit_hooks(
4263        &mut self,
4264        chunk: fusevm::Chunk,
4265        label: &str,
4266    ) -> Result<i32, String> {
4267        let status = self.run_chunk(chunk, label)?;
4268        self.fire_script_exit_hooks(status)
4269    }
4270
4271    /// The end-of-script hooks alone (`EXIT` trap, `TRAPEXIT`, `zshexit` +
4272    /// `zshexit_functions`), for a script that already ran; `status` is the
4273    /// script's `$?` and is what the call returns.
4274    fn fire_script_exit_hooks(&mut self, status: i32) -> Result<i32, String> {
4275        // Fire EXIT trap if set. Two storage paths:
4276        //   (a) `trap 'cmd' EXIT` writes the body text into
4277        //       `traps_table` via bin_trap (Src/builtin.c) — fire
4278        //       directly via execute_script.
4279        //   (b) `TRAPEXIT() { ... }` function-named form goes
4280        //       through settrap(SIGEXIT, None, ZSIG_FUNC) at
4281        //       funcdef time (fusevm_bridge.rs BUILTIN_REGISTER_COMPILED_FN
4282        //       arm) and lives in shfunctab + sigtrapped — fire
4283        //       via dotrap(SIGEXIT) which dispatches the named
4284        //       shfunc. Bug #157 in docs/BUGS.md.
4285        // Remove the trap from `traps_table` first to prevent
4286        // infinite recursion of `(a)`; `(b)`'s sigtrapped flag
4287        // is cleared by dotrap's own intrap guard.
4288        let exit_body = crate::ported::builtin::traps_table()
4289            .lock()
4290            .ok()
4291            .and_then(|mut t| t.remove("EXIT"));
4292        if let Some(action) = exit_body {
4293            tracing::debug!("firing EXIT trap (new pipeline)");
4294            // c:Src/signals.c — the EXIT trap body sees $? at the
4295            // value the script left off (so `trap 'echo $?' EXIT;
4296            // (exit 7)` prints 7), but the SHELL's final exit code
4297            // is still the pre-trap value (running `echo` inside
4298            // the trap doesn't reset the script's exit status).
4299            // Preserve `status` and re-apply it after the trap
4300            // body returns.
4301            //
4302            // c:Src/signals.c:1123/1236 — `intrap++` … `intrap--` bracket a
4303            // trap body, and while intrap the EXIT, DEBUG and ZERR traps are
4304            // suppressed (c:1112-1119, the guard in dotrap). This path runs
4305            // the EXIT body through the script pipeline rather than dotrap,
4306            // so nothing raised intrap and the body's own failure re-entered
4307            // the ERR trap: `trap 'print err' ERR; trap 'true; false' EXIT`
4308            // printed err where zsh prints nothing.
4309            //
4310            // A counter, not a flag, and paired with dotrap's SELECTIVE
4311            // guard: signals zsh does deliver from inside a trap body (e.g.
4312            // `trap 'kill -USR1 $$' EXIT`) must still dispatch.
4313            crate::ported::signals::intrap.fetch_add(1, Ordering::SeqCst); // c:1123
4314            // c:Src/signals.c:1170 — `execode((Eprog)sigfn, 1, 0, "trap")`:
4315            // the EXIT body sees "trap" in zsh_eval_context (c:Src/exec.c:1251).
4316            let trap_ctx = crate::ported::exec::EvalContextFrame::push("trap");
4317            let _ = self.execute_script_zsh_pipeline(&action);
4318            drop(trap_ctx);
4319            crate::ported::signals::intrap.fetch_sub(1, Ordering::SeqCst); // c:1236
4320            self.set_last_status(status);
4321        }
4322        // c:Src/signals.c::dotrap(SIGEXIT) — fire TRAPEXIT() shfunc
4323        // if installed via the function-name path. The TRAPEXIT()
4324        // form goes through settrap(SIGEXIT, None, ZSIG_FUNC) at
4325        // funcdef time (sets sigtrapped[SIGEXIT] |= ZSIG_FUNC).
4326        // Dispatching from here AFTER run_chunk returns means we're
4327        // outside the VM context — dotrap can't safely re-enter
4328        // via dispatch_function_call (which uses with_executor).
4329        // Route through execute_script_zsh_pipeline which sets up
4330        // a fresh VM context — invoke the function by name.
4331        let trapped = crate::ported::signals::sigtrapped
4332            .lock()
4333            .ok()
4334            .and_then(|g| g.get(crate::signals_h::SIGEXIT as usize).copied())
4335            .unwrap_or(0);
4336        // c:Src/signals.c:1112-1119 — `if (intrap) { switch (sig) { case
4337        // SIGEXIT: … return; } }`, and c:Src/signals.c:892 `if (!intrap &&
4338        // …)` in endtrapscope. An EXIT trap never fires from inside another
4339        // trap body. This site is a Rust-only end-of-pipeline hook (every
4340        // `eval` / `source` / trap body runs its own pipeline and reaches
4341        // here), and it dispatches TRAPEXIT by NAME without going through
4342        // dotrap — so nothing consulted `intrap` and nothing cleared
4343        // sigtrapped. Once `endtrapscope` started restoring a saved
4344        // ZSIG_FUNC EXIT trap (the c:929-931 arm), the TRAPEXIT body's own
4345        // nested pipeline re-entered this hook with the flag still set and
4346        // recursed without bound:
4347        //   f() { eval 'TRAPEXIT() { echo T; }' }; f
4348        // The `intrap++ … intrap--` bracket is the same one the string-form
4349        // branch above already carries (c:1123 / c:1236).
4350        // c:Src/signals.c:744-752 — `sigtrapped[sig] |= (locallevel <<
4351        // ZSIG_SHIFT)`: a trap installed inside a function carries its
4352        // scope's locallevel, and `endtrapscope` (c:892-903/945-956) is what
4353        // fires THAT one, at the scope exit. Only an untagged (locallevel 0)
4354        // EXIT trap belongs to the shell-exit path this hook stands in for.
4355        // Without the test, `f() { TRAPEXIT() { echo T } }; f` fired twice —
4356        // once from f's endtrapscope and once more from the pipeline hook.
4357        let exit_trap_locallevel = trapped >> crate::ported::zsh_h::ZSIG_SHIFT;
4358        if (trapped & crate::ported::zsh_h::ZSIG_FUNC as i32) != 0
4359            && exit_trap_locallevel == 0
4360            && crate::ported::signals::intrap.load(Ordering::SeqCst) == 0
4361        {
4362            // The TRAP<SIG> function is stored in shfunctab as
4363            // "TRAPEXIT"; calling it by name re-enters
4364            // execute_script_zsh_pipeline with a fresh VM context.
4365            crate::ported::signals::intrap.fetch_add(1, Ordering::SeqCst); // c:1123
4366            let _ = self.execute_script_zsh_pipeline("TRAPEXIT");
4367            crate::ported::signals::intrap.fetch_sub(1, Ordering::SeqCst); // c:1236
4368        }
4369        // c:Src/init.c::zexit — `callhookfunc("zshexit", NULL, 1, NULL)`.
4370        // Fire the `zshexit` shfunc + walk `zshexit_functions` array.
4371        // Routed through execute_script_zsh_pipeline calls because
4372        // we're outside the VM context here (post-run_chunk). Iterate
4373        // the array directly + call zshexit by name. Bug #215 in
4374        // docs/BUGS.md.
4375        //
4376        // Re-entry guard: each call to execute_script_zsh_pipeline
4377        // (whether top-level script or the named-fn dispatch below)
4378        // hits this code at its tail. Without a guard, the zshexit
4379        // hook recurses infinitely (calls itself at end via this
4380        // path). Use a thread-local depth counter and skip the
4381        // dispatch when depth > 0.
4382        thread_local! {
4383            static ZSHEXIT_HOOK_DEPTH: std::cell::Cell<u32> = const {
4384                std::cell::Cell::new(0)
4385            };
4386        }
4387        let hook_depth = ZSHEXIT_HOOK_DEPTH.with(|c| c.get());
4388        if hook_depth == 0 {
4389            ZSHEXIT_HOOK_DEPTH.with(|c| c.set(hook_depth + 1));
4390            if crate::ported::hashtable::shfunctab_lock()
4391                .read()
4392                .ok()
4393                .map(|t| t.contains_key("zshexit"))
4394                .unwrap_or(false)
4395            {
4396                let _ = self.execute_script_zsh_pipeline("zshexit");
4397            }
4398            let exit_arr = crate::ported::params::paramtab()
4399                .read()
4400                .ok()
4401                .and_then(|t| t.get("zshexit_functions").and_then(|p| p.u_arr.clone()))
4402                .unwrap_or_default();
4403            for fn_name in exit_arr {
4404                let exists = crate::ported::hashtable::shfunctab_lock()
4405                    .read()
4406                    .ok()
4407                    .map(|t| t.contains_key(&fn_name))
4408                    .unwrap_or(false);
4409                if exists {
4410                    let _ = self.execute_script_zsh_pipeline(&fn_name);
4411                }
4412            }
4413            ZSHEXIT_HOOK_DEPTH.with(|c| c.set(hook_depth));
4414        }
4415        // Preserve script status; trap body shouldn't override it.
4416        self.set_last_status(status);
4417
4418        let _ = status;
4419        Ok(self.last_status())
4420    }
4421    /// zshrs's script entry: lex + parse + compile + run, then the
4422    /// end-of-script hooks. `eval`, `source`, trap bodies and autoload
4423    /// registration all funnel through here.
4424    pub fn execute_script_zsh_pipeline(&mut self, script: &str) -> Result<i32, String> {
4425        let chunk = self.compile_script_isolated(script)?;
4426        self.run_chunk_with_exit_hooks(chunk, "execute_script_zsh_pipeline")
4427    }
4428
4429    /// Run a source string the way C's `execstring` does (c:Src/exec.c:1228
4430    /// → `execode`, c:1245): parse, execute, return the status. No `EXIT`
4431    /// trap, `TRAPEXIT` or `zshexit` hook runs here. In C those belong to
4432    /// `zexit` (c:Src/builtin.c:6037-6043) and to the end of a function's
4433    /// trap scope (`endtrapscope`, c:Src/signals.c:880), never to the end of
4434    /// an `eval`, a trap body or a `sched` command. Firing them there ran a
4435    /// script's EXIT trap in the middle of the script:
4436    /// `trap 'print T' EXIT; eval :; print after` printed T before `after`.
4437    pub fn execute_string_without_exit_hooks(&mut self, script: &str) -> Result<i32, String> {
4438        let chunk = self.compile_script_isolated(script)?;
4439        self.run_chunk(chunk, "execstring")
4440    }
4441
4442    /// `execode(parse_string(cmdarg, 0), 1, 0, "cmdsubst")` — a nofork
4443    /// substitution body (c:Src/subst.c:2044-2046) parsed from the running
4444    /// line, so its statements carry the outer line numbers and leave
4445    /// `lineno` on them, as C's do.
4446    pub fn execute_string_at_lineno(&mut self, script: &str, lineno: u64) -> Result<i32, String> {
4447        let chunk = self.compile_script_isolated_at(script, Some(lineno))?;
4448        self.run_chunk(chunk, "execstring")
4449    }
4450
4451    /// Run the TEXT that `getpermtext` reconstructed from an already-compiled
4452    /// `.zwc` program.
4453    ///
4454    /// c:Src/init.c:1618-1622 — the compiled arm of `source()` is
4455    /// `execode(prog, 1, 0, "filecode")`. The wordcode runs as it stands and
4456    /// NOTHING is lexed; a `.zwc` is quote-resolved once, at `zcompile` time.
4457    ///
4458    /// !!! WARNING: RUST-ONLY HELPER !!!
4459    /// zshrs has no execute-the-wordcode path — it deparses the program back
4460    /// to source (`getpermtext`) and lexes it again — so the round trip is
4461    /// lossless only while the lexer reads quotes the way the deparse writes
4462    /// them. `untokenize` (c:Src/exec.c:2134) renders EVERY quote null through
4463    /// `ztokens[Snull - Pound]`, and that entry is a bare single quote
4464    /// (c:Src/lex.c:38), so a closing null followed by an opening one comes
4465    /// back out as two adjacent quotes. Under RCQUOTES the lexer reads that
4466    /// pair inside a quoted word as one LITERAL quote (c:Src/lex.c:1328)
4467    /// instead of as two delimiters, so the openshift-aliases plugin's
4468    /// `alias opodr='oc …=''{…}'''` — which `zcompile` resolved with no
4469    /// literal quotes at all — re-lexed with two of them. The deparse
4470    /// spelling is by construction the DEFAULT-option spelling, so the option
4471    /// is cleared for the compile to restore C's "not lexed at all" property.
4472    ///
4473    /// It is cleared for the COMPILE ONLY. A `.zwc` that does `setopt
4474    /// rcquotes` (zsh-expand's plugin entry does, at its line 39) must still
4475    /// set the option for real, and that setting must outlive the source — so
4476    /// the previous value is restored before the chunk RUNS, not after. The
4477    /// same split applies to alias expansion: a function or `eval` body the
4478    /// program runs is lexed at RUNTIME and must see the live alias table.
4479    pub fn execute_zwc_program(&mut self, script: &str) -> Result<i32, String> {
4480        let chunk = {
4481            let _relex = ZwcRelexGuard::enter();
4482            self.compile_script_isolated(script)
4483        };
4484        self.run_chunk_with_exit_hooks(chunk?, "execute_zwc_program")
4485    }
4486
4487    /// Run `script` the way C runs a PLAIN sourced file: parse ONE event,
4488    /// execute it, parse the next — so lexer-time state that one line
4489    /// establishes is in force when the next line is lexed.
4490    ///
4491    /// c:Src/init.c:1618-1641 — `source()` has two arms. A file that was
4492    /// already compiled (`try_source_file` found a `.zwc`) runs whole, as one
4493    /// program: `execode(prog, 1, 0, "filecode")` (c:1621). A plain file runs
4494    /// through the per-command loop: `/* loop through the file to be sourced
4495    /// */ switch (loop(0, 0))` (c:1626-1627), whose body is `lexinit();
4496    /// parse_event(ENDINPUT); … execode(prog, 0, 0, "file")` (c:155-220).
4497    /// This is the second arm.
4498    ///
4499    /// The difference is observable whenever a line changes something the
4500    /// LEXER consults, because a whole-file compile lexes every line with the
4501    /// state the file STARTED with:
4502    ///
4503    /// ```text
4504    /// alias greet='print -r -- hello'   # takes effect at execution time
4505    /// greet                             # …but this line is lexed after it
4506    /// ```
4507    ///
4508    /// Same for `setopt rcquotes` (c:Src/lex.c:1326), `unsetopt aliases`, and
4509    /// a syntax error late in the file (C has already run the good lines).
4510    ///
4511    /// **Re-entrancy.** Every nested context — `$(source f)`, `` `source f` ``,
4512    /// `eval "source f"`, a pipe stage, a `( … )` subshell, a `source` inside
4513    /// a sourced file — reaches here through the normal builtin path, so this
4514    /// must be safe to enter while an outer instance of itself is parked
4515    /// mid-file. Two properties make it so, and both are deliberate:
4516    ///
4517    ///   * It never touches the shell's INPUT STACK. C's `source` points
4518    ///     `SHIN` at the file (c:1584) and lets `loop`'s `ingetc` pull from
4519    ///     it; doing that here would fight the outer reader for the one
4520    ///     global. Instead the file body is installed as the lexer's own
4521    ///     `LEX_INPUT` window under `strinbeg` — the exact parking
4522    ///     [`parse_isolated`] uses for a command-substitution body — and the
4523    ///     outer window is saved on the Rust stack and restored on the way
4524    ///     out. Nesting is then just stack discipline.
4525    ///   * It never dispatches through `execode`. `execode`
4526    ///     (`src/ported/exec.rs`) runs its program on the installed SESSION
4527    ///     executor, which is the right one only for the top-level REPL; from
4528    ///     inside a command substitution the live executor is the sub-VM that
4529    ///     owns the capture. Each event is compiled and run here on `self` —
4530    ///     the same executor `execute_script` would have used — via
4531    ///     [`Self::run_chunk`]. `$(source f)` therefore captures exactly what
4532    ///     `$(…)` captures from any other builtin.
4533    ///
4534    /// Returns the file's `$?`. `Err` only for a VM error, as
4535    /// [`Self::run_chunk`] reports it.
4536    pub fn execute_script_per_command(&mut self, script: &str) -> Result<i32, String> {
4537        // c:Src/init.c:1626-1627 — source() runs `loop(0, 0)`: not toplevel.
4538        self.run_events_per_command(script, None, false)
4539    }
4540
4541    /// The `loop()` body shared by a sourced file and a script file.
4542    ///
4543    /// `events`, when given, receives every executed event's lists, and is
4544    /// CLEARED unless the loop drained the input to a clean `ENDINPUT` — so a
4545    /// caller holding a non-empty collection after the call has the exact
4546    /// program the lexer produced, one event at a time, for the whole file.
4547    ///
4548    /// `toplevel` is loop()'s parameter of the same name: true only for the
4549    /// script file zsh_main runs (c:Src/init.c:1963 `loop(1, 0)`), false for
4550    /// `source` / `.` (c:1626-1627 `loop(0, 0)`). It gates the preexec hook
4551    /// (c:180).
4552    fn run_events_per_command(
4553        &mut self,
4554        script: &str,
4555        mut events: Option<&mut Vec<crate::parse::ZshList>>,
4556        toplevel: bool,
4557    ) -> Result<i32, String> {
4558        use crate::ported::lex::{
4559            tok, ENDINPUT, LEXERR, LEX_FILE_WINDOW_STRIN, LEX_INPUT, LEX_LINENO, LEX_POS,
4560            LEX_UNGET_BUF,
4561        };
4562
4563        // Inline Rust FFI blocks are rewritten before the lexer sees them,
4564        // as on every other source-string entry (see `parse_isolated`).
4565        let ffi_desugared = script
4566            .contains("rust")
4567            .then(|| crate::rust_ffi::desugar(script));
4568        let script: &str = ffi_desugared.as_deref().unwrap_or(script);
4569
4570        // c:Src/init.c:121 — `if (!toplevel) zcontext_save();`, plus the
4571        // zshrs-only lexer window that `zcontext` doesn't cover (identical
4572        // list to `parse_isolated`, which parks it for the same reason).
4573        crate::ported::context::zcontext_save(); // c:121
4574        let saved_input = LEX_INPUT.with_borrow(|s| s.clone());
4575        let saved_pos = LEX_POS.get();
4576        let saved_unget = LEX_UNGET_BUF.with_borrow(|b| b.clone());
4577        let saved_lineno = LEX_LINENO.get();
4578        let saved_in_lexstop = crate::ported::input::lexstop.with(|c| c.get());
4579        let saved_file_window = LEX_FILE_WINDOW_STRIN.get();
4580
4581        // `strin` makes a drained window report EOF instead of falling
4582        // through to `inputline()` and STEALING the outer reader's next line
4583        // (input.rs:391). Without it a sourced file's last event swallows the
4584        // caller's next stdin line.
4585        crate::ported::hist::strinbeg(0);
4586        // c:1588 — `lineno = 1;`. `lex_init` (called by `parse_init`)
4587        // installs the body as the window and resets the line counter.
4588        crate::ported::parse::parse_init(script);
4589        // C reads this file through `inputline` (c:Src/input.c:366) with
4590        // `strin == 0`: ONE LINE per buffer, which is what makes each line
4591        // its own event (c:Src/lex.c:310 / c:Src/parse.c:657), and every
4592        // newline counted (c:Src/input.c:330). zshrs installs the body as
4593        // one window under one `strinbeg`, so the file kind is declared
4594        // here, tagged with THIS strinbeg's depth — a nested string push
4595        // inside the file is deeper and stays a string.
4596        LEX_FILE_WINDOW_STRIN.set(crate::ported::input::strin.with(|s| s.get()));
4597
4598        // c:116 — `int err, non_empty = 0;`
4599        let mut non_empty = false;
4600        let mut vm_error: Option<String> = None;
4601        let mut drained = false;
4602
4603        loop {
4604            // c:155 — `lexinit();` Resets `tok` and BOTH `lexstop` copies;
4605            // it does NOT move the window, so the next event resumes where
4606            // the last one stopped.
4607            crate::ported::lex::lexinit(); // c:155
4608            // Event source for preexec's `$2`/`$3` — see run_preexec_hook.
4609            let event_mark = toplevel.then(crate::funcdef_capture::body_mark_begin);
4610            let prog = crate::ported::parse::parse_event(ENDINPUT); // c:156
4611            let event_src = event_mark.and_then(crate::funcdef_capture::event_text);
4612            let Some(prog) = prog else {
4613                // c:159-174 — no event this pass. Break on clean EOF or on a
4614                // parse error (`!toplevel` makes C's LEXERR arm
4615                // unconditional here); a bare separator just goes round
4616                // again, which is C's `continue` at c:174.
4617                let tok_v = tok(); // c:159
4618                let errflag_v = errflag.load(Ordering::Relaxed);
4619                if (tok_v == ENDINPUT && errflag_v == 0) || tok_v == LEXERR {
4620                    // c:159-162
4621                    drained = tok_v == ENDINPUT;
4622                    if tok_v == LEXERR
4623                        && crate::ported::builtin::LASTVAL.load(Ordering::Relaxed) == 0
4624                    {
4625                        crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:173
4626                    }
4627                    // !!! DASH-FAITHFUL GATE (no C counterpart) !!! a syntax error in a
4628                    // sourced file is `exraise(EXERROR)`: status 2 and the shell ends.
4629                    if tok_v == LEXERR
4630                        && crate::extensions::dash_mode::dash_faithful()
4631                        && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
4632                    {
4633                        std::process::exit(
4634                            crate::extensions::dash_mode::fatal_error_status().unwrap_or(2),
4635                        );
4636                    }
4637                    break;
4638                }
4639                if tok_v == ENDINPUT || errflag_v != 0 {
4640                    // Drained (or aborted) with a flag set — nothing left to
4641                    // read, so looping again would spin.
4642                    break;
4643                }
4644                continue; // c:174
4645            };
4646            non_empty = true; // c:179
4647            if toplevel {
4648                // callhookfunc dispatches through the live executor, which
4649                // is only installed while a chunk runs (see run_chunk).
4650                let _ctx = ExecutorContext::enter(self);
4651                run_preexec_hook(event_src.as_deref()); // c:180-215
4652            }
4653
4654            // c:220 — `execode(prog, 0, 0, "file")`. The eval-context entry
4655            // C's `execode` pushes for this arm is already on the stack:
4656            // `bin_dot` pushes it once around the whole file (builtin.rs), so
4657            // pushing it per event would report `file:file:file:…`.
4658            //
4659            // `LEX_LINENO` is saved across execution for the same reason
4660            // `loop()` does it (init.rs, c:Src/exec.c:1376/1640): each
4661            // statement's `SET_LINENO` overwrites the counter while it runs,
4662            // and the NEXT event must be lexed from the line the file is
4663            // really on.
4664            let chunk = crate::compile_zsh::ZshCompiler::new().compile(&prog);
4665            let saved_lex_lineno = LEX_LINENO.get();
4666            let run = self.run_chunk(chunk, "source");
4667            LEX_LINENO.set(saved_lex_lineno);
4668            if let Some(ev) = events.as_deref_mut() {
4669                ev.extend(prog.lists);
4670            }
4671            if let Err(e) = run {
4672                vm_error = Some(e);
4673                break;
4674            }
4675
4676            // c:234 — `if (((!interact || sourcelevel) && errflag) || retflag)
4677            // break;`. A sourced file always runs with `sourcelevel` bumped
4678            // (bin_dot, c:1606), so the errflag half is unconditional here.
4679            if (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0
4680                || crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) != 0
4681            {
4682                // c:Src/init.c:1960-1968 — after loop(1,0) breaks on a
4683                // runtime error, zsh_main re-enters it unless
4684                // `errflag && !interact && !isset(CONTINUEONERROR)`; the
4685                // re-entered loop's hbegin clears ERRFLAG_ERROR
4686                // (c:Src/hist.c:1115) before the next event. Only the script
4687                // file (toplevel) has that outer loop: `source` breaks
4688                // (c:234 `sourcelevel`), and a parse error never gets here
4689                // (the LEXERR arm above breaks first).
4690                if toplevel
4691                    && crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) == 0
4692                    && crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) == 0
4693                    && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
4694                    && crate::ported::zsh_h::isset(crate::ported::zsh_h::CONTINUEONERROR)
4695                {
4696                    errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed); // c:Src/hist.c:1115
4697                    continue;
4698                }
4699                break; // c:235
4700            }
4701            // C's `exit` inside a sourced file calls `zexit` → `realexit()`
4702            // and the process is gone, so C never reaches the next event.
4703            // zshrs defers the exit (EXIT_PENDING plus a jump to chunk end)
4704            // and lets the caller unwind, so the loop has to stop on its own.
4705            if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) != 0 {
4706                break;
4707            }
4708        }
4709
4710        // c:245 — `err = errflag;` is read BEFORE the context is restored,
4711        // because `zcontext_restore` → `parse_context_restore` ends with
4712        // `errflag &= ~ERRFLAG_ERROR` (c:Src/parse.c:354). C carries the
4713        // answer out as the `LOOP_ERROR` return value; zshrs's `bin_dot`
4714        // reads the flag itself (its c:1623-1624 + c:1663 block), so the bit
4715        // is put back after the restore instead.
4716        let err = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0; // c:245
4717        if let Some(ev) = events {
4718            if !drained || err || vm_error.is_some() {
4719                ev.clear();
4720            }
4721        }
4722
4723        // c:246-249 — leave the loop's context exactly as it was found.
4724        crate::ported::hist::strinend();
4725        LEX_INPUT.with_borrow_mut(|s| *s = saved_input);
4726        LEX_POS.set(saved_pos);
4727        LEX_UNGET_BUF.with_borrow_mut(|b| *b = saved_unget);
4728        LEX_LINENO.set(saved_lineno);
4729        crate::ported::input::lexstop.with(|c| c.set(saved_in_lexstop));
4730        LEX_FILE_WINDOW_STRIN.set(saved_file_window);
4731        crate::ported::context::zcontext_restore(); // c:247
4732        if err {
4733            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed); // see the c:245 note
4734        }
4735
4736        if let Some(e) = vm_error {
4737            return Err(e);
4738        }
4739        // c:1633-1636 — `case LOOP_EMPTY: /* Empty code resets status */
4740        // lastval = 0;`. `source /dev/null` (or a comments-only file) clears
4741        // `$?` rather than leaving the caller's.
4742        if !non_empty {
4743            self.set_last_status(0); // c:1635
4744        }
4745        Ok(self.last_status())
4746    }
4747
4748    /// Install an autoloaded function by running its definition program,
4749    /// reusing the rkyv-cached chunk when the cache can PROVE the chunk
4750    /// was compiled from this same definition text by this same binary.
4751    ///
4752    /// `registered` is what `autoload_register_source` produced: either
4753    /// `name() { <file body> }` or, for a file that already contains the
4754    /// definition, the body verbatim. Running it installs the function;
4755    /// the compiled chunk for it is exactly what the cache stores, so a hit
4756    /// skips lex+parse+compile of the whole file. For `_git` that is 424 KB
4757    /// of shell — the dominant cost of the first `git <tab>`.
4758    ///
4759    /// Two conditions gate caching, because outside them the chunk is not a
4760    /// function of the definition text alone:
4761    ///   * ksh-style autoload (`KSHAUTOLOAD` / `PM_KSHSTORED`) runs the file
4762    ///     at top level instead of wrapping it, so the same bytes produce a
4763    ///     different program depending on a runtime option;
4764    ///   * without `PM_UNALIASED` (`autoload` without `-U`) the body is
4765    ///     parsed WITH alias expansion, so the chunk depends on the alias
4766    ///     table too. Every compsys / plugin autoload uses `-Uz`.
4767    ///
4768    /// A hit that runs without defining `name` is treated as a corrupt
4769    /// entry, not as a failed load: the entry is dropped and the real
4770    /// source compiled. Installing a function is the one thing this
4771    /// function exists to do, so "it ran and the function is not there"
4772    /// is a fact the loader can check for itself rather than leaving the
4773    /// caller to report `function not defined by file` for what is
4774    /// actually a bad cache line.
4775    ///
4776    /// `from_wordcode` says the text is a `.zwc` deparse rather than file
4777    /// bytes. C never re-lexes such a body at all — `shf->funcdef =
4778    /// stripkshdef(prog, …)` (c:Src/exec.c:5753-5755) installs the dump's own
4779    /// wordcode and the ksh arm runs it with `execode(prog, 1, 0,
4780    /// "evalautofunc")` (c:5795) — so the compile here, which is zshrs's
4781    /// stand-in for that install, runs under [`ZwcRelexGuard`]. The guard
4782    /// covers the COMPILE only: a ksh-style body executes real user code, and
4783    /// an `eval` or function body IT reaches is lexed at runtime against the
4784    /// live alias table and the live RCQUOTES, exactly as in C.
4785    fn run_autoload_definition(
4786        &mut self,
4787        name: &str,
4788        registered: &str,
4789        ksh_style: bool,
4790        from_wordcode: bool,
4791        parse_failed: bool,
4792    ) -> Result<i32, String> {
4793        let unaliased = crate::ported::utils::getshfunc(name)
4794            .map(|f| (f.node.flags as u32 & crate::ported::zsh_h::PM_UNALIASED) != 0)
4795            .unwrap_or(false);
4796        let key = if ksh_style || !unaliased {
4797            None
4798        } else {
4799            autoload_source_key(name, registered, from_wordcode)
4800        };
4801        if let Some((dir, sha)) = key.as_ref() {
4802            if let Some(blob) = crate::autoload_cache::try_load_for_source(name, dir, sha) {
4803                match bincode::deserialize::<fusevm::Chunk>(&blob) {
4804                    Ok(chunk) if !chunk.ops.is_empty() => {
4805                        tracing::debug!(
4806                            name,
4807                            ops = chunk.ops.len(),
4808                            "autoload: rkyv chunk hit, skipping parse+compile"
4809                        );
4810                        let status = self.run_chunk(chunk, "autoload:cached");
4811                        if self.functions_compiled.contains_key(name) {
4812                            return status;
4813                        }
4814                        // The chunk ran and `name` is still undefined, so
4815                        // it is not this function's definition program
4816                        // whatever the key said. Drop it and fall through
4817                        // to a real compile — a wrong answer here costs
4818                        // every completion on the shell.
4819                        tracing::warn!(
4820                            name,
4821                            "autoload: cached chunk did not define the function; \
4822                             dropping the entry and recompiling"
4823                        );
4824                        crate::autoload_cache::try_remove(name);
4825                    }
4826                    _ => {}
4827                }
4828            }
4829        }
4830        let chunk = {
4831            // c:Src/exec.c:5753-5755 / c:5795 — the dump's wordcode is
4832            // installed/executed as it stands, so nothing about the live
4833            // option or alias state can reach it. zshrs lexes the deparse
4834            // instead; pin the lexer to the spelling the deparse was written
4835            // in for the duration of that compile, and only that compile.
4836            let _relex = from_wordcode.then(ZwcRelexGuard::enter);
4837            // c:Src/exec.c:6264-6266 / c:5726 — C parses the file ONCE and a
4838            // failure ends the load (`return NULL`), so exactly one diagnostic
4839            // reaches the terminal. zshrs parsed the raw body in
4840            // `autoload_definition_source` and parses the WRAPPED text again
4841            // here, so a body zsh rejects printed the error twice — the second
4842            // time shifted one line by the wrap's leading `name() {`. The
4843            // first pass already reported it against the loadee's name; mute
4844            // this one. `noerrs` still sets ERRFLAG_ERROR (c:Src/utils.c:175),
4845            // which is what `compile_script_isolated` reads, so the load still
4846            // fails.
4847            let _muted = parse_failed.then(ZerrMute::enter);
4848            self.compile_script_isolated(registered)?
4849        };
4850        if let Some((dir, sha)) = key.as_ref() {
4851            match bincode::serialize(&chunk) {
4852                Ok(blob) => {
4853                    if let Err(e) = crate::autoload_cache::try_save_one(name, &blob, dir, *sha) {
4854                        tracing::warn!(name, error = %e, "autoload: rkyv chunk save failed");
4855                    }
4856                }
4857                Err(e) => tracing::warn!(name, error = %e, "autoload: chunk serialize failed"),
4858            }
4859        }
4860        // c:Src/exec.c:5807-5816 — installing the definition is a funcdef
4861        // store (`execode(…, "evalautofunc")` for ksh style); nothing on that
4862        // path reaches the shell-exit hooks. Running the definition through
4863        // `run_chunk_with_exit_hooks` fired the EXIT trap, `TRAPEXIT` and
4864        // `zshexit` on the first call of EVERY autoloaded function (and
4865        // consumed a string EXIT trap so it never ran at exit): `trap 'echo T'
4866        // EXIT; autoload -Uz is-at-least; is-at-least 1.0` printed T first.
4867        self.run_chunk(chunk, "autoload:compiled")
4868    }
4869
4870    /// `execute_script` — see implementation.
4871    #[tracing::instrument(skip(self, script), fields(len = script.len()))]
4872    pub fn execute_script(&mut self, script: &str) -> Result<i32, String> {
4873        // lex+parse free ported + ZshCompiler is the only execution path.
4874        self.execute_script_zsh_pipeline(script)
4875    }
4876
4877    /// Run `script` with stdout AND stderr captured, returning `(exit status,
4878    /// output)` — the entry point for an embedder that owns the terminal (a
4879    /// TUI), where a stray `echo` corrupts the display.
4880    ///
4881    /// A shell cannot capture its output into an in-process buffer the way a
4882    /// single-runtime language can: a forked child writes fd 1 directly and
4883    /// knows nothing about the parent's buffers. The capture is therefore at fd
4884    /// level, and it differs from `$(…)` in the one way that matters to an
4885    /// embedder: [`Self::run_command_substitution`] runs on a sub-VM, as a
4886    /// subshell must, so a variable it sets is gone afterwards. This runs the
4887    /// script on THIS VM, so state persists across captured runs exactly as it
4888    /// does across ordinary [`Self::execute_script`] calls.
4889    ///
4890    /// The saved fds go through `movefd` to land at fd >= 10 and marked
4891    /// `FDT_INTERNAL`, per zsh's invariant that shell-internal fds never live
4892    /// below 10 — otherwise a script doing `exec 9>&-` closes the capture's own
4893    /// bookkeeping. A temp file, not a pipe, receives the output: with no
4894    /// concurrent reader, a pipe deadlocks the moment a script writes past the
4895    /// 64 KiB buffer.
4896    ///
4897    /// # Concurrency contract
4898    ///
4899    /// **While a capture is in flight, no other thread in the process may write
4900    /// fd 1 or fd 2.** POSIX has no per-thread fd table, so pointing fd 1 at the
4901    /// capture points it there for every thread at once; any byte another thread
4902    /// writes during the window lands in the returned `String` instead of on the
4903    /// terminal. The `CAPTURE_LOCK` below excludes a second *capture*, which is
4904    /// all a lock can do — a thread that never calls this function (a logger, a
4905    /// progress meter, a test harness's own reporter) is not excluded by
4906    /// anything, and its output is silently absorbed.
4907    ///
4908    /// This is not a gap that a different capture mechanism closes. C zsh dodges
4909    /// it for `$(…)` by forking: `getoutput` (`Src/exec.c:4816`) calls `zfork`
4910    /// and only the child does `redup(pipes[1], 1)` (`Src/exec.c:4837`), so the
4911    /// parent's fd 1 is never touched — but the child then runs `entersubsh`
4912    /// (`Src/exec.c:4838`) and a variable it sets is gone. Forking here would
4913    /// throw away the one property this call exists to provide (state persists
4914    /// on THIS VM across captured runs), so the cost is paid as a contract
4915    /// instead: **capture from one thread, and quiesce the rest.**
4916    pub fn execute_script_captured(&mut self, script: &str) -> (i32, String) {
4917        use std::io::{Read, Seek, SeekFrom};
4918        use std::os::unix::io::AsRawFd;
4919
4920        /// Serializes the redirect/restore window. fd 1 belongs to the process,
4921        /// not to a `ShellExecutor`, so two threads capturing at once would
4922        /// restore each other's fds mid-run and each would read back an empty
4923        /// file. An embedder that evaluates on one thread never contends here.
4924        /// It excludes another *capture* and nothing else: see the concurrency
4925        /// contract on this function for what remains the caller's problem.
4926        static CAPTURE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
4927        let _guard = CAPTURE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
4928
4929        let Ok(mut tmp) = tempfile::tempfile() else {
4930            // No temp file, no capture: run it anyway rather than silently
4931            // dropping the script, and report nothing captured.
4932            let status = self.execute_script(script).unwrap_or(1);
4933            return (status, String::new());
4934        };
4935
4936        // Flush Rust's buffered stdout against the REAL fd 1 before the swap,
4937        // or bytes written before this call drain into the capture instead
4938        // (the same ordering bug `run_command_substitution` documents).
4939        let _ = io::stdout().flush();
4940
4941        /// Puts fds 1 and 2 back on the way out, including when the run
4942        /// unwinds. A panic anywhere under `execute_script` would otherwise
4943        /// leave the whole PROCESS writing into a temp file that is already
4944        /// unlinked — every later write vanishes, starting with the one
4945        /// reporting the panic, which turns a localized bug into a silent one.
4946        struct RestoreFds {
4947            saved_out: i32,
4948            saved_err: i32,
4949        }
4950        impl Drop for RestoreFds {
4951            fn drop(&mut self) {
4952                let _ = io::stdout().flush();
4953                unsafe {
4954                    libc::dup2(self.saved_out, libc::STDOUT_FILENO);
4955                    libc::dup2(self.saved_err, libc::STDERR_FILENO);
4956                }
4957                crate::ported::utils::zclose(self.saved_out);
4958                crate::ported::utils::zclose(self.saved_err);
4959            }
4960        }
4961
4962        let saved_out = crate::ported::utils::movefd(unsafe { libc::dup(libc::STDOUT_FILENO) });
4963        let saved_err = crate::ported::utils::movefd(unsafe { libc::dup(libc::STDERR_FILENO) });
4964        unsafe {
4965            libc::dup2(tmp.as_raw_fd(), libc::STDOUT_FILENO);
4966            libc::dup2(tmp.as_raw_fd(), libc::STDERR_FILENO);
4967        }
4968        let restore = RestoreFds {
4969            saved_out,
4970            saved_err,
4971        };
4972
4973        let status = self.execute_script(script);
4974
4975        // Explicit, not end-of-scope: the temp file must be read back only
4976        // after the real fds are restored, or a diagnostic emitted while
4977        // reading would land in the very buffer being read.
4978        drop(restore);
4979
4980        let mut output = String::new();
4981        let _ = tmp.seek(SeekFrom::Start(0));
4982        let mut bytes = Vec::new();
4983        if tmp.read_to_end(&mut bytes).is_ok() {
4984            // Lossless for the same reason as the `$( cmd )` capture above:
4985            // a captured byte that is not valid UTF-8 is a byte the script
4986            // really wrote, not an encoding error to paper over.
4987            output = crate::script_bytes::decode_script_bytes(&bytes);
4988        }
4989        // Match `$(…)`: one trailing newline is an artifact of the last `echo`,
4990        // not part of the output.
4991        while output.ends_with('\n') {
4992            output.pop();
4993        }
4994
4995        (status.unwrap_or_else(|_| self.last_status()), output)
4996    }
4997
4998    /// Run an ALREADY-PARSED program (the back half of
4999    /// `execute_script_zsh_pipeline`): compile the `ZshProgram` to a
5000    /// fusevm Chunk and run it. Used by the ported `loop()` REPL
5001    /// (Src/init.c:220 `execode`), which parses via `parse_event` and
5002    /// hands the program here through the `execute_program` exec hook.
5003    /// Returns the resulting `$?` (1 on a compile/run error).
5004    pub fn execute_program(&mut self, program: &crate::parse::ZshProgram) -> i32 {
5005        let chunk = crate::compile_zsh::ZshCompiler::new().compile(program);
5006        match self.run_chunk(chunk, "loop") {
5007            Ok(status) => status,
5008            Err(_) => 1,
5009        }
5010    }
5011
5012    /// Whether `name` is a known function. Checks the compiled-functions
5013    /// table and the autoload-pending registry — `autoload foo` should
5014    /// make `whence foo`/`type foo`/`functions foo` recognize `foo` as
5015    /// a function before it's actually loaded. Doesn't trigger autoload
5016    /// itself; use `maybe_autoload` first if you need to load before
5017    /// introspecting.
5018    pub fn function_exists(&self, name: &str) -> bool {
5019        // Either compiled (already loaded) or shfunctab has an
5020        // autoload stub with PM_UNDEFINED set (pending). Matches C's
5021        // `lookupshfunc(name)` semantics at `Src/exec.c:5215`.
5022        if self.functions_compiled.contains_key(name) {
5023            return true;
5024        }
5025        crate::ported::hashtable::shfunctab_lock()
5026            .read()
5027            .ok()
5028            .map(|t| t.get(name).is_some())
5029            .unwrap_or(false)
5030    }
5031
5032    /// Sorted list of every known function name (union of compiled + source).
5033    pub fn function_names(&self) -> Vec<String> {
5034        let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
5035        for k in self.functions_compiled.keys() {
5036            set.insert(k.clone());
5037        }
5038        for k in self.function_source.keys() {
5039            set.insert(k.clone());
5040        }
5041        set.into_iter().collect()
5042    }
5043
5044    /// Dispatch a function by name. Thin passthru — autoload-materialize
5045    /// the body if needed, build a synthetic `shfunc`, and hand off to
5046    /// the canonical `doshfunc` port (`Src/exec.c:5823` →
5047    /// `src/ported/exec.rs::doshfunc`). doshfunc owns ALL scope
5048    /// management (starttrapscope/endtrapscope, startparamscope/
5049    /// endparamscope, funcdepth bump, pipestats save/restore, scriptname
5050    /// snapshot, BREAKS/CONTFLAG/LOOPS/RETFLAG snapshot+restore, `$0`
5051    /// override via FUNCTIONARGZERO, etc.). The body run itself is the
5052    /// Rust-only adaptation passed via the `body_runner` closure because
5053    /// zshrs runs function bodies through fusevm bytecode (not C zsh's
5054    /// wordcode walker via `runshfunc`).
5055    ///
5056    /// Returns `None` when the name isn't a known function so the caller
5057    /// can fall through to external dispatch.
5058    /// Body-only counterpart to [`dispatch_function_call`] — runs
5059    /// the function body WITHOUT wrapping in `doshfunc`. Used as the
5060    /// `body_runner` closure target by `src/ported/` callers that
5061    /// already wrap their own `crate::ported::exec::doshfunc(...)`
5062    /// call (so going back through `dispatch_function_call` would
5063    /// double-wrap the scope). Mirrors C's `runshfunc(prog, wrappers,
5064    /// name)` at `exec.c:6042` from doshfunc's perspective.
5065    pub fn run_function_body_only(&mut self, name: &str, args: &[String]) -> Option<i32> {
5066        // Held for the WHOLE call, not just the load: an autoloaded function is
5067        // registered TWICE — once when its file's text defines it, and again
5068        // (unchanged) when its chunk is compiled at call time — and the second
5069        // stamp would otherwise relabel it with the caller's scriptfilename.
5070        // See the AUTOLOAD_DEF_FILE consumer in fusevm_bridge.
5071        let mut _autoload_file_guard: Option<AutoloadFileGuard> = None;
5072        // Same Rust-port short-circuit as dispatch_function_call,
5073        // sans the doshfunc wrap.
5074        //
5075        // c:Src/exec.c:3760-3763 — `if (errflag) { lastval = 1; goto err; }`,
5076        // the gate execcmd_exec applies AFTER argument expansion and BEFORE it
5077        // invokes anything. A shell function whose argument list raised errflag
5078        // (`_describe -t t "${(j:,:)${(s: :)x y z}}" d` — a bad substitution)
5079        // is therefore never entered. zshrs's ordinary function path honours
5080        // that, but the compsys router short-circuits above it, so a NATIVE
5081        // PORT ran where the stock shell function did not and the completion
5082        // listed matches zsh never offered. Gate the short-circuit on the same
5083        // condition; the ordinary path below is left alone, since it already
5084        // aborts upstream.
5085        if (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0 {
5086            crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:3761
5087            return Some(1); // c:3762 goto err
5088        }
5089        if let Some(rc) = crate::compsys::router::dispatch_compsys(name, args) {
5090            // Plugin override (ABI v4) wins over the built-in Rust port.
5091            return Some(rc);
5092        }
5093        // Bug #657 gap #2 — `_regex_arguments`-generated completion functions
5094        // live in a runtime registry, not the static router table (a plain
5095        // `fn` ptr can't carry the dynamic name). Consult that registry here
5096        // so `compdef mycmd` → `_comps[cmd]=mycmd` → this call routes to the
5097        // compiled regex state machine.
5098        if let Some(rc) = crate::compsys::ported::_regex_arguments::dispatch_if_registered(name) {
5099            return Some(rc);
5100        }
5101        // c:Src/exec.c:5626 — see the twin site in
5102        // `dispatch_function_call`: a body loaded on THIS call runs one
5103        // `zsh_eval_context` frame deeper ("loadautofunc") than the
5104        // caller's "shfunc".
5105        let mut did_autoload = false;
5106        // Autoload prelude (same as dispatch_function_call's).
5107        if !self.functions_compiled.contains_key(name) {
5108            // On-demand $fpath autoload for `_`-prefixed compsys helpers that
5109            // compinit didn't register as autoload stubs — see the fuller
5110            // note in dispatch_function_call.
5111            if name.starts_with('_') && crate::ported::utils::getshfunc(name).is_none() {
5112                // c:6219 getfpfunc — gate the stub on the definition file
5113                // actually existing in $fpath, mirroring zsh's `compdef -na`
5114                // (only autoloads `_`-names present in fpath). Without this a
5115                // `_`-name with no file (e.g. a fasd completer trigger absent
5116                // from this fpath) got a phantom PM_UNDEFINED stub, and
5117                // loadautofn then leaked "function definition file not found"
5118                // to the terminal during completion. test_only=1 is a pure
5119                // probe; dump_out is preserved so .zwc-dump autoloads resolve.
5120                let mut _dir: Option<String> = None;
5121                let mut _dump = None;
5122                if crate::ported::exec::getfpfunc(name, &mut _dir, None, 1, &mut _dump).is_some() {
5123                    let _ = self.execute_script_zsh_pipeline(&format!("autoload -rUz -- {name}"));
5124                }
5125            }
5126            if let Some(stub) = crate::ported::utils::getshfunc(name) {
5127                // c:Src/exec.c:5684-5704 (loadautofn) —
5128                //     int noalias = noaliases;
5129                //     noaliases = (shf->node.flags & PM_UNALIASED);
5130                //     prog = getfpfunc(...);        /* parses the file */
5131                //     noaliases = noalias;
5132                // `autoload -U` records PM_UNALIASED (c:3354-3357), and its ONLY
5133                // effect is that the autoloaded body is PARSED with alias
5134                // expansion disabled. zshrs recorded the bit but never consulted
5135                // it, so a body calling `helper` picked up a caller-defined
5136                // `alias helper=...` — exactly what -U exists to prevent.
5137                let unaliased = (stub.node.flags as u32 & crate::ported::zsh_h::PM_UNALIASED) != 0;
5138                let noalias_save = crate::ported::lex::noaliases(); // c:5684
5139                crate::ported::lex::set_noaliases(unaliased); // c:5697
5140                let _restore_noaliases = NoAliasesRestore(noalias_save); // c:5704
5141                if (stub.node.flags as u32 & PM_UNDEFINED) != 0 {
5142                    did_autoload = true; // c:5626 — body runs as "loadautofunc"
5143                    let _sticky_load = AutoloadStickyGuard::enter(&stub);
5144                    let boxed = Box::new(stub.clone());
5145                    let ptr = Box::into_raw(boxed);
5146                    let load_rc = crate::ported::exec::loadautofn(ptr, 0, 0, 0);
5147                    unsafe {
5148                        let _ = Box::from_raw(ptr);
5149                    }
5150                    // c:Src/exec.c:5713-5719 — `if (prog == &dummy_eprog) {
5151                    //     zwarn("%s: function definition file not found",
5152                    //           shf->node.nam); … return NULL; }`, and
5153                    // c:5635-5644 execautofn: `if (!loadautofn(...)) return 1;`
5154                    // A failed load is TERMINAL: C has already replaced
5155                    // shf->funcdef with the mkautofn trampoline (c:3180), so
5156                    // nothing of the old stub body survives to be re-run.
5157                    // zshrs keeps the stub's TEXT on the shfunc node, and the
5158                    // `if let Some(body)` arm below would hand that text back
5159                    // to run_autoload_definition — re-executing the very
5160                    // `autoload -X` that triggered this load. `cod() {
5161                    // autoload -XUz }; cod` recursed until FUNCNEST and
5162                    // printed the diagnostic 500 times (C04funcdef:38,39,40).
5163                    if load_rc != 0 {
5164                        return Some(1); // c:5719 NULL → c:5644 `return 1`
5165                    }
5166                    // c:5657 — preserve the fpath dir + PM_LOADDIR across the
5167                    // funcdef re-register (which stamps filename="zsh"), so
5168                    // `whence -v` reports the source. See the twin site below.
5169                    let loaded_dir = crate::ported::utils::getshfunc(name)
5170                        .and_then(|f| f.filename)
5171                        .filter(|d| d != "zsh");
5172                    // c:Src/exec.c:5682-5760 — C loads the body IN PLACE on the
5173                    // existing Shfunc, so every stub flag except PM_UNDEFINED
5174                    // survives. See the twin site below for why PM_ABSPATH_USED
5175                    // in particular has to come back.
5176                    let abspath_used =
5177                        (stub.node.flags as u32 & crate::ported::zsh_h::PM_ABSPATH_USED) != 0;
5178                    let ksh_style = autoload_is_ksh_style(name); // c:5781 (pre-registration)
5179                    if let Some(body) = crate::ported::utils::getshfunc(name).and_then(|f| f.body) {
5180                        _autoload_file_guard = Some(AutoloadFileGuard::enter(name));
5181                        let AutoloadSource {
5182                            text: registered,
5183                            from_wordcode,
5184                            parse_failed,
5185                        } = autoload_register_source(name, &body);
5186                        {
5187                            // c:Src/exec.c:5735-5760 — C INSTALLS the parsed Eprog
5188                            // as the function body; it executes nothing at load
5189                            // time, so the global `lineno` still holds the line the
5190                            // CALL was made on when doshfunc records
5191                            // `funcsave->fstack.lineno = lineno` (c:6013). zshrs
5192                            // installs the body by RUNNING `name() { … }` through
5193                            // the pipeline, which walks the counter to the file's
5194                            // last line — so the very first call of an autoloaded
5195                            // function reported its caller's line as that instead:
5196                            // `$functrace` read `script.zsh:1` where zsh reads
5197                            // `script.zsh:4`, and inside completion `_subscript:0`
5198                            // where zsh reads `_subscript:125`. Every LATER call
5199                            // was already correct, because the load only happens
5200                            // once.
5201                            let caller_lineno = crate::ported::lex::lineno();
5202                            // c:5384-5388 assigns `shf->lineno` only when a
5203                            // `name() { … }` STATEMENT defines the function. An
5204                            // autoload stub's Shfunc keeps the 0 it was created
5205                            // with, and loadautofn replaces only `funcdef`, so zsh
5206                            // reports `funcsourcetrace` as `<file>:0`. Running a
5207                            // synthesized wrapper here stamps line 1 instead, so
5208                            // put the stub's value back when the wrapper was ours.
5209                            let synthesized = registered != body;
5210                            let _ = self.run_autoload_definition(
5211                                name,
5212                                &registered,
5213                                ksh_style,
5214                                from_wordcode,
5215                                parse_failed,
5216                            );
5217                            crate::ported::lex::set_lineno(caller_lineno);
5218                            if synthesized {
5219                                // c:5384-5388 sets `shf->lineno` only where a
5220                                // `name() { … }` STATEMENT defines the function; an
5221                                // autoload stub keeps the 0 it was created with and
5222                                // loadautofn replaces only `funcdef`, so
5223                                // `funcsourcetrace` reads `<file>:0`. Executing our
5224                                // synthesized wrapper records a line base of 1
5225                                // instead. -1 marks "autoload-installed" so the
5226                                // call-time clamp below can tell that apart from an
5227                                // INLINE `f() { … }`, whose base underflows to 0 but
5228                                // whose def line really is >= 1.
5229                                self.function_line_base.insert(name.to_string(), -1);
5230                            }
5231                        }
5232                    }
5233                    if let Some(dir) = loaded_dir.as_deref() {
5234                        restore_loaddir(name, dir, abspath_used, ksh_style);
5235                    }
5236                } else if let Some(body) = stub.body.clone() {
5237                    // c:Src/exec.c:5978 — the body runs under the stub's sticky
5238                    // emulation; see AutoloadStickyGuard.
5239                    let _sticky_load = AutoloadStickyGuard::enter(&stub);
5240                    // c:Src/builtin.c:3180 (eval_autoload) — `autoload +X NAME`
5241                    // loads the body EAGERLY through `loadautofn`, which sets
5242                    // `body` + `filename`/PM_LOADDIR and clears PM_UNDEFINED
5243                    // but leaves no compiled chunk behind. The first CALL of
5244                    // such a function therefore lands in THIS arm, never the
5245                    // PM_UNDEFINED arm above, so it needs the same
5246                    // post-registration restore — otherwise `autoload +X
5247                    // /abs/dir/NAME` lost the pair before the body ran and a
5248                    // sibling `autoload -Uz SIB` inside it failed with
5249                    // "function definition file not found" where zsh loads
5250                    // /abs/dir/SIB. `functions[name]=body` (parameter.c
5251                    // setpmfunction) reaches this arm too and simply has no
5252                    // PM_LOADDIR, so the restore is skipped for it.
5253                    let loaded_dir = ((stub.node.flags as u32 & crate::ported::zsh_h::PM_LOADDIR)
5254                        != 0)
5255                        .then(|| stub.filename.clone())
5256                        .flatten();
5257                    let abspath_used =
5258                        (stub.node.flags as u32 & crate::ported::zsh_h::PM_ABSPATH_USED) != 0;
5259                    // c:Src/Modules/parameter.c:295-315 — a body that arrived
5260                    // through `functions[NAME]=BODY` is NOT a file. See
5261                    // `body_came_from_param_assignment`.
5262                    let param_body = body_came_from_param_assignment(stub.node.flags);
5263                    let ksh_style = !param_body && autoload_is_ksh_style(name); // c:5781 (pre-registration)
5264                    _autoload_file_guard = Some(AutoloadFileGuard::enter(name));
5265                    // A `functions[name]=body` assignment is already a parsed
5266                    // body, so it takes no file parse and cannot have reported
5267                    // one (`parse_failed` is false by construction).
5268                    let (registered, from_wordcode, parse_failed) = if param_body {
5269                        (param_definition_source(name, &body), false, false)
5270                    } else {
5271                        let AutoloadSource {
5272                            text,
5273                            from_wordcode,
5274                            parse_failed,
5275                        } = autoload_register_source(name, &body);
5276                        (text, from_wordcode, parse_failed)
5277                    };
5278                    {
5279                        // c:Src/exec.c:5735-5760 — C INSTALLS the parsed Eprog
5280                        // as the function body; it executes nothing at load
5281                        // time, so the global `lineno` still holds the line the
5282                        // CALL was made on when doshfunc records
5283                        // `funcsave->fstack.lineno = lineno` (c:6013). zshrs
5284                        // installs the body by RUNNING `name() { … }` through
5285                        // the pipeline, which walks the counter to the file's
5286                        // last line — so the very first call of an autoloaded
5287                        // function reported its caller's line as that instead:
5288                        // `$functrace` read `script.zsh:1` where zsh reads
5289                        // `script.zsh:4`, and inside completion `_subscript:0`
5290                        // where zsh reads `_subscript:125`. Every LATER call
5291                        // was already correct, because the load only happens
5292                        // once.
5293                        let caller_lineno = crate::ported::lex::lineno();
5294                        // c:5384-5388 assigns `shf->lineno` only when a
5295                        // `name() { … }` STATEMENT defines the function. An
5296                        // autoload stub's Shfunc keeps the 0 it was created
5297                        // with, and loadautofn replaces only `funcdef`, so zsh
5298                        // reports `funcsourcetrace` as `<file>:0`. Running a
5299                        // synthesized wrapper here stamps line 1 instead, so
5300                        // put the stub's value back when the wrapper was ours.
5301                        let synthesized = registered != body;
5302                        let _ = self.run_autoload_definition(
5303                            name,
5304                            &registered,
5305                            ksh_style,
5306                            from_wordcode,
5307                            parse_failed,
5308                        );
5309                        crate::ported::lex::set_lineno(caller_lineno);
5310                        if synthesized {
5311                            // c:5384-5388 sets `shf->lineno` only where a
5312                            // `name() { … }` STATEMENT defines the function; an
5313                            // autoload stub keeps the 0 it was created with and
5314                            // loadautofn replaces only `funcdef`, so
5315                            // `funcsourcetrace` reads `<file>:0`. Executing our
5316                            // synthesized wrapper records a line base of 1
5317                            // instead. -1 marks "autoload-installed" so the
5318                            // call-time clamp below can tell that apart from an
5319                            // INLINE `f() { … }`, whose base underflows to 0 but
5320                            // whose def line really is >= 1.
5321                            self.function_line_base.insert(name.to_string(), -1);
5322                        }
5323                    }
5324                    if let Some(dir) = loaded_dir.as_deref() {
5325                        restore_loaddir(name, dir, abspath_used, ksh_style);
5326                    }
5327                }
5328            }
5329        }
5330        let chunk = self.functions_compiled.get(name).cloned()?;
5331        // c:5626 — `execode(shf->funcdef, 1, 0, "loadautofunc")`. Held
5332        // across the body run and dropped with the VM below.
5333        let _load_ctx =
5334            did_autoload.then(|| crate::ported::exec::EvalContextFrame::push("loadautofunc"));
5335        // c:Src/exec.c:5551-5557 (zsh-5.9.1, execautofn_basic) — the freshly
5336        // loaded body runs with `scriptname = shf->node.nam` and
5337        // `scriptfilename = getshfuncfile(shf)`, restored afterwards.
5338        // `doshfunc` skips its own `scriptname = name` for a PM_UNDEFINED
5339        // function (c:5835-5836), so without this every diagnostic in a body
5340        // autoloaded here kept the CALLER's name: `_complete:117:` printed as
5341        // `_main_complete:117:`. `lineno` is zeroed for the body here rather
5342        // than in `doshfunc` (see the PM_UNDEFINED note there), because the
5343        // load above must still see the caller's line.
5344        let autofn_saved = did_autoload.then(|| {
5345            let saved = (
5346                crate::ported::utils::scriptname_get(),
5347                crate::ported::utils::scriptfilename_get(),
5348            );
5349            crate::ported::utils::set_scriptname(Some(name.to_string())); // c:5553
5350            crate::ported::utils::set_scriptfilename(crate::ported::hashtable::getshfuncfile(
5351                name,
5352            )); // c:5554
5353            crate::ported::lex::set_lineno(0);
5354            saved
5355        });
5356        let seed_status = self.last_status();
5357        let _ = args; // fusevm body reads $1..$N from PPARAMS
5358                      // Reuse a VM from the per-thread pool instead of building one from
5359                      // scratch every call. `register_builtins` installs ~hundreds of
5360                      // fn-pointer handlers into the VM's builtin_table; the table is
5361                      // identical for every VM, so re-running it per function call was
5362                      // pure waste (~130 profile samples in a tight call loop, the #2 hot
5363                      // spot after option lookups). `VM::reset(chunk)` clears execution
5364                      // state but PRESERVES builtin_table / host / JIT wiring, so a
5365                      // recycled VM is call-ready without re-registration. Fresh VMs pay
5366                      // the registration once. Nested calls simply check out additional
5367                      // VMs; the pool grows to the max call depth. Re-entrant and
5368                      // panic-safe: the VM is returned on the normal path below.
5369                      // c:Src/exec.c:4364 — a `return` out of a redirected compound
5370                      // command still runs `fixfds(save)`. See
5371                      // `unwind_redirect_scopes_to`.
5372        let redir_depth = self.redirect_scope_stack.len();
5373        // A `return` past an open pipeline leaves its job slot held; see
5374        // exec_jobs::execpline_slots_mark.
5375        let slots_entry = crate::exec_jobs::execpline_slots_mark();
5376        let mut vm = crate::vm_pool::acquire(chunk);
5377        vm.last_status = seed_status;
5378        let _ = vm.run();
5379        let status = vm.last_status;
5380        drop(vm);
5381        crate::exec_jobs::execpline_slots_release_to(slots_entry);
5382        self.unwind_redirect_scopes_to(redir_depth);
5383        if let Some((sn, sfn)) = autofn_saved {
5384            crate::ported::utils::set_scriptname(sn); // c:5556
5385            crate::ported::utils::set_scriptfilename(sfn); // c:5557
5386        }
5387        Some(status)
5388    }
5389
5390    /// Run a shell function, growing the stack first when little is left.
5391    ///
5392    /// Each nested shell-function call costs thousands of bytes of native stack,
5393    /// and Linux gives the main thread the 8 MB `ulimit -s` (macOS honours the
5394    /// larger `-stack_size` link flag, see build.rs). FUNCNEST allows 500 levels,
5395    /// so a runaway or merely deep recursion overflowed the stack before the
5396    /// FUNCNEST guard could report it. `maybe_grow` continues on a fresh segment
5397    /// when fewer than 256 KiB remain, on the same thread, so signals, job control
5398    /// and traps behave exactly as before.
5399    pub fn dispatch_function_call(&mut self, name: &str, args: &[String]) -> Option<i32> {
5400        stacker::maybe_grow(256 * 1024, 32 * 1024 * 1024, || {
5401            self.dispatch_function_call_on_stack(name, args)
5402        })
5403    }
5404
5405    fn dispatch_function_call_on_stack(&mut self, name: &str, args: &[String]) -> Option<i32> {
5406        // Held for the WHOLE call, not just the load: an autoloaded function is
5407        // registered TWICE — once when its file's text defines it, and again
5408        // (unchanged) when its chunk is compiled at call time — and the second
5409        // stamp would otherwise relabel it with the caller's scriptfilename.
5410        // See the AUTOLOAD_DEF_FILE consumer in fusevm_bridge.
5411        let mut _autoload_file_guard: Option<AutoloadFileGuard> = None;
5412        // Nested scope for `>(cmd)` fd ownership — builtins running
5413        // inside the function body must not close the CALLER's
5414        // pending psub fds (`myfn >(cmd)` keeps /dev/fd/N alive for
5415        // the whole function, like C's per-job filelist). See
5416        // PSUB_SCOPE_DEPTH in fusevm_bridge.rs.
5417        let _psub_scope = crate::fusevm_bridge::PsubScope::enter();
5418        // c:Src/exec.c — `disable -f NAME` flips the DISABLED flag on
5419        // the shfunctab entry. `lookupshfunc` (which dispatch consults)
5420        // returns NULL for DISABLED entries, falling through to PATH
5421        // lookup → "command not found". zshrs keeps the compiled body
5422        // in functions_compiled independently of the flag, so check
5423        // shfunctab and short-circuit when DISABLED is set. Bug #221
5424        // in docs/BUGS.md.
5425        let is_disabled = crate::ported::hashtable::shfunctab_lock()
5426            .read()
5427            .ok()
5428            .and_then(|t| {
5429                let entry = t.get_including_disabled(name)?;
5430                Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
5431            })
5432            .unwrap_or(false);
5433        if is_disabled {
5434            return None;
5435        }
5436        // `_regex_arguments NAME …` (e.g. `_regex_arguments _sed_expressions …`
5437        // in `_sed`) eval-defines a real shell function NAME in zsh. This port
5438        // stores it in a runtime registry keyed by NAME (a static router fn-ptr
5439        // can't carry a dynamic name). `run_function_body_only` already consults
5440        // that registry, but `dispatch_function_call` — the path an `_arguments`
5441        // action (`:sed script:_sed_expressions`) or any by-name caller takes —
5442        // did not, so the call fell through to the autoload prelude and errored
5443        // "function definition file not found" (`sed -<TAB>`). Consult the
5444        // registry here too, before autoload. Returned directly (like
5445        // run_function_body_only) — the regex body drives compsys globals, not
5446        // function locals, so it needs no doshfunc scope wrap.
5447        if let Some(rc) = crate::compsys::ported::_regex_arguments::dispatch_if_registered(name) {
5448            return Some(rc);
5449        }
5450        // zshrs-original: `[compsys] backend = "rust"` short-circuit.
5451        // When a `_NAME` has a Rust port AND the user opted into the
5452        // rust backend, run the Rust fn directly here — but still
5453        // through the canonical doshfunc scope-management path below
5454        // (we synthesize a body_runner from the fn pointer). Router
5455        // returns None for names without a Rust port → graceful
5456        // fallback to the shfunc autoload path.
5457        //
5458        // Note: `compcore::callcompfunc` (the compsys entry hit by
5459        // Tab) wraps doshfunc itself per C `compcore.c:835`, so the
5460        // Rust _main_complete dispatch lands HERE only when called
5461        // from a non-compcore caller (e.g. a user shell script
5462        // directly invoking `_main_complete`). The doshfunc scope
5463        // wrap below applies uniformly to both.
5464        let direct_rust_fn: Option<fn(&[String]) -> i32> =
5465            crate::compsys::router::try_rust_dispatch(name);
5466        // A plugin-registered override (ABI v4, `zmodload -R`) also
5467        // intercepts natively: it supplies the body, so no shell autoload
5468        // or compiled chunk is needed — same as a built-in Rust port.
5469        let has_plugin_override = crate::extensions::plugin_host::compfn_override(name).is_some();
5470        // c:Src/exec.c:3760-3763 — the twin of the gate in
5471        // `run_function_body_only`: a native compsys port stands in for the
5472        // COMMAND here, so it must not run when the argument expansion that
5473        // built its argument list already raised errflag. Scoped to the ported
5474        // names on purpose — this function is also the shim ports use to call
5475        // ordinary shell functions (src/ported/exec.rs:8588), and those keep
5476        // whatever behaviour the executor gives them.
5477        if (direct_rust_fn.is_some() || has_plugin_override)
5478            && (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0
5479        {
5480            crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:3761
5481            return Some(1); // c:3762 goto err
5482        }
5483        // c:Src/exec.c:5626 — the body of a function loaded on THIS call
5484        // runs through `execode(shf->funcdef, 1, 0, "loadautofunc")`
5485        // (execautofn_basic), nested inside runshfunc's "shfunc" frame.
5486        // zshrs performs the load here, before `doshfunc`, so the flag
5487        // carries the fact into the body_runner that pushes the frame.
5488        let mut did_autoload = false;
5489        // Autoload prelude skipped when a Rust port OR plugin override wins
5490        // — no upstream shell function to load.
5491        if direct_rust_fn.is_none()
5492            && !has_plugin_override
5493            && !self.functions_compiled.contains_key(name)
5494        {
5495            // c:Src/exec.c:3105-3109 — `execcmd` resolves a command word as
5496            //     if (!(cflags & (BINF_BUILTIN | BINF_COMMAND)) &&
5497            //         (hn = shfunctab->getnode(shfunctab, cmdarg))) {
5498            //         is_shfunc = 1; break; }
5499            // the identical gate repeating at c:3484-3488, then `builtintab`,
5500            // then `$PATH`, with a name that resolves nowhere ending at c:903
5501            // `zerr("command not found: %s", arg0)`. `getnode` finds a node
5502            // ALREADY IN `shfunctab` — a real definition, or the autoload STUB
5503            // that `autoload` / `compinit` put there. A file merely PRESENT in
5504            // `$fpath` is NOT a command in zsh; nothing autoloads it on demand.
5505            //
5506            // This used to probe `$fpath` with `getfpfunc` (c:Src/exec.c:6279)
5507            // for ANY `_`-prefixed name with no shfunctab entry and, on a hit,
5508            // run `autoload -rUz -- NAME` and execute the file. That gate keyed
5509            // on the FILENAME, where zsh keys on the file's TAG LINE: compinit
5510            // sh:511-525 reads `IFS=$' \t' read -rA _i_line < $_i_file` and then
5511            // `case $_i_tag in (\#compdef) compdef -na … ;; (\#autoload)
5512            // autoload -rUz "$_i_line[@]" ${_i_name} ;; esac`, so an UNTAGGED
5513            // `_`-file is registered by neither mechanism — yet zshrs ran it:
5514            //     fpath=(/tmp/d); __zzz_probe
5515            //     zsh    zsh:1: command not found: __zzz_probe   (rc 127)
5516            //     zshrs  RAN                                     (rc 0)
5517            // The cost was a SWALLOWED DIAGNOSTIC: with the untagged
5518            // `__fasd_files_comp` / `__fasd_dirs_comp` in `$fpath`, a `_files`
5519            // override offering them as `_alternative` actions makes zsh report
5520            // `_alternative:71: command not found: __fasd_files_comp`, where
5521            // zshrs reported nothing at all because it executed the file.
5522            //
5523            // Nothing replaces the probe. The providers that stand in for a
5524            // stock `$fpath` file already short-circuit ABOVE this block
5525            // (`direct_rust_fn`, `has_plugin_override`), and `compinit` installs
5526            // a real PM_UNDEFINED stub for every `#compdef`/`#autoload` file it
5527            // scans — on the scan path and from a dump alike
5528            // (`register_autoload_stubs`, src/compsys/ported/compinit.rs:831-855,
5529            // called at :1137). So the `getshfunc` below IS C's
5530            // `shfunctab->getnode`, and a name it misses falls through to
5531            // builtin → `$PATH` → command-not-found, exactly as `execcmd` does.
5532            if let Some(stub) = crate::ported::utils::getshfunc(name) {
5533                // c:Src/exec.c:5684-5704 (loadautofn) — `autoload -U` records
5534                // PM_UNALIASED, whose ONLY effect is that the autoloaded body is
5535                // PARSED with alias expansion disabled:
5536                //     int noalias = noaliases;
5537                //     noaliases = (shf->node.flags & PM_UNALIASED);
5538                //     prog = getfpfunc(...);        /* parses the file */
5539                //     noaliases = noalias;
5540                let unaliased = (stub.node.flags as u32 & crate::ported::zsh_h::PM_UNALIASED) != 0;
5541                let noalias_save = crate::ported::lex::noaliases(); // c:5684
5542                crate::ported::lex::set_noaliases(unaliased); // c:5697
5543                                                              // c:5704 — restored on EVERY exit from this block, including the
5544                                                              // early `return Some(1)` paths below.
5545                let _restore_noaliases = NoAliasesRestore(noalias_save);
5546                if (stub.node.flags as u32 & PM_UNDEFINED) != 0 {
5547                    did_autoload = true; // c:5626 — body runs as "loadautofunc"
5548                    let _sticky_load = AutoloadStickyGuard::enter(&stub);
5549                    let boxed = Box::new(stub.clone());
5550                    let ptr = Box::into_raw(boxed);
5551                    let load_rc = crate::ported::exec::loadautofn(ptr, 0, 0, 0);
5552                    unsafe {
5553                        let _ = Box::from_raw(ptr);
5554                    }
5555                    // c:Src/exec.c:5713-5719 / 5635-5644 — a failed load is
5556                    // TERMINAL: `loadautofn` already emitted "function
5557                    // definition file not found" and C's `execautofn` returns
5558                    // 1 without ever touching a body (C replaced shf->funcdef
5559                    // with the mkautofn trampoline at c:3180). zshrs still has
5560                    // the stub's TEXT on the shfunc node, and the `if let
5561                    // Some(body)` arm below would re-run it — for an
5562                    // `autoload -X` stub that means re-entering the autoload
5563                    // path, which recursed to FUNCNEST and printed the
5564                    // diagnostic 500 times (C04funcdef:38,39,40). The
5565                    // `else if load_rc != 0` arm below stays as the (now
5566                    // unreachable) faithful mirror of the same C line.
5567                    if load_rc != 0 {
5568                        return Some(1); // c:5719 NULL → c:5644 `return 1`
5569                    }
5570                    // c:Src/exec.c:5657 loadautofnsetfile — capture the fpath
5571                    // directory loadautofn wrote so it can be restored (as an
5572                    // absolutized path with PM_LOADDIR) after the funcdef pipeline
5573                    // below clobbers `filename` to scriptfilename ("zsh"). Without
5574                    // this, `whence -v <autoloaded>` printed "from zsh".
5575                    let loaded_dir = crate::ported::utils::getshfunc(name)
5576                        .and_then(|f| f.filename)
5577                        .filter(|d| d != "zsh");
5578                    // c:Src/exec.c:5682-5760 — C loads the body IN PLACE on the
5579                    // existing Shfunc: `shf->node.flags &= ~PM_UNDEFINED`
5580                    // (c:5751) is the only flag C clears, so PM_ABSPATH_USED —
5581                    // stamped by `autoload -Uz /abs/dir/NAME`
5582                    // (`add_autoload_function`, Src/builtin.c:3290-3291) —
5583                    // survives the load. zshrs re-registers the body through the
5584                    // funcdef pipeline, which builds a FRESH node and drops the
5585                    // whole flag word; `loadautofnsetfile` below puts filename +
5586                    // PM_LOADDIR back, and PM_ABSPATH_USED has to come back with
5587                    // them. It is read by `add_autoload_function`'s sibling arm
5588                    // (Src/builtin.c:3310-3323): when a function loaded by
5589                    // absolute path autoloads a sibling with a bare name, C
5590                    // inherits the CALLER's load directory — `if ((shf2 = ...
5591                    // getnode2(shfunctab, calling_f)) && (shf2->node.flags &
5592                    // PM_LOADDIR) && (shf2->node.flags & PM_ABSPATH_USED) && ...`
5593                    // Without the restore that test never fired, so
5594                    // `autoload -Uz $D/wrapper; wrapper` → `autoload -Uz sibling`
5595                    // failed with "function definition file not found" (zsh runs
5596                    // $D/sibling), and compsys `_`-names fell through to the Rust
5597                    // port instead of the user's file.
5598                    let abspath_used =
5599                        (stub.node.flags as u32 & crate::ported::zsh_h::PM_ABSPATH_USED) != 0;
5600                    if let Some(body) = crate::ported::utils::getshfunc(name).and_then(|f| f.body) {
5601                        let ksh_style = autoload_is_ksh_style(name); // c:5781 (pre-registration)
5602                        _autoload_file_guard = Some(AutoloadFileGuard::enter(name));
5603                        let AutoloadSource {
5604                            text: registered,
5605                            from_wordcode,
5606                            parse_failed,
5607                        } = autoload_register_source(name, &body);
5608                        // c:Src/exec.c:5739 — the ksh-autoload body runs via
5609                        // `execode(prog, 1, 0, "evalautofunc")` at the function
5610                        // invocation's locallevel, so a `return`/`break`/
5611                        // `continue` inside the file body is CONTAINED to the
5612                        // autoload call. add-zle-hook-widget's first line is
5613                        // `zmodload -e zsh/zle || return 1`; when a plugin has
5614                        // leaked `ksh_autoload` on (e.g. a bare `emulate sh`),
5615                        // that `return` must NOT propagate out and abort the
5616                        // caller's precmd/shell (zsh warns "not defined by file"
5617                        // and CONTINUES). Save & restore the control-flow flags
5618                        // around the body run to reinstate that boundary.
5619                        {
5620                            use crate::ported::builtin::{
5621                                BREAKS, EXIT_PENDING, EXIT_VAL, RETFLAG, SHELL_EXITING,
5622                            };
5623                            use std::sync::atomic::Ordering::Relaxed;
5624                            // c:Src/exec.c:5739 — `execode(prog, 1, 0,
5625                            // "evalautofunc")` runs the file body as part of the
5626                            // autoload invocation. add-zle-hook-widget's
5627                            // `zmodload -e zsh/zle || return 1` sits at the
5628                            // file's TOP LEVEL (above its anon-func wrapper); at
5629                            // script scope a top-level `return` is a shell EXIT,
5630                            // so running the body as a plain script aborted the
5631                            // caller's precmd/shell. `return` is contained when
5632                            // `locallevel || sourcelevel` (bin_return, c:5840) —
5633                            // raise SOURCELEVEL (the file-source counter, which
5634                            // unlike locallevel does NOT open a local scope, so
5635                            // the body's global assignments still land globally)
5636                            // so the top-level `return` returns from the load
5637                            // instead of exiting. Save/restore the control-flow
5638                            // flags so nothing leaks — matching zsh's
5639                            // warn-and-continue.
5640                            use crate::ported::init::sourcelevel;
5641                            let saved_retflag = RETFLAG.swap(0, Relaxed);
5642                            let saved_breaks = BREAKS.swap(0, Relaxed);
5643                            let saved_exit_pending = EXIT_PENDING.swap(0, Relaxed);
5644                            let saved_exit_val = EXIT_VAL.swap(0, Relaxed);
5645                            let saved_shell_exiting = SHELL_EXITING.swap(0, Relaxed);
5646                            sourcelevel.fetch_add(1, Relaxed);
5647                            {
5648                                // c:Src/exec.c:5739 — the ksh-autoload branch
5649                                // runs the file through
5650                                // `execode(prog, 1, 0, "evalautofunc")`, so
5651                                // that label is on `zsh_eval_context` while
5652                                // the file body executes.
5653                                let _ctx =
5654                                    crate::ported::exec::EvalContextFrame::push("evalautofunc");
5655                                {
5656                                    // c:Src/exec.c:5735-5760 — C INSTALLS the parsed Eprog
5657                                    // as the function body; it executes nothing at load
5658                                    // time, so the global `lineno` still holds the line the
5659                                    // CALL was made on when doshfunc records
5660                                    // `funcsave->fstack.lineno = lineno` (c:6013). zshrs
5661                                    // installs the body by RUNNING `name() { … }` through
5662                                    // the pipeline, which walks the counter to the file's
5663                                    // last line — so the very first call of an autoloaded
5664                                    // function reported its caller's line as that instead:
5665                                    // `$functrace` read `script.zsh:1` where zsh reads
5666                                    // `script.zsh:4`, and inside completion `_subscript:0`
5667                                    // where zsh reads `_subscript:125`. Every LATER call
5668                                    // was already correct, because the load only happens
5669                                    // once.
5670                                    let caller_lineno = crate::ported::lex::lineno();
5671                                    // c:5384-5388 assigns `shf->lineno` only when a
5672                                    // `name() { … }` STATEMENT defines the function. An
5673                                    // autoload stub's Shfunc keeps the 0 it was created
5674                                    // with, and loadautofn replaces only `funcdef`, so zsh
5675                                    // reports `funcsourcetrace` as `<file>:0`. Running a
5676                                    // synthesized wrapper here stamps line 1 instead, so
5677                                    // put the stub's value back when the wrapper was ours.
5678                                    let synthesized = registered != body;
5679                                    let _ = self.run_autoload_definition(
5680                                        name,
5681                                        &registered,
5682                                        ksh_style,
5683                                        from_wordcode,
5684                                        parse_failed,
5685                                    );
5686                                    crate::ported::lex::set_lineno(caller_lineno);
5687                                    if synthesized {
5688                                        // c:5384-5388 sets `shf->lineno` only where a
5689                                        // `name() { … }` STATEMENT defines the function; an
5690                                        // autoload stub keeps the 0 it was created with and
5691                                        // loadautofn replaces only `funcdef`, so
5692                                        // `funcsourcetrace` reads `<file>:0`. Executing our
5693                                        // synthesized wrapper records a line base of 1
5694                                        // instead. -1 marks "autoload-installed" so the
5695                                        // call-time clamp below can tell that apart from an
5696                                        // INLINE `f() { … }`, whose base underflows to 0 but
5697                                        // whose def line really is >= 1.
5698                                        self.function_line_base.insert(name.to_string(), -1);
5699                                    }
5700                                }
5701                            }
5702                            sourcelevel.fetch_sub(1, Relaxed);
5703                            RETFLAG.store(saved_retflag, Relaxed);
5704                            BREAKS.store(saved_breaks, Relaxed);
5705                            EXIT_PENDING.store(saved_exit_pending, Relaxed);
5706                            EXIT_VAL.store(saved_exit_val, Relaxed);
5707                            SHELL_EXITING.store(saved_shell_exiting, Relaxed);
5708                        }
5709                        if let Some(dir) = loaded_dir.as_deref() {
5710                            restore_loaddir(name, dir, abspath_used, ksh_style);
5711                        }
5712                        if !self.functions_compiled.contains_key(name) {
5713                            // c:Src/exec.c:5742-5745 — ksh-style load ran
5714                            // the file (`execode`, "evalautofunc") but it
5715                            // didn't define NAME:
5716                            //   `zwarn("%s: function not defined by file", n);`
5717                            // That diagnostic lives INSIDE C's
5718                            // `ksh == 2 || (ksh == 1 && isset(KSHAUTOLOAD))`
5719                            // branch (c:5726). The zsh-style branch (c:5753-
5720                            // 5758) installs the parsed program as the body
5721                            // outright and cannot fail this way, so zsh prints
5722                            // nothing there — and when the file did not parse,
5723                            // `getfpfunc` already returned NULL and the load
5724                            // ended at c:5722 with only the parse error.
5725                            // zshrs used to print it for every failed
5726                            // registration, so `g(){ emulate ksh -c _files }`
5727                            // gained a `_files: function not defined by file`
5728                            // line zsh never emits. Status still propagates:
5729                            // c:5644 `if (!loadautofn(...)) return 1`.
5730                            if ksh_style {
5731                                crate::ported::utils::zwarn(&format!(
5732                                    "{}: function not defined by file",
5733                                    name
5734                                ));
5735                            }
5736                            return Some(1);
5737                        }
5738                    } else if load_rc != 0 {
5739                        // c:Src/exec.c:5713-5719 / 5635-5644 —
5740                        // `execautofn`'s `if (!loadautofn(...)) return 1`
5741                        // propagates the loadautofn failure as the
5742                        // command's exit status. zshrs's previous
5743                        // path returned None here, falling through to
5744                        // execute_external which emitted a SECOND
5745                        // diagnostic (`command not found: NAME`) on
5746                        // top of loadautofn's `function definition
5747                        // file not found`. Mirror C: when load failed
5748                        // AND the stub still has no body, surface
5749                        // status=1 so the caller does NOT fall back
5750                        // to PATH search.
5751                        return Some(1);
5752                    }
5753                } else if let Some(body) = stub.body.clone() {
5754                    // c:Src/exec.c:5978 — the body runs under the stub's sticky
5755                    // emulation; see AutoloadStickyGuard.
5756                    let _sticky_load = AutoloadStickyGuard::enter(&stub);
5757                    // c:Src/Modules/parameter.c::setpmfunction — function
5758                    // registered via `functions[name]=body` lives in
5759                    // shfunctab with `body` set but `functions_compiled`
5760                    // empty (the canonical port stores the parsed eprog,
5761                    // not a fusevm Chunk). Lazy-compile here by feeding
5762                    // the body through the standard funcdef pipeline so
5763                    // the next CallFunction op finds the chunk.
5764                    //
5765                    // c:Src/builtin.c:3180 (eval_autoload) — `autoload +X NAME`
5766                    // reaches this arm as well: it loads the body EAGERLY via
5767                    // `loadautofn`, which sets `body` + `filename`/PM_LOADDIR
5768                    // and clears PM_UNDEFINED but leaves no compiled chunk, so
5769                    // the first CALL never sees the PM_UNDEFINED arm above.
5770                    // Restore the load directory after re-registration exactly
5771                    // as that arm does — otherwise `autoload +X /abs/dir/NAME`
5772                    // dropped PM_LOADDIR|PM_ABSPATH_USED before the body ran
5773                    // and a sibling `autoload -Uz SIB` inside it failed with
5774                    // "function definition file not found" where zsh loads
5775                    // /abs/dir/SIB. The `functions[name]=body` case has no
5776                    // PM_LOADDIR, so the restore is skipped for it.
5777                    let loaded_dir = ((stub.node.flags as u32 & crate::ported::zsh_h::PM_LOADDIR)
5778                        != 0)
5779                        .then(|| stub.filename.clone())
5780                        .flatten();
5781                    let abspath_used =
5782                        (stub.node.flags as u32 & crate::ported::zsh_h::PM_ABSPATH_USED) != 0;
5783                    // c:Src/Modules/parameter.c:295-315 — a body that arrived
5784                    // through `functions[NAME]=BODY` is NOT a file. See
5785                    // `body_came_from_param_assignment`.
5786                    let param_body = body_came_from_param_assignment(stub.node.flags);
5787                    let ksh_style = !param_body && autoload_is_ksh_style(name); // c:5781 (pre-registration)
5788                    _autoload_file_guard = Some(AutoloadFileGuard::enter(name));
5789                    // A `functions[name]=body` assignment is already a parsed
5790                    // body, so it takes no file parse and cannot have reported
5791                    // one (`parse_failed` is false by construction).
5792                    let (registered, from_wordcode, parse_failed) = if param_body {
5793                        (param_definition_source(name, &body), false, false)
5794                    } else {
5795                        let AutoloadSource {
5796                            text,
5797                            from_wordcode,
5798                            parse_failed,
5799                        } = autoload_register_source(name, &body);
5800                        (text, from_wordcode, parse_failed)
5801                    };
5802                    {
5803                        // c:Src/exec.c:5735-5760 — C INSTALLS the parsed Eprog
5804                        // as the function body; it executes nothing at load
5805                        // time, so the global `lineno` still holds the line the
5806                        // CALL was made on when doshfunc records
5807                        // `funcsave->fstack.lineno = lineno` (c:6013). zshrs
5808                        // installs the body by RUNNING `name() { … }` through
5809                        // the pipeline, which walks the counter to the file's
5810                        // last line — so the very first call of an autoloaded
5811                        // function reported its caller's line as that instead:
5812                        // `$functrace` read `script.zsh:1` where zsh reads
5813                        // `script.zsh:4`, and inside completion `_subscript:0`
5814                        // where zsh reads `_subscript:125`. Every LATER call
5815                        // was already correct, because the load only happens
5816                        // once.
5817                        let caller_lineno = crate::ported::lex::lineno();
5818                        // c:5384-5388 assigns `shf->lineno` only when a
5819                        // `name() { … }` STATEMENT defines the function. An
5820                        // autoload stub's Shfunc keeps the 0 it was created
5821                        // with, and loadautofn replaces only `funcdef`, so zsh
5822                        // reports `funcsourcetrace` as `<file>:0`. Running a
5823                        // synthesized wrapper here stamps line 1 instead, so
5824                        // put the stub's value back when the wrapper was ours.
5825                        let synthesized = registered != body;
5826                        let _ = self.run_autoload_definition(
5827                            name,
5828                            &registered,
5829                            ksh_style,
5830                            from_wordcode,
5831                            parse_failed,
5832                        );
5833                        crate::ported::lex::set_lineno(caller_lineno);
5834                        if synthesized {
5835                            // c:5384-5388 sets `shf->lineno` only where a
5836                            // `name() { … }` STATEMENT defines the function; an
5837                            // autoload stub keeps the 0 it was created with and
5838                            // loadautofn replaces only `funcdef`, so
5839                            // `funcsourcetrace` reads `<file>:0`. Executing our
5840                            // synthesized wrapper records a line base of 1
5841                            // instead. -1 marks "autoload-installed" so the
5842                            // call-time clamp below can tell that apart from an
5843                            // INLINE `f() { … }`, whose base underflows to 0 but
5844                            // whose def line really is >= 1.
5845                            self.function_line_base.insert(name.to_string(), -1);
5846                        }
5847                    }
5848                    if let Some(dir) = loaded_dir.as_deref() {
5849                        restore_loaddir(name, dir, abspath_used, ksh_style);
5850                    }
5851                }
5852            }
5853        }
5854        // When a Rust port is registered, skip the fusevm Chunk
5855        // lookup entirely — the body_runner closure below will run
5856        // the Rust fn pointer directly. Otherwise require a compiled
5857        // chunk for the autoloaded body.
5858        let mut chunk_opt = if direct_rust_fn.is_some() || has_plugin_override {
5859            None
5860        } else {
5861            Some(self.functions_compiled.get(name).cloned()?)
5862        };
5863
5864        // c:Src/exec.c:5634 (execshfunc) — `xtrerr = stderr;`: the body traces
5865        // to the stderr the call's own redirections set up.
5866        crate::ported::utils::xtrerr.store(2, Ordering::Relaxed);
5867
5868        // zshrs-specific bookkeeping that doshfunc doesn't own:
5869        // - prompt_funcstack (PS4 trace) push/pop
5870        // - local_scope_depth FUNCNEST guard
5871        //
5872        // c:Src/exec.c::funcnest_check — C zsh allows FUNCNEST=500 by
5873        // default. zshrs's per-call stack usage is heavier (vm_helper
5874        // state, fusevm closures, parse buffers), so on the default 8MB
5875        // stack a deep recursion overflowed around depth ~80-120 and
5876        // crashed. That is now fixed at the source: the shell runs on a
5877        // 512MB-stack thread (see bins/zshrs.rs::main), which comfortably
5878        // fits FUNCNEST (500) nested heavy frames. So the effective limit
5879        // is the user's FUNCNEST (default 500), matching zsh — no premature
5880        // clamp — with a generous hard ceiling as a last-resort backstop
5881        // that stays well under the big stack's capacity. Bug #519 (the
5882        // crash) / #643 (the false-positive clamp at 80 that broke
5883        // legitimately deep recursion). The authoritative FUNCNEST error
5884        // is also enforced in doshfunc (exec.rs) on the FS_FUNC depth.
5885        const FUNCNEST_RUST_CEILING: usize = 6000;
5886        let funcnest_user: usize = self
5887            .scalar("FUNCNEST")
5888            .and_then(|s| s.parse().ok())
5889            .unwrap_or(500);
5890        let funcnest_limit = funcnest_user.min(FUNCNEST_RUST_CEILING);
5891        if self.local_scope_depth >= funcnest_limit {
5892            // c:Src/exec.c:6060-6063 —
5893            //     zerr("maximum nested function level reached; increase FUNCNEST?");
5894            //     lastval = 1;
5895            //     goto undoshfunc;
5896            // `zerr` is what makes this FATAL: it raises errflag, so the
5897            // enclosing list stops and a non-interactive shell exits.
5898            // zsh 5.9:
5899            //     zsh -fc 'FUNCNEST=2; f() { f; }; f; printf after'
5900            // prints only the diagnostic and exits 1 — no `after`. bash
5901            // agrees ("Function invocations that exceed this nesting level
5902            // cause the current command to abort", bash(1) FUNCNEST), and
5903            // its own message is likewise followed by exit 1.
5904            //
5905            // This guard printed with a bare `eprintln!` and returned 1
5906            // WITHOUT raising errflag, so the runaway recursion stopped but
5907            // the script kept running — `printf after` ran and the shell
5908            // exited 0. The ported check in exec.rs::doshfunc already does
5909            // the C trio, but this one fires first (it is the zshrs-only
5910            // stack backstop, evaluated before dispatch reaches doshfunc),
5911            // so it has to carry the same side effects.
5912            //
5913            // The message is written here rather than through `zerr`
5914            // because C's prefix is the *function* name — `scriptname` is
5915            // the running function inside doshfunc — and this guard runs
5916            // before that switch; going through zerr would print the outer
5917            // script name instead. Byte-compared against zsh 5.9.
5918            // c:Src/utils.c zerr → zerrmsg prints `scriptname:lineno: msg`
5919            // whenever `scriptname` is set (which it is here: c:5963
5920            // `scriptname = dupstring(name)` runs BEFORE the c:6060 check).
5921            // The `:lineno` half was missing, so
5922            //   ( FUNCNEST=0; fn() { true; }; fn )
5923            // printed `fn: maximum …` where zsh prints `fn:4: maximum …`
5924            // (C04funcdef:46).
5925            // c:Src/utils.c:301-308 zerrmsg — the `lineno: ` part is only
5926            // printed when `lineno` is non-zero (else a single space), so a
5927            // recursion on a function's first line reads `f: maximum …`.
5928            let lineno = crate::ported::lex::lineno();
5929            let shows_lineno = (crate::ported::zsh_h::unset(crate::ported::zsh_h::SHINSTDIN)
5930                || crate::ported::params::locallevel.here() != 0)
5931                && lineno != 0;
5932            if shows_lineno {
5933                eprintln!(
5934                    "{}:{}: maximum nested function level reached; increase FUNCNEST?",
5935                    name, lineno
5936                );
5937            } else {
5938                eprintln!("{}: maximum nested function level reached; increase FUNCNEST?", name);
5939            }
5940            errflag.fetch_or(ERRFLAG_ERROR, Ordering::Relaxed); // c:6061 (zerr)
5941            crate::ported::builtin::LASTVAL.store(1, Ordering::Relaxed); // c:6062
5942            return Some(1);
5943        }
5944        let display_name = if name.starts_with("_zshrs_anon_") {
5945            "(anon)".to_string()
5946        } else {
5947            name.to_string()
5948        };
5949        let line_base = self.function_line_base.get(name).copied().unwrap_or(0);
5950        let def_file = self.function_def_file.get(name).cloned().flatten();
5951        self.prompt_funcstack
5952            .push((name.to_string(), line_base, def_file));
5953        self.local_scope_depth += 1;
5954
5955        // Synthetic shfunc for doshfunc — carries the name + def-file
5956        // info so funcstack push gets a proper filename. funcdef/body
5957        // stay None because the wordcode body is irrelevant on this
5958        // path (body_runner runs the fusevm Chunk directly).
5959        // c:Src/exec.c:5390-5410 — execfuncdef records the
5960        // current `scriptfilename` on the shfunc at definition
5961        // time so funcsourcetrace can show file:line of the
5962        // function's source. The function_def_file map stores
5963        // this; fall back to the live scriptfilename so dynamic
5964        // / non-`compile_funcdef`-routed definitions still get a
5965        // sensible filename. Without the fallback, the synth_shf
5966        // saw None and the funcstack push at exec.rs:5719
5967        // defaulted to an empty string, which the funcsourcetrace
5968        // getfn rendered as `:N` (or worse, picked up the
5969        // function name from a parallel field). Bug #515.
5970        // c:Src/exec.c:5620/5625 — the source file is `getshfuncfile(shf)`,
5971        // which reads the shfunc's own `filename` (authoritative: set by
5972        // execfuncdef for a normally-defined function, and by loadautofn — as
5973        // the fpath dir with PM_LOADDIR — for an AUTOLOADED one). Prefer it.
5974        // `function_def_file` is a zshrs-only side map that, for an autoloaded
5975        // function, was stamped with the OUTER `scriptfilename` ("zsh") at
5976        // compile time, not the fpath file — so it must NOT override
5977        // getshfuncfile. Consulting it second still covers functions whose
5978        // shfunc `filename` wasn't recorded (compile_funcdef-routed defs);
5979        // scriptfilename is the final fallback. Without getshfuncfile winning,
5980        // funcsourcetrace reported "zsh" for every autoloaded completer, which
5981        // broke `_git`: its first git-completion.bash search path is
5982        // `"$(dirname ${funcsourcetrace[1]%:*})"/git-completion.bash` — "zsh"
5983        // resolved to `./git-completion.bash`, found nothing, and
5984        // `. "$script"` errored (`_git:.:48: no such file or directory`).
5985        let synth_filename = crate::ported::hashtable::getshfuncfile(name)
5986            .or_else(|| self.function_def_file.get(name).cloned().flatten())
5987            .or_else(|| self.scriptfilename.clone());
5988        // c:Src/exec.c:5409 — `shf->lineno = lineno;` (def line).
5989        // `function_line_base[name]` carries compile_funcdef's
5990        // `lineno_offset = first_body_line - 1` — equals the def line
5991        // for multi-line `f() {\n body }` but underflows to 0 for
5992        // INLINE `f() { body }` (def and body share a line). zsh's
5993        // funcsourcetrace reports the def line as 1-based, so clamp
5994        // to >= 1 to handle the inline case without rebuilding
5995        // line tracking through the parser. Bug #396.
5996        let synth_lineno = {
5997            let base = self.function_line_base.get(name).copied().unwrap_or(0);
5998            if base < 0 {
5999                // Autoload-installed (see the -1 marker at the install
6000                // site): c:5384 never runs for it, so the def line is 0.
6001                0
6002            } else {
6003                std::cmp::max(1i64, base)
6004            }
6005        };
6006        // Carry the REAL function's attribute flags over from shfunctab.
6007        // `functions -t/-T/-W` store PM_TAGGED / PM_TAGGED_LOCAL /
6008        // PM_WARNNESTED on the shfunctab node (builtin.rs c:3719), and
6009        // doshfunc turns PM_TAGGED* into XTRACE for the duration of the call
6010        // (exec.c:5954-5960). Hardcoding 0 here severed that link: the flags
6011        // were parsed and stored correctly, but the synthesized shfunc handed
6012        // to doshfunc always claimed "no attributes", so `functions -t f; f`
6013        // ran silently while `setopt xtrace` (a global option, not routed
6014        // through this struct) traced normally. Bug #1058.
6015        // The shfunctab key is the REGISTRATION name, which for an
6016        // anonymous function is the generated `_zshrs_anon_*` (only the
6017        // DISPLAY name is `(anon)` — c:Src/exec.c:5492 sets
6018        // `shf->node.nam = ANONYMOUS_FUNCTION_NAME` on the same struct
6019        // that already carries `tracing_flags` from c:5437). Looking the
6020        // flags up under the display name missed every anonymous
6021        // function, so `function -T { … }` ran untraced (E02xtrace:7,9).
6022        let synth_flags = crate::ported::hashtable::shfunctab_lock()
6023            .read()
6024            .ok()
6025            .and_then(|t| {
6026                t.get(name)
6027                    .or_else(|| t.get(display_name.as_str()))
6028                    .map(|s| s.node.flags)
6029            })
6030            .unwrap_or(0)
6031            // PM_LOADDIR is the ONE flag that must not ride along, because it
6032            // describes the `filename` FIELD and `synth_filename` above is not
6033            // that field — it is already `getshfuncfile`'s ANSWER.
6034            // c:Src/hashtable.c:1061 `zhtricat(shf->filename, "/", shf->node.nam)`
6035            // says PM_LOADDIR means "filename holds the fpath DIRECTORY, append
6036            // the name"; doshfunc re-applies exactly that when it builds the
6037            // funcstack frame (exec.rs c:6019, the PM_LOADDIR arm). Carrying
6038            // the flag onto a node whose filename is the resolved FILE appended
6039            // `/name` a SECOND time, so an autoloaded completer's
6040            // `$funcsourcetrace[1]` read `<dir>/_git/_git:0` where zsh reads
6041            // `<dir>/_git:0`. That is not cosmetic: `_git` (git's own zsh
6042            // wrapper) locates git-completion.bash with
6043            // `"$(dirname ${funcsourcetrace[1]%:*})"/git-completion.bash`, so
6044            // the doubled component sent it to a directory that does not exist,
6045            // left `$script` empty, and `. "$script"` failed with
6046            // `_git:.:48: no such file or directory:`. This silently undid
6047            // 8d8adf9c48, whose whole purpose was to make that search succeed.
6048            // `functions_source` / `whence -v` were unaffected — they call
6049            // `getshfuncfile` against the REAL shfunctab node, which is
6050            // correct; only the synthesized frame doubled.
6051            & !(crate::ported::zsh_h::PM_LOADDIR as i32);
6052        // c:Src/exec.c:5978 — `if (sticky_emulation_differs(shfunc->sticky))`
6053        // reads the STORED per-function sticky snapshot that
6054        // `shfunc_set_sticky` (c:5402) stamped at definition time. The
6055        // synthesized shfunc hardcoded `sticky: None`, so a function
6056        // defined under `emulate sh -c '...'` never re-entered its
6057        // emulation when called (B07emulate.ztst:6,7,8,12,13,14).
6058        // Carry it over from shfunctab like `synth_flags` above.
6059        let synth_sticky = crate::ported::hashtable::shfunctab_lock()
6060            .read()
6061            .ok()
6062            .and_then(|t| {
6063                t.get(name)
6064                    .or_else(|| t.get(display_name.as_str()))
6065                    .and_then(|s| {
6066                        s.sticky
6067                            .as_deref()
6068                            .map(|b| crate::ported::exec::sticky_emulation_dup(b, 0))
6069                    })
6070            });
6071        let mut synth_shf = crate::ported::zsh_h::shfunc {
6072            node: crate::ported::zsh_h::hashnode {
6073                next: None,
6074                nam: display_name.clone(),
6075                flags: synth_flags,
6076            },
6077            filename: synth_filename,
6078            lineno: synth_lineno,
6079            funcdef: None,
6080            redir: None,
6081            sticky: synth_sticky,
6082            body: None,
6083            redir_text: None,
6084        };
6085        // doshargs: C convention — argv[0] = function name (for
6086        // FUNCTIONARGZERO `$0`), argv[1..] = real positional args.
6087        let mut doshargs: Vec<String> = vec![display_name.clone()];
6088        doshargs.extend(args.iter().cloned());
6089
6090        // Seed `$?` with the parent's last status — C zsh's
6091        // doshfunc inherits lastval automatically because it's a
6092        // process-global; the fusevm VM creates a fresh
6093        // `vm.last_status = 0` per call, so we mirror the inherit
6094        // explicitly. Without this, a function reading `$?` BEFORE
6095        // running any command sees 0 instead of the caller's status.
6096        let seed_status = self.last_status();
6097        let body_args: Vec<String> = args.to_vec();
6098        let name_owned = name.to_string();
6099        let body_runner = move || -> i32 {
6100            // c:Src/exec.c:5626 — `execode(shf->funcdef, 1, 0,
6101            // "loadautofunc")`. On the call that autoloaded the function,
6102            // C runs its body one `zsh_eval_context` frame deeper than
6103            // runshfunc's "shfunc", which is why zsh reports
6104            // `shfunc:loadautofunc:…` down a chain of freshly autoloaded
6105            // completers where zshrs reported a flat `shfunc:shfunc:…`.
6106            let _load_ctx =
6107                did_autoload.then(|| crate::ported::exec::EvalContextFrame::push("loadautofunc"));
6108            // Branch: plugin override (ABI v4) → built-in Rust port →
6109            // fusevm Chunk (autoloaded shell body). All run INSIDE
6110            // doshfunc's scope so prologue/epilogue applies identically.
6111            if let Some(rc) =
6112                crate::extensions::plugin_host::dispatch_compfn(&name_owned, &body_args)
6113            {
6114                return rc;
6115            }
6116            if let Some(f) = direct_rust_fn {
6117                return crate::compsys::router::run_port(&name_owned, &body_args, f);
6118            }
6119            // The function body is already this closure's own copy (taken at
6120            // dispatch, above), and fusevm's VM owns the chunk it runs. Move
6121            // it into the VM and take it back afterwards instead of copying
6122            // it again: compinit alone runs compdef ~15,000 times, and the
6123            // second deep copy of every body was the largest single cost in
6124            // its profile. `VM::run` never writes `vm.chunk`, so what comes
6125            // back is the same body. Taking it back also keeps a repeated
6126            // invocation working — C's runshfunc runs the body again when a
6127            // wrapper handler returns `cont` after running it
6128            // (Src/exec.c runshfunc: `while (wrap) { cont = handler(...);
6129            // if (!cont) return; ... } execode(prog, ...)`).
6130            let chunk = chunk_opt
6131                .take()
6132                .expect("chunk_opt must be Some when direct_rust_fn is None");
6133            crate::fusevm_disasm::maybe_print_stdout(
6134                &format!(
6135                    "function:{}",
6136                    body_args.first().map(|s| s.as_str()).unwrap_or("")
6137                ),
6138                &chunk,
6139            );
6140            let mut vm = crate::vm_pool::acquire(chunk);
6141            vm.last_status = seed_status;
6142            let _ = vm.run();
6143            chunk_opt = Some(std::mem::take(&mut vm.chunk));
6144            vm.last_status
6145        };
6146
6147        // Enter executor context BEFORE doshfunc so the body_runner's
6148        // VM builtins can `with_executor(...)` to reach this state.
6149        // c:Src/exec.c:5572-5585 — execshfunc swaps in a FRESH, EMPTY cmdstack
6150        // for the duration of a shell-function call and restores the caller's
6151        // afterwards:
6152        //     ocs = cmdstack; ocsp = cmdsp;
6153        //     cmdstack = zalloc(CMDSTACKSZ); cmdsp = 0;
6154        //     doshfunc(shf, args, 0);
6155        //     free(cmdstack); cmdstack = ocs; cmdsp = ocsp;
6156        // The cmdstack is what `%_` renders, so without the swap a function
6157        // body inherits the CALLER's parser context: `f(){ print -rP "[%_]" }`
6158        // printed `[cursh]` inside `{ f }`, `[then]` inside an `if`, `[for]`
6159        // inside a loop and `[case]` inside a case arm, where zsh prints `[]`
6160        // in every one. Most visible under xtrace, whose default PS4 ends in
6161        // `%_`, so every traced line inside a called function carried a stale
6162        // field. `( f )` was already correct only because the subshell forks.
6163        // Bug #1059.
6164        let saved_cmdstack: Vec<u8> =
6165            crate::ported::prompt::CMDSTACK.with(|s| std::mem::take(&mut *s.borrow_mut()));
6166        // c:Src/exec.c:4364 — a `return` out of a redirected compound
6167        // command still runs `fixfds(save)`. See
6168        // `unwind_redirect_scopes_to`.
6169        let redir_depth = self.redirect_scope_stack.len();
6170        // c:Src/exec.c:5632-5638 — `if ((osfc = sfcontext) == SFC_NONE)
6171        // sfcontext = SFC_DIRECT; … doshfunc(shf, args, 0); sfcontext = osfc;`.
6172        // Read by the job-text gates (c:2060, c:3537): a command run inside a
6173        // function is not given the source text `time` reports as %J.
6174        let osfc = crate::ported::exec::sfcontext.load(std::sync::atomic::Ordering::Relaxed);
6175        if osfc == crate::ported::zsh_h::SFC_NONE {
6176            crate::ported::exec::sfcontext.store(
6177                crate::ported::zsh_h::SFC_DIRECT,
6178                std::sync::atomic::Ordering::Relaxed,
6179            ); // c:5633
6180        }
6181        let _ctx = ExecutorContext::enter(self);
6182        let status = crate::ported::exec::doshfunc(&mut synth_shf, doshargs, false, body_runner);
6183        drop(_ctx);
6184        crate::ported::exec::sfcontext.store(osfc, std::sync::atomic::Ordering::Relaxed); // c:5638
6185        self.unwind_redirect_scopes_to(redir_depth);
6186        crate::ported::prompt::CMDSTACK.with(|s| *s.borrow_mut() = saved_cmdstack);
6187
6188        self.prompt_funcstack.pop();
6189        self.local_scope_depth -= 1;
6190
6191        // Honor explicit `return N` from inside the function body.
6192        if let Some(ret) = self.returning.take() {
6193            self.set_last_status(ret);
6194            Some(ret)
6195        } else {
6196            self.set_last_status(status);
6197            Some(status)
6198        }
6199    }
6200
6201    pub(crate) fn execute_external(
6202        &mut self,
6203        cmd: &str,
6204        args: &[String],
6205        redirects: &[Redirect],
6206    ) -> Result<i32, String> {
6207        // FORK_EVENTS is bumped at the real spawn site inside
6208        // execute_external_bg — this entry is only ONE of several
6209        // callers of that spawn (the common static-head command path
6210        // calls execute_external_bg directly), so counting here would
6211        // miss `time sleep 0` while double-counting this path.
6212        self.execute_external_bg(cmd, args, redirects, false, 0)
6213    }
6214
6215    /// `command -p cmd …` — `execute(args, cflags, defpath=1)`.
6216    ///
6217    /// c:Src/exec.c:3212-3214 — the `command` precommand modifier's `-p`
6218    /// sets `use_defpath = 1`, and c:4369 `execute(args, cflags,
6219    /// use_defpath)` carries it into the exec. C never touches `$path` for
6220    /// this: the default-path search happens inside `execute()` alone
6221    /// (c:810-828), so `$PATH`, the child's environment and `cmdnamtab` all
6222    /// stay exactly as they were.
6223    pub(crate) fn execute_external_defpath(
6224        &mut self,
6225        cmd: &str,
6226        args: &[String],
6227        redirects: &[Redirect],
6228    ) -> Result<i32, String> {
6229        self.execute_external_bg(cmd, args, redirects, false, 1)
6230    }
6231
6232    /// Port of `execute()` from `Src/exec.c:729` — C decl
6233    /// `execute(LinkList args, int flags, int defpath)`. `defpath` is the
6234    /// third C parameter and is non-zero only for `command -p`.
6235    fn execute_external_bg(
6236        &mut self,
6237        cmd: &str,
6238        args: &[String],
6239        _redirects: &[Redirect],
6240        background: bool,
6241        defpath: i32, // c:729
6242    ) -> Result<i32, String> {
6243        tracing::trace!(cmd, bg = background, "exec external");
6244        // c:Src/exec.c:3545-3547 — `setunderscore((args && nonempty(args)) ?
6245        // ((char *) getdata(lastnode(args))) : "")`. execcmd_exec sets `$_`
6246        // to the last word of the command it is about to run, in the PARENT,
6247        // before any builtin/plugin resolution or fork — so `cat /dev/null;
6248        // print $_` reports `/dev/null` and a pipeline's stages each leave
6249        // their own last word behind. This is the single funnel every
6250        // external spawn reaches (the static-head command path calls it
6251        // directly and bypasses ZshrsHost::exec / host_exec_external), so
6252        // the write belongs here. C's `args` list carries argv[0], hence the
6253        // fallback to `cmd` for a bare command.
6254        {
6255            let last = args.last().cloned().unwrap_or_else(|| cmd.to_string());
6256            crate::ported::params::set_zunderscore(std::slice::from_ref(&last));
6257            // c:3546
6258        }
6259        // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
6260        // Native (Rust) plugin builtins registered via `zmodload -R`
6261        // (src/extensions/plugin_host.rs). fusevm compiles unknown
6262        // names into external execution, so a plugin command arrives
6263        // here as an "external". Resolve it BEFORE the PATH-unset guard
6264        // and the process spawn — plugin builtins are in-process and
6265        // need no PATH. This is the analog of C's `resolvebuiltin`
6266        // slot (Src/exec.c:2700), which likewise runs before the fork.
6267        // Bare names only: a `/`-qualified token is always a filesystem
6268        // path, never a plugin command name. Runs synchronously even
6269        // when backgrounded — zshrs is non-forking and an in-process
6270        // builtin has nothing to background.
6271        if !cmd.contains('/') {
6272            if let Some(status) = crate::plugin_host::dispatch(cmd, args) {
6273                return Ok(status);
6274            }
6275        }
6276        // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
6277        // Host-registered native commands (`extensions/native_cmds.rs`) — the
6278        // sibling runtimes a fat binary links into the shell's address space:
6279        // `git` (zvcs), `arb` (arblang) and `stryke` (strykelang) in the
6280        // zshrs-native build. Same slot and same reason as the plugin-builtin
6281        // dispatch directly above: the compiler has never heard of these names,
6282        // so it lowered them to external execution and they arrive here — this
6283        // is where they must be caught, BEFORE the PATH guard and before the
6284        // spawn, because an in-process builtin needs no PATH and no process.
6285        //
6286        // Two escape hatches to the binary on disk stay open, and both are
6287        // checked here. A `/`-qualified token (`/usr/bin/git`) is a filesystem
6288        // path the user named, never a registry key. And `command git`
6289        // explicitly asks past the in-process one — the `command` handler
6290        // raises `native_cmds::force_external` around this call, exactly as
6291        // `command cat` already escapes the coreutils shadow.
6292        //
6293        // The registry's contract is full argv (argv[0] = the name as
6294        // invoked), which zvcs reads for its `git-<verb>` dashed form.
6295        //
6296        // Empty in the thin shell: one map lookup that always misses.
6297        if !cmd.contains('/')
6298            && !crate::native_cmds::is_forced_external()
6299            && crate::native_cmds::is_enabled(cmd)
6300        {
6301            let full: Vec<String> = std::iter::once(cmd.to_string())
6302                .chain(args.iter().cloned())
6303                .collect();
6304            if let Some(status) = crate::native_cmds::dispatch(cmd, &full) {
6305                return Ok(status);
6306            }
6307        }
6308        // c:Src/exec.c:824-876 — when arg0 has no `/`, C zsh requires
6309        // a PATH search. With PATH unset, the search yields no hit
6310        // and C emits `command not found: <cmd>`. Rust's
6311        // `Command::new(name)` delegates to libc `execvp`, which on
6312        // many platforms falls back to a built-in default PATH when
6313        // the env entry is missing — so `unset PATH; ls` still finds
6314        // `/bin/ls` and runs it, breaking the security boundary the
6315        // unset is supposed to establish (#416). Gate explicitly:
6316        // when cmd is a bare name (no `/`) and zshrs's own PATH
6317        // param is unset OR empty, emit the canonical
6318        // "command not found" diagnostic and return 127 BEFORE
6319        // touching libc.
6320        //
6321        // c:810-815 — `if (defpath) { … search_defpath(arg0, …) … }` runs
6322        // INSTEAD OF the `$path` walk, so `command -p` must not be judged by
6323        // `$PATH` at all: the oracle runs `PATH=; command -p awk …` fine.
6324        if !cmd.contains('/') && defpath == 0 {
6325            let path_set_and_nonempty = crate::ported::params::getsparam("PATH")
6326                .map(|p| !p.is_empty())
6327                .unwrap_or(false);
6328            if !path_set_and_nonempty {
6329                let sn =
6330                    crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
6331                // c:Src/exec.c:811 `zerr("command not found: %s", arg0)`
6332                // — the diagnostic carries the CURRENT line, not a
6333                // hardcoded 1. `lineno()` is the same counter zwarning
6334                // (utils.rs:179) uses and is live during VM execution
6335                // (verified: read-only / div-by-zero errors already
6336                // report the right line). Emitted directly (not via
6337                // zerr) to avoid setting errflag — command-not-found is
6338                // non-fatal and the script must continue.
6339                // Inline Rust FFI export: needs no PATH, so run it here rather
6340                // than reporting not-found when PATH is unset/empty.
6341                if let Some(rc) = self.try_registered_ffi_command(cmd, args) {
6342                    return Ok(rc);
6343                }
6344                eprintln!("{}: command not found: {}", zerr_prefix(&sn), crate::ported::utils::nicedupstring(&cmd));
6345                return Ok(127);
6346            }
6347        }
6348        // c:Src/exec.c:2700-2724 resolvebuiltin — names registered via
6349        // `zmodload -ab MOD NAME` resolve through builtintab BEFORE
6350        // PATH search in C (execcmd's builtin lookup precedes the
6351        // external fork). Names the compiler didn't know as builtins
6352        // land here; consult the autoload ledger, load the module,
6353        // and re-dispatch through the builtin chokepoint. Without
6354        // this, `zmodload -ab zsh/bogus mybltn; mybltn` skipped the
6355        // C autoload-fire entirely (PATH miss → 127 instead of the
6356        // load_module diagnostic → 1).
6357        if !cmd.contains('/') {
6358            if let Some(rc) = crate::ported::exec::resolvebuiltin(cmd) {
6359                if rc != 0 {
6360                    return Ok(1);
6361                }
6362                return Ok(crate::fusevm_bridge::dispatch_builtin_raw(
6363                    cmd,
6364                    args.to_vec(),
6365                ));
6366            }
6367        }
6368        // c:Src/exec.c:3655-3675 — execcmd_exec's external-command
6369        // resolution, the step that populates `cmdnamtab`:
6370        //
6371        //     hn = cmdnamtab->getnode(cmdnamtab, cmdarg);           c:3661
6372        //     if (!hn && dohashcmd && strcmp(cmdarg, "..")) {       c:3671
6373        //         for (s = cmdarg; *s && *s != '/'; s++);           c:3672
6374        //         if (!*s) hn = (HashNode) hashcmd(cmdarg, ...);    c:3674
6375        //     }
6376        //
6377        // and c:831-869 in `execute()`, which turns the resulting node into
6378        // the pathname it hands `zexecve`: a HASHED entry execs `cn->u.cmd`
6379        // verbatim, an unhashed one execs `"<*cn->u.name>/<arg0>"`.
6380        //
6381        // Neither step ran here. This is the funnel every statically-spelled
6382        // external command reaches (`awk …` compiles to a fusevm exec op →
6383        // host_exec_external → here), and it handed the bare word straight to
6384        // `Command::new`, i.e. to libc `execvp`, which does its own PATH walk
6385        // and knows nothing about the shell's table. Two consequences:
6386        // `cmdnamtab` stayed empty for the whole life of the shell, so `hash`
6387        // listed nothing after running a command and everything reading the
6388        // table (`hash`, `unhash`, `whence`, `$commands`, completion) saw an
6389        // empty one; and an explicit `hash awk=/bin/echo` was ignored at exec
6390        // time because libc re-derived the path from `$PATH`. Only the
6391        // run-time-resolved head (`c=awk; $c …`) went through execcmd_exec
6392        // and hashed correctly, which is why the gap was invisible from the
6393        // ported side.
6394        //
6395        // `hashed_prog` also records that the pathname came from the table,
6396        // so the ENOENT arms below can fall back to the bare name the way
6397        // c:877 `execute_skip_exec:` falls through to a full `$path` walk
6398        // when the cmdnamtab candidate fails to exec.
6399        let mut hashed_prog: Option<String> = None;
6400        // c:3672-3673 — bare names only; a `/` in the word means the user
6401        // named a file and C never consults the table for it. c:3671 —
6402        // `..` is excluded by name.
6403        if !cmd.contains('/') && cmd != ".." {
6404            // c:3661 — `cmdnamtab->getnode(cmdnamtab, cmdarg)`.
6405            let mut have = crate::ported::hashtable::cmdnamtab_lock()
6406                .read()
6407                .ok()
6408                .and_then(|t| t.get(cmd).cloned());
6409            // c:3659 + c:3671 — `dohashcmd = isset(HASHCMDS)`.
6410            if have.is_none() && crate::ported::zsh_h::isset(crate::ported::zsh_h::HASHCMDS) {
6411                // c:3674 — `hashcmd(cmdarg, checkpath)`. Passing the whole
6412                // `$path` matches the sibling call in execcmd_exec
6413                // (exec.rs:11106) and hashcmd's own indexing of `pathchecked`.
6414                let dirs: Vec<String> = crate::ported::params::getsparam("PATH")
6415                    .unwrap_or_default()
6416                    .split(':')
6417                    .map(String::from)
6418                    .collect();
6419                have = crate::ported::exec::hashcmd(cmd, &dirs);
6420            }
6421            if have.is_some() {
6422                crate::extensions::emulation_output::bash_hash_hit(cmd);
6423            }
6424            if have.is_some() {
6425                // c:834 / c:861 — `cn->u.cmd` for a HASHED node, else
6426                // `"<*cn->u.name>/<arg0>"`. `get_full_path` is that arm.
6427                hashed_prog = crate::ported::hashtable::cmdnamtab_lock()
6428                    .read()
6429                    .ok()
6430                    .and_then(|t| t.get_full_path(cmd))
6431                    .map(|p| p.display().to_string());
6432            }
6433        }
6434        // c:Src/exec.c:3650-3652 + c:3678-3685 — AUTOCD. With no command
6435        // found, a one-word command with no redirections, in a shell
6436        // reading commands from its input (SHINSTDIN), that names a
6437        // directory — or, under CDABLEVARS, a parameter or user whose value
6438        // or home is one — runs as `cd -- DIR`:
6439        //     trycd = isset(AUTOCD) && isset(SHINSTDIN) && (!redir ||
6440        //             empty(redir)) && args && !empty(args) &&
6441        //             !nextnode(firstnode(args)) && *peekfirst(args);
6442        //     if (!hn && trycd && (s = cancd(peekfirst(args)))) { … "cd" … }
6443        // The ported execcmd_exec carries this; the compiled path every
6444        // literal command takes reaches only here, so `tmp` from `/`,
6445        // `/tmp` or a CDABLEVARS name reported "command not found".
6446        if hashed_prog.is_none()
6447            && defpath == 0
6448            && args.is_empty()
6449            && !cmd.is_empty()
6450            && crate::ported::zsh_h::isset(crate::ported::zsh_h::AUTOCD)
6451            && crate::ported::zsh_h::isset(crate::ported::zsh_h::SHINSTDIN)
6452            && !crate::fusevm_bridge::own_redirect_scope(self)
6453        {
6454            if let Some(dir) = crate::ported::exec::cancd(cmd) {
6455                return Ok(crate::fusevm_bridge::dispatch_builtin_raw(
6456                    "cd",
6457                    vec!["--".to_string(), dir],
6458                ));
6459            }
6460        }
6461        // c:Src/exec.c:810-828 — `/* for command -p, search the default path */
6462        //     if (defpath) {
6463        //         char pbuf[MAXCMDLEN];
6464        //         if (!search_defpath(arg0, pbuf, MAXCMDLEN)) {
6465        //             if (commandnotfound(arg0, args) == 0) _realexit();
6466        //             zerr("command not found: %s", arg0);
6467        //             _exit(127);
6468        //         }
6469        //         ee = zexecve(pbuf, argv, newenvp);
6470        //     } else { … cmdnamtab / $path walk … }`
6471        //
6472        // The two arms are mutually exclusive in C, so under `-p` the exec'd
6473        // pathname is the DEFAULT_PATH hit and nothing else — no `$path` walk,
6474        // no bare-name retry, and a miss is final even when the name sits on
6475        // `$PATH`. The `cmdnamtab` population above still runs because it is
6476        // NOT part of `execute()`: it belongs to `execcmd_exec` in the parent
6477        // (c:3671-3674), which hashes `cmdarg` against `$path` regardless of
6478        // `use_defpath`. That asymmetry is visible in the oracle — after
6479        // `PATH=<dir-with-a-fake-awk>:…; command -p awk`, the REAL
6480        // `/usr/bin/awk` runs while the table records the fake one.
6481        //
6482        // c:798-808 runs before this and execs a `/`-bearing arg0 directly, so
6483        // `defpath` never applies to a name the user spelled as a path.
6484        let mut defpath_prog: Option<String> = None;
6485        if defpath != 0 && !cmd.contains('/') {
6486            // c:815 — `if (!search_defpath(arg0, pbuf, MAXCMDLEN))`
6487            match crate::ported::exec::search_defpath(cmd, libc::PATH_MAX as usize) {
6488                // c:822 — `ee = zexecve(pbuf, argv, newenvp)`
6489                Some(pbuf) => defpath_prog = Some(pbuf),
6490                None => {
6491                    // c:816-817 — `if (commandnotfound(arg0, args) == 0)
6492                    // _realexit();`. The `command_not_found_handler` hook
6493                    // runs for a default-path miss exactly as it does for a
6494                    // `$path` miss, and its status is the command's status.
6495                    // The `$path` miss reaches the same hook from the ENOENT
6496                    // arm further down, after the spawn has failed; there is
6497                    // no spawn to fail here, so the call is explicit.
6498                    let mut hook_args = Vec::with_capacity(args.len() + 1);
6499                    hook_args.push(cmd.to_string());
6500                    hook_args.extend_from_slice(args);
6501                    if let Some(rc) =
6502                        self.dispatch_function_call("command_not_found_handler", &hook_args)
6503                    {
6504                        return Ok(rc);
6505                    }
6506                    let sn = crate::ported::utils::scriptname_get()
6507                        .unwrap_or_else(|| "zshrs".to_string());
6508                    // c:818 — `zerr("command not found: %s", arg0)`. Emitted
6509                    // directly rather than through `zerr` for the same reason
6510                    // as the PATH-unset arm above: command-not-found is
6511                    // non-fatal and must not raise errflag.
6512                    eprintln!("{}: command not found: {}", zerr_prefix(&sn), crate::ported::utils::nicedupstring(&cmd));
6513                    return Ok(127); // c:819 `_exit(127)`
6514                }
6515            }
6516        }
6517        // c:Src/exec.c:758-776 — "If ARGV0 is in the commands environment, we
6518        // use that as argv[0] for this external command" and unsetenv it; else
6519        // "if the pre-command `-' was given, we add `-' to the front of
6520        // argv[0] for this command."
6521        let carrier = crate::fusevm_bridge::take_exec_carrier();
6522        let job_text = crate::fusevm_bridge::take_job_text();
6523        let exec_dash = carrier & crate::fusevm_bridge::EXEC_CARRIER_DASH != 0;
6524        // c:Src/exec.c:4369 — this process was forked for the command
6525        // (BUILTIN_EXEC_FORKED_SIMPLE): exec it here rather than spawn.
6526        //
6527        // !!! WARNING: RUST-ONLY !!! C's multio helpers are forked tee/cat
6528        // processes (closemn, c:Src/exec.c:2288), so they outlive the execve
6529        // that replaces the shell. zshrs runs them as threads of the shell
6530        // (multios_scope_stack), and an execve kills every thread: the
6531        // concatenator of `cat <o1 <<<x` died before writing (cat read EOF)
6532        // and the splitter of `/bin/echo hi >a >b` left the command writing
6533        // into a pipe nobody reads (SIGPIPE, both files empty). While any
6534        // such thread is live the command is spawned and waited for instead,
6535        // which lets the threads finish before the shell exits. The command
6536        // still takes the shell's place for SHLVL, as it does in C.
6537        let exec_forked = !background && carrier & crate::fusevm_bridge::EXEC_CARRIER_FORKED != 0;
6538        let multio_threads_live = self.multios_scope_stack.iter().any(|scope| !scope.is_empty());
6539        let in_place = exec_forked && !multio_threads_live;
6540        if exec_forked && carrier & crate::fusevm_bridge::EXEC_CARRIER_SHLVL != 0 {
6541            crate::fusevm_bridge::exec_shlvl_decrement(); // c:Src/exec.c:4334-4336
6542        }
6543        let argv0_env = std::env::var("ARGV0").ok(); // c:760 zgetenv("ARGV0")
6544        let mut argv: Vec<String> = Vec::with_capacity(args.len() + 1);
6545        argv.push(match &argv0_env {
6546            Some(z) => z.clone(),                      // c:761
6547            None if exec_dash => format!("-{}", cmd), // c:775-776
6548            None => cmd.to_string(),
6549        });
6550        argv.extend_from_slice(args);
6551
6552        // c:Src/jobs.c — `time` reports only on JOBS (forked work). This
6553        // is the single chokepoint where an external process is actually
6554        // spawned (both fg and bg, all callers), AFTER the
6555        // command-not-found and resolvebuiltin early-returns above — so
6556        // counting here makes BUILTIN_TIME_SUBLIST report `time sleep 0`
6557        // / `time /usr/bin/true` (external → fork) while staying silent
6558        // for `time true` (builtin, never reaches this point). The
6559        // subshell entry counts separately (fusevm_bridge.rs:9573).
6560        FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6561        // c:Src/exec.c:4352 `closem(FDT_INTERNAL, 0)` before execve.
6562        crate::lowfd::cloexec_internal_fds();
6563
6564        // Redirect handling lives in fusevm's WithRedirectsBegin/End
6565        // ops at compile time; `_redirects` arrives empty here.
6566        let spawned = if background {
6567            execute_spawn(cmd, &argv, defpath_prog.as_deref(), hashed_prog.as_deref(), argv0_env.is_some(), false)
6568                .map_err(|eno| (eno, None))
6569        } else {
6570            // Queue signals across the wait so zshrs's SIGCHLD reaper
6571            // (waitpid(-1) in wait_for_processes, delivered on any thread)
6572            // can't reap this child before the wait does, and claim the
6573            // status back when it wins anyway — see foreground_status.
6574            let _wait_guard = crate::fusevm_bridge::ForegroundWaitGuard::enter();
6575            match execute_spawn(cmd, &argv, defpath_prog.as_deref(), hashed_prog.as_deref(), argv0_env.is_some(), in_place) {
6576                Ok(pid) => match crate::fusevm_bridge::wait_pid_status(pid) {
6577                    Ok(status) => {
6578                        use std::os::unix::process::ExitStatusExt as _;
6579                        drop(_wait_guard);
6580                        // c:Src/jobs.c:645-650 — update_job reports the finished job.
6581                        crate::exec_jobs::foreground_job_report(
6582                            status.into_raw(),
6583                            job_text,
6584                            !self.subshell_snapshots.is_empty(),
6585                        );
6586                        // c:Src/jobs.c:654-679 — update_job's foreground tail.
6587                        crate::exec_jobs::foreground_job_signalled(status.into_raw());
6588                        return Ok(crate::exec_jobs::wait_status_val(status));
6589                    }
6590                    Err(e) => Err((0, Some(e))),
6591                },
6592                Err(eno) => Err((eno, None)),
6593            }
6594        };
6595        let sn = crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
6596        let eno = match spawned {
6597            Ok(pid) => {
6598                let cmd_str = format!("{} {}", cmd, args.join(" "));
6599                let job_id = self.jobs.add_pid_job(pid, cmd_str, JobState::Running);
6600                println!("[{}] {}", job_id, pid);
6601                return Ok(0);
6602            }
6603            Err((_, Some(e))) => return Err(format!("{}: {}: {}", sn, cmd, e)),
6604            Err((eno, None)) => eno,
6605        };
6606        // c:Src/exec.c:871-881 — `if (eno) zerr("%e: %s", eno, arg0); else
6607        // if (commandnotfound(arg0, args) == 0) _realexit(); else
6608        // zerr("command not found: %s", arg0); _exit((eno == EACCES || eno
6609        // == ENOEXEC) ? 126 : 127);`. Emitted directly rather than through
6610        // `zerr`: C prints from the doomed child, so the parent's errflag is
6611        // never raised and the script continues.
6612        if eno != 0 {
6613            eprintln!(
6614                "{}: {}: {}",
6615                zerr_prefix(&sn),
6616                crate::ported::utils::zsh_errno_msg(eno),
6617                cmd
6618            );
6619            return Ok(if eno == libc::EACCES || eno == libc::ENOEXEC { 126 } else { 127 });
6620        }
6621        // c:873 — the `command_not_found_handler` user hook; its status is
6622        // the command's status. Documented in zshmisc(1) under "Special
6623        // Functions". Bug #426.
6624        if !background {
6625            let mut hook_args = Vec::with_capacity(args.len() + 1);
6626            hook_args.push(cmd.to_string());
6627            hook_args.extend_from_slice(args);
6628            if let Some(rc) = self.dispatch_function_call("command_not_found_handler", &hook_args) {
6629                return Ok(rc);
6630            }
6631        }
6632        // Inline Rust FFI export: consulted after builtins, functions, PATH
6633        // search, and command_not_found_handler have all missed — real
6634        // commands keep priority. In the background an in-process FFI call
6635        // has nothing to background, so it runs synchronously.
6636        if let Some(rc) = self.try_registered_ffi_command(cmd, args) {
6637            return Ok(rc);
6638        }
6639        eprintln!("{}: command not found: {}", zerr_prefix(&sn), crate::ported::utils::nicedupstring(&cmd));
6640        Ok(127)
6641    }
6642    /// !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
6643    /// Inline Rust FFI fallback: when `cmd` names a function exported by a
6644    /// `rust { ... }` block (registered by the `__rust_compile` builtin) run it
6645    /// as a command. Consulted only when `cmd` resolved to nothing else — not a
6646    /// builtin, function, plugin, external on `$PATH`, or
6647    /// `command_not_found_handler` — so real commands keep priority. Positional
6648    /// args are marshalled as strings; fusevm coerces each to the export's
6649    /// signature (`i64` / `f64` / `*const c_char`). The return value is printed
6650    /// to stdout (the redirect-aware process fd 1) and the command exits 0.
6651    /// Bare names only — a `/`-qualified token is a filesystem path, never an
6652    /// FFI export. Returns `None` when `cmd` is not a registered export, so the
6653    /// caller emits its normal "command not found".
6654    fn try_registered_ffi_command(&self, cmd: &str, args: &[String]) -> Option<i32> {
6655        if cmd.contains('/') || !fusevm::ffi::is_registered(cmd) {
6656            return None;
6657        }
6658        let vals: Vec<fusevm::Value> = args.iter().map(|a| fusevm::Value::str(a.clone())).collect();
6659        match fusevm::ffi::try_call(cmd, &vals) {
6660            Some(Ok(v)) => {
6661                use std::io::Write as _;
6662                let mut out = io::stdout().lock();
6663                let _ = writeln!(out, "{}", v.to_str());
6664                let _ = out.flush();
6665                Some(0)
6666            }
6667            Some(Err(e)) => {
6668                eprintln!("zshrs: {e}");
6669                Some(1)
6670            }
6671            // Registered a moment ago but the entry vanished (registry race) —
6672            // treat as unresolved and let the caller report command-not-found.
6673            None => None,
6674        }
6675    }
6676
6677    /// Parse `cmd_str` via parse_init+parse and pull out the first Simple
6678    /// command's words, untokenized + variable-expanded, ready to spawn
6679    /// as argv. Used by process-substitution where we need raw argv to
6680    /// hand to `Command::new`. Returns empty vec if the cmd isn't a
6681    /// simple shape — pipelines / compound forms aren't process-sub
6682    /// friendly anyway.
6683    fn simple_cmd_words(&mut self, cmd_str: &str) -> Vec<String> {
6684        // Mirror Src/init.c-style errflag save/clear/check around the
6685        // parse. Process-sub argv extraction silently bails on syntax
6686        // errors (matches zsh's behavior when the inner command can't
6687        // be parsed).
6688        let saved_errflag = errflag.load(Ordering::Relaxed);
6689        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
6690        // Context-isolated nested parse (c:Src/exec.c:283 parse_string) —
6691        // same rationale as run_command_substitution: process-sub argv
6692        // extraction runs during execution and must not clobber the outer
6693        // single-event reader's lexer/input position.
6694        let prog = parse_isolated(cmd_str);
6695        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
6696        errflag.store(saved_errflag, Ordering::Relaxed);
6697        if parse_failed {
6698            return Vec::new();
6699        }
6700        let first = match prog.lists.first() {
6701            Some(l) => l,
6702            None => return Vec::new(),
6703        };
6704        let pipe = &first.sublist.pipe;
6705        if let crate::parse::ZshCommand::Simple(simple) = &pipe.cmd {
6706            simple
6707                .words
6708                .iter()
6709                .map(|w| {
6710                    // Untokenize then variable-expand — text-based
6711                    // word expansion for the spawned argv.
6712                    let untoked = crate::lex::untokenize(w);
6713                    singsub(&untoked)
6714                })
6715                .collect()
6716        } else {
6717            Vec::new()
6718        }
6719    }
6720    /// `run_command_substitution` — see implementation.
6721    /// The SQLite mirror, opened the first time anything asks for it.
6722    ///
6723    /// Returns `None` when no cache file exists yet (or it failed to open),
6724    /// which is the same answer the eager constructor produced.
6725    pub fn compsys_cache(&self) -> Option<&CompsysCache> {
6726        self.compsys_cache
6727            .get_or_init(|| {
6728                let cache_path = crate::compsys::cache::default_cache_path();
6729                if !cache_path.exists() {
6730                    tracing::debug!("compsys: no cache at {}", cache_path.display());
6731                    return None;
6732                }
6733                let db_size = fs::metadata(&cache_path).map(|m| m.len()).unwrap_or(0);
6734                match CompsysCache::open(&cache_path) {
6735                    Ok(c) => {
6736                        tracing::info!(
6737                            db_bytes = db_size,
6738                            path = %cache_path.display(),
6739                            "compsys: sqlite mirror opened (dbview/SQL inspection only; rkyv shards are the authoritative cache)"
6740                        );
6741                        Some(c)
6742                    }
6743                    Err(e) => {
6744                        tracing::warn!(error = %e, "compsys: failed to open cache");
6745                        None
6746                    }
6747                }
6748            })
6749            .as_ref()
6750    }
6751
6752    pub fn run_command_substitution(&mut self, cmd_str: &str) -> String {
6753        // c:Src/subst.c / Src/lex.c — the text inside `$(…)` is a FRESH
6754        // command line. The double quotes that may surround the substitution
6755        // apply to its RESULT, not to the words inside it: in `"$(f $x)"` the
6756        // `$x` is unquoted. `in_dq_context` is the runtime signal the
6757        // `${(flags)…}` bridges read for paramsubst's `qt` (c:1625), and it
6758        // stayed set for the whole body, so every flag-expansion inside a
6759        // DQ command substitution ran as if quoted.
6760        //
6761        // What that broke: `qt` suppresses RC_EXPAND_PARAM's word removal, so
6762        // under `setopt rcexpandparam` an EMPTY array kept a word instead of
6763        // deleting it (c:4327's `while ((x = *aval++))` emits nothing for an
6764        // empty array; the `!plan9` single-empty-word path at c:4261 is the
6765        // one that must NOT run):
6766        //     setopt rcexpandparam
6767        //     f() { declare -a x; print "n=$(set -- H ${(q)x}; print $#)" }
6768        //     f            # zsh: n=1, zshrs was n=2
6769        // Only the `"$(…)"` spelling was affected — unquoted `$(…)`,
6770        // backticks, and `v=$(…)` were all already correct, which is what
6771        // made it look like a quoting bug rather than an option bug.
6772        //
6773        // Bit through compsys: completion runs with rcexpandparam ON, and
6774        // `_git`'s __git_recent_commits passes `${(q)commit_opts}` to
6775        // `_call_program` inside `"$(…)"`. The stray empty word became a
6776        // bogus `''` argument to `git rev-list`, the command failed, and
6777        // `git checkout <TAB>` lost its whole recent-commits group.
6778        //
6779        // `SUBEXP_SCALAR_CTX` carries the same thing one level down — it is
6780        // what a NESTED expansion reads as `subexp_dq` (subst.rs:18873) to
6781        // learn that its OUTER `${…}` was quoted. A `$(…)` inside a quoted
6782        // outer expansion is still a fresh command line, so it has to be
6783        // cleared too:
6784        //     setopt rcexpandparam
6785        //     f() { declare -a co; local -a c
6786        //           c=("${(f)"$(cmd HEAD ${(q)co})"}") }
6787        // is `_git`'s exact shape, and the leaked context flipped c:4354's
6788        // `mark_empty`, keeping the empty element that plan9 must delete.
6789        let saved_dq = std::mem::replace(&mut self.in_dq_context, 0);
6790        let saved_subexp = crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.replace(0));
6791        let out = self.run_command_substitution_inner(cmd_str, false);
6792        crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.set(saved_subexp));
6793        self.in_dq_context = saved_dq;
6794        out
6795    }
6796
6797    /// ksh93 funsub `${ list; }` / mksh valsub `${| list; }` — capture the
6798    /// output of `cmd_str` WITHOUT the subshell isolation `$( … )` applies.
6799    ///
6800    /// ksh(1), Command Substitution: "${ command;} … the command is
6801    /// executed in the current shell environment", so an assignment or a
6802    /// `cd` inside survives:
6803    ///   `ksh -c 'x=0; y=${ x=5; print -n out; }; print "x=$x y=$y"'`
6804    ///   → `x=5 y=out`, where the same body in `$( … )` leaves `x` at 0.
6805    /// mksh behaves identically for both of its forms.
6806    ///
6807    /// Same capture machinery as `$( … )` — only the parent-state
6808    /// snapshot/restore is skipped, which is exactly the difference the
6809    /// two references document.
6810    ///
6811    /// !!! RUST-ONLY ENTRY POINT — zsh has no funsub/valsub !!!
6812    pub fn run_shared_state_substitution(&mut self, cmd_str: &str) -> String {
6813        let saved_dq = std::mem::replace(&mut self.in_dq_context, 0);
6814        let saved_subexp = crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.replace(0));
6815        let out = self.run_command_substitution_inner(cmd_str, true);
6816        crate::ported::subst::SUBEXP_SCALAR_CTX.with(|c| c.set(saved_subexp));
6817        self.in_dq_context = saved_dq;
6818        out
6819    }
6820
6821    /// `shared_state`: skip the parent-state snapshot/restore that makes
6822    /// `$( … )` a subshell. Only the ksh/mksh funsub-valsub entry point
6823    /// passes true.
6824    fn run_command_substitution_inner(&mut self, cmd_str: &str, shared_state: bool) -> String {
6825        // `$(< FILE)` — zsh shorthand for "read FILE contents". Faster
6826        // than spawning `cat`. The leading `<` (after stripping
6827        // whitespace) means "read this file". Trailing newline is
6828        // stripped (same as command-substitution).
6829        let trimmed = cmd_str.trim_start();
6830        // Only treat as `$(<file)` shorthand when the SINGLE leading `<`
6831        // is followed by a filename, not another `<`. `$(<<<"hi" cat)`
6832        // starts with `<<<` (here-string) and must go through the full
6833        // parse path, not the read-file shortcut.
6834        if let Some(rest) = trimmed.strip_prefix('<').filter(|s| !s.starts_with('<')) {
6835            let filename = rest.trim();
6836            // c:Src/lex.c — the `$(<file)` shortcut ONLY applies when
6837            // the body is exactly `<` + ONE word. Anything else (extra
6838            // args, redirects, semicolons, pipes) is a regular command
6839            // list and must go through the full parse path so `2>/dev/null`
6840            // / `>file` / `|cmd` / `; next` etc. work. Without this
6841            // gate, `$(< file 2>/dev/null)` treated `file 2>/dev/null`
6842            // as the literal filename and errored on the missing file.
6843            // Bug #615.
6844            let is_single_word = !filename.is_empty()
6845                && !filename.chars().any(|c| {
6846                    matches!(
6847                        c,
6848                        ' ' | '\t'
6849                            | '\n'
6850                            | ';'
6851                            | '&'
6852                            | '|'
6853                            | '<'
6854                            | '>'
6855                            | '('
6856                            | ')'
6857                            | '`'
6858                            | '"'
6859                            | '\''
6860                    )
6861                });
6862            if is_single_word {
6863                // Expand any leading $ / tilde in the filename so
6864                // `$(< $f)` and `$(< ~/x)` work.
6865                let resolved = if filename.contains('$') || filename.starts_with('~') {
6866                    singsub(filename)
6867                } else {
6868                    filename.to_string()
6869                };
6870                let resolved = resolved.to_string();
6871                // c:Src/exec.c `readoutput` — `$(<file)` reads the file a
6872                // BYTE at a time and metafies (`if (imeta(c)) { *ptr++ =
6873                // Meta; ... }`); it does not require UTF-8.
6874                match crate::script_bytes::read_script_file(&resolved) {
6875                    Ok(contents) => {
6876                        return contents.trim_end_matches('\n').to_string();
6877                    }
6878                    Err(e) => {
6879                        // c:Src/exec.c:4797-4800 — `zwarn("%e: %s", errno, s);
6880                        // lastval = cmdoutval = 1; return newlinklist();`.
6881                        // zwarn carries the real `<script>:<line>:` prefix.
6882                        crate::ported::utils::zwarn(&format!(
6883                            "{}: {}",
6884                            crate::ported::utils::zsh_errno_msg(
6885                                e.raw_os_error().unwrap_or(libc::ENOENT)
6886                            ),
6887                            resolved
6888                        ));
6889                        self.set_last_status(1); // c:4799
6890                        crate::ported::exec::cmdoutval.store(1, Ordering::Relaxed);
6891                        return String::new();
6892                    }
6893                }
6894            }
6895            // Multi-word / has-redirects → fall through to full parse.
6896        }
6897
6898        // Port of getoutput(char *cmd, int qt) from Src/exec.c. Parse and compile via
6899        // the lex+parse free ported + ZshCompiler pipeline, run on a
6900        // sub-VM with the host wired up. Stdout is captured through
6901        // an in-process pipe via dup2 — no fork. The sub-VM emits
6902        // Op::Exec for unknown command names, which forks/execs
6903        // through the host.
6904
6905        // Set up the stdout-capture pipe. We dup the original stdout
6906        // so post-run we can restore it; the write end is dup2'd onto
6907        // STDOUT_FILENO so all output the sub-VM emits (including from
6908        // forked children, which inherit fd 1) lands in the pipe.
6909        //
6910        // c:Src/exec.c:4753 — `if (mpipe(pipes) < 0)`. mpipe (c:5160)
6911        // moves BOTH pipe ends to fd >= 10 via movefd and marks them
6912        // FDT_INTERNAL. This is load-bearing: zsh's invariant is that
6913        // shell-internal fds never live below 10, so user redirections
6914        // like `exec 9>&-` (which close fd<10 unconditionally, no
6915        // FDT_INTERNAL guard — c:Src/exec.c:3856-3868) can never hit
6916        // them. A raw pipe() here landed the read end on fd 9 when
6917        // fresh-HOME init held fds 3-7, and A04redirect's %prep
6918        // `exec 9>&-` closed our own capture pipe → SIGPIPE killed
6919        // the whole shell.
6920        let (read_fd, write_fd) = {
6921            let mut fds = [0i32; 2];
6922            if crate::ported::exec::mpipe(&mut fds) < 0 {
6923                return String::new();
6924            }
6925            // The read end stays open while the body runs in process; C's
6926            // forked body closes it (c:Src/exec.c:4839), so it must not sit
6927            // where the body's `{var}` allocations land.
6928            (crate::lowfd::movefd_past_script(fds[0]), fds[1])
6929        };
6930        // c:Src/utils.c:1996 — `movefd(dup(fd))`: saved copies of the
6931        // user-visible fds are shell-internal (FDT_INTERNAL), and since
6932        // they exist only for the in-process body they sit past the
6933        // script's `{var}` range too (movefd_past_script).
6934        // A CLOSED stdout saves as -1 and is closed again on the way out
6935        // (c:Src/utils.c:2047-2048 redup `if (x < 0) zclose(y)`). C's forked
6936        // getoutput child needs no save at all (c:4840 `redup(pipes[1], 1)`),
6937        // so the body runs either way: bailing out here lost everything the
6938        // body did, `{ x=$(print b >&2) } >&-` never printed `b`.
6939        let saved_stdout =
6940            crate::lowfd::movefd_past_script(unsafe { libc::dup(libc::STDOUT_FILENO) });
6941        // Flush Rust's stdout BufWriter against the ORIGINAL fd before
6942        // dup2 swaps fd 1 to the capture pipe. Without this, bytes left
6943        // buffered by a prior `print -n` get drained to fd 1 AFTER the
6944        // dup2, which routes them into the cmd-subst's pipe — they end
6945        // up in the captured result and disappear from terminal output.
6946        //
6947        // Bug #10 in docs/BUGS.md — `print -n "A"; v=$(true); print -n
6948        // "B"; v=$(true); print -n "C"; echo` printed only `C` because
6949        // `A` and `B` were redirected into the empty cmd-subst's pipe
6950        // and discarded as its "output". C zsh's getoutput() forks, so
6951        // the child inherits the buffer COPY and the parent's buffer
6952        // stays untouched; zshrs runs cmd-subst in-process so the
6953        // parent buffer is the only one — must flush before the swap.
6954        let _ = io::stdout().flush();
6955        // c:Bug #56 — publish the saved outer stdout so a trap firing
6956        // during the nested run routes body output to the parent's
6957        // real stdout instead of the cmdsub's pipe-bound fd 1.
6958        // Same as saved_stdout above.
6959        let saved_stderr_for_trap =
6960            crate::lowfd::movefd_past_script(unsafe { libc::dup(libc::STDERR_FILENO) });
6961        // A job the body forks must not keep them: they belong to the
6962        // parent C never forked the body from (see mark_saved_fd).
6963        crate::fusevm_bridge::mark_saved_fd(saved_stdout);
6964        crate::fusevm_bridge::mark_saved_fd(saved_stderr_for_trap);
6965        crate::fusevm_bridge::CMDSUBST_OUTER_FDS
6966            .with(|s| s.borrow_mut().push((saved_stdout, saved_stderr_for_trap)));
6967        unsafe {
6968            libc::dup2(write_fd, libc::STDOUT_FILENO);
6969        }
6970        // zclose (not raw close) so the FDT_INTERNAL mark set by mpipe
6971        // is cleared from fdtable — c:Src/utils.c:2137.
6972        crate::ported::utils::zclose(write_fd);
6973
6974        // Drain the capture pipe CONCURRENTLY on a background reader
6975        // thread. The sub-VM (and any children it forks, which inherit
6976        // fd 1) writes to the pipe; reading it only AFTER vm.run()
6977        // returns deadlocks the moment the output exceeds the OS pipe
6978        // buffer (~64KB): the writer blocks on a full pipe that nothing
6979        // is draining, so vm.run() never returns. `$(alias)` over
6980        // zpwr's 2000+ aliases (~177KB) hung the whole shell a few
6981        // prompts in (thefuck's `fuck()` init runs `TF_SHELL_ALIASES=
6982        // $(alias)`). C's getoutput (Src/exec.c) forks the writer child
6983        // so the parent reads concurrently; this reader thread is the
6984        // in-process analog. It does only raw fd reads (no shell state /
6985        // thread-locals). EOF arrives once every write end closes — fd 1
6986        // restored below plus any forked child exiting.
6987        let reader_handle = crate::signal_thread::spawn(move || {
6988            let mut buf: Vec<u8> = Vec::new();
6989            let mut chunk = [0u8; 65536];
6990            loop {
6991                let n = unsafe {
6992                    libc::read(
6993                        read_fd,
6994                        chunk.as_mut_ptr() as *mut libc::c_void,
6995                        chunk.len(),
6996                    )
6997                };
6998                if n < 0 {
6999                    // Retry on EINTR (a signal interrupted the read);
7000                    // any other error ends the drain.
7001                    let e = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
7002                    if e == libc::EINTR {
7003                        continue;
7004                    }
7005                    break;
7006                }
7007                if n == 0 {
7008                    break; // EOF — all write ends closed.
7009                }
7010                buf.extend_from_slice(&chunk[..n as usize]);
7011            }
7012            buf
7013        });
7014
7015        // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
7016        // which does `zsh_subshell++`; in-process equivalent (RAII,
7017        // restored on every return path below).
7018        // A funsub/valsub is NOT a subshell — ksh(1) says the command runs
7019        // "in the current shell environment" — so it must not bump the
7020        // nesting counter `$ZSH_SUBSHELL` / `$BASH_SUBSHELL` reads.
7021        let _subshell_bump = if shared_state {
7022            None
7023        } else {
7024            Some(crate::fusevm_bridge::CmdSubstSubshellBump::enter())
7025        };
7026        // A funsub/valsub shares the shell, so only a real substitution drops -e.
7027        let _bash_errexit = (!shared_state).then(crate::dash_mode::BashCmdsubstErrexit::enter);
7028
7029        // c:Src/exec.c:1208-1209 — the same forked child clears
7030        // `opts[USEZLE]` and `zleactive`. Without it a substitution run
7031        // from inside a widget still looks "in ZLE", so `fc` refuses with
7032        // "no interactive history within ZLE" (c:Src/builtin.c:1523-1527)
7033        // and history-based completers come back empty. Placed here rather
7034        // than in exec::getoutput so the bridge's own cmdsubst paths
7035        // (BUILTIN_CMD_SUBST_TEXT, backtick) are covered too.
7036        let _subsh_state = crate::ported::exec::SubshStateGuard::enter();
7037
7038        // Parse + compile + run.
7039        // Push CS_CMDSUBST for `%_` xtrace prefix — direct port of
7040        // Src/exec.c:4783 `cmdpush(CS_CMDSUBST);` around execode().
7041        // Trace lines emitted by the inner program inherit this token
7042        // so their PS4 prefix shows "cmdsubst" matching zsh -x.
7043        cmdpush(crate::ported::zsh_h::CS_CMDSUBST as u8); // c:zsh.h:2799
7044                                                          // Save LINENO so the inner cmdsubst's line counter doesn't
7045                                                          // leak into the outer trace — direct port of Src/exec.c:1407
7046                                                          // `oldlineno = lineno;` followed by `lineno = oldlineno;`
7047                                                          // restore at line 1640. Inner program parses fresh as line 1
7048                                                          // and increments from there; once it returns, the outer
7049                                                          // line at the `$(…)` site must read the original outer
7050                                                          // lineno (so xtrace renders `+:5:> echo …` not `+:1:> …`).
7051        let saved_lineno = getsparam("LINENO");
7052        // Anchor the inner program's lineno to the outer's current
7053        // $LINENO so xtrace inside the cmdsubst renders the outer
7054        // line. zsh's execlist preserves lineno across the inner
7055        // exec — for our sub-VM (fresh compile) nested_lineno_base maps
7056        // inner line N → outer_lineno + (N - 1), outer 0 included.
7057        let outer_lineno: u64 = self
7058            .scalar("LINENO")
7059            .and_then(|s| s.parse::<u64>().ok())
7060            .unwrap_or(0);
7061        // Mirror Src/init.c errflag save/clear/check pattern around
7062        // the nested parse so an inner syntax error doesn't bleed into
7063        // the outer execution.
7064        let saved_errflag = errflag.load(Ordering::Relaxed);
7065        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
7066        // Context-isolated nested parse (c:Src/exec.c:283 parse_string).
7067        // The outer loop()/parse_event reader may be mid-stream when this
7068        // cmd-subst executes (single-event mode), so a destructive
7069        // parse_init/lex_init would clobber its next read. parse_isolated
7070        // brackets the parse with zcontext_save/restore + inpush/inpop.
7071        let parsed = parse_isolated_at(cmd_str, Some(outer_lineno)); // c:4778 parse_string(cmd, 0)
7072        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
7073        errflag.store(saved_errflag, Ordering::Relaxed);
7074        let prog = if parse_failed { None } else { Some(parsed) };
7075        let mut cmd_status: Option<i32> = None;
7076        if let Some(prog) = prog {
7077            let mut compiler = crate::compile_zsh::ZshCompiler::new();
7078            compiler.nested_lineno_base = Some(outer_lineno); // c:Src/exec.c:4778
7079            let chunk = compiler.compile(&prog);
7080            if !chunk.ops.is_empty() {
7081                crate::fusevm_disasm::maybe_print_stdout("run_command_substitution", &chunk);
7082                // c:Src/exec.c:4783 — `$(...)` runs in a subshell, so
7083                // assignments / setopt / cd / trap changes inside
7084                // mustn't leak to the parent. zsh forks; we run
7085                // in-process and snapshot/restore manually. Same
7086                // snapshot shape used by host_subshell_begin/end for
7087                // the `(...)` subshell form.
7088                let paramtab_snap = crate::ported::params::paramtab()
7089                    .read()
7090                    .ok()
7091                    .map(|t| t.clone())
7092                    .unwrap_or_default();
7093                let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
7094                    .lock()
7095                    .ok()
7096                    .map(|m| m.clone())
7097                    .unwrap_or_default();
7098                let pparams_snap = self.pparams();
7099                let opts_snap = crate::ported::options::opt_state_snapshot();
7100                // c:Src/exec.c:1161 — a command substitution runs in a
7101                // subshell, so IFS changes inside it must NOT leak to the
7102                // parent. IFS lives in the external `ifs_lock` global (not
7103                // paramtab), so the paramtab snapshot above doesn't cover
7104                // it: `echo $(IFS=:; set -- a b c; echo "$*")` set IFS=":"
7105                // which both produced "a:b:c" AND then word-split the
7106                // UNQUOTED result on the leaked ":" → "a b c". Snapshot the
7107                // global IFS here and restore it (with inittyptab) below.
7108                let ifs_snap = crate::ported::params::ifs_lock()
7109                    .lock()
7110                    .map(|g| g.clone())
7111                    .unwrap_or_default();
7112                let traps_snap = crate::ported::builtin::traps_table()
7113                    .lock()
7114                    .map(|t| t.clone())
7115                    .unwrap_or_default();
7116                // c:Src/exec.c:4783 — function definitions / unfunction
7117                // inside `$(...)` must also be isolated from the parent.
7118                // C zsh's getoutput() forks, so the child's shfunctab
7119                // mutations die with the child. zshrs's in-process
7120                // cmd-subst needs to snapshot/restore the function
7121                // tables manually alongside the param/opts/trap snaps
7122                // already in this block. Bug #455.
7123                let shfunctab_snap = crate::ported::hashtable::shfunctab_lock()
7124                    .read()
7125                    .ok()
7126                    .map(|t| t.snapshot())
7127                    .unwrap_or_default();
7128                let functions_compiled_snap = self.functions_compiled.clone();
7129                let function_source_snap = self.function_source.clone();
7130                // c:Src/exec.c:4816 — what the forked child owns a copy of:
7131                // hash tables, `environ`, cwd, umask, rlimits, …; see
7132                // SubshForkCopy.
7133                // A funsub/valsub runs in the current shell and keeps it all.
7134                let tables_snap = (!shared_state).then(crate::ported::exec::SubshForkCopy::save);
7135                let locale_snap = (!shared_state).then(subsh_locale_save).flatten();
7136                let fd_frame = (!shared_state).then(crate::ported::exec::SubshFdFrame::enter);
7137                // c:Src/exec.c:1127-1131 — the child's `entersubsh` unsets
7138                // every trap that is not function-form (`$(trap)` lists
7139                // nothing), in ITS copy of `sigtrapped[]`. Keep the
7140                // parent's for the restore, and route signals that arrive
7141                // meanwhile to it — same as `( … )`, see
7142                // fusevm_bridge::subshell_defer_signal.
7143                let sigtrapped_snap = (!shared_state).then(|| {
7144                    let parent = crate::ported::signals::sigtrapped
7145                        .lock()
7146                        .map(|g| g.clone())
7147                        .unwrap_or_default();
7148                    crate::fusevm_bridge::subshell_signal_enter();
7149                    crate::fusevm_bridge::entersubsh_reset_traps();
7150                    parent
7151                });
7152                // c:Src/exec.c:4782 — getoutput's child runs
7153                // `entersubsh(ESUB_PGRP|ESUB_NOMONITOR)`, and c:1219
7154                // `if (flags & ESUB_PGRP) clearjobtab(monitor)` hands
7155                // that child an EMPTY job table. The oldjobtab snapshot
7156                // (c:Src/jobs.c:1800) is monitor-only, so a
7157                // non-interactive shell keeps nothing at all — which is
7158                // why zsh prints nothing for `sleep 5 & print $(jobs)`.
7159                // The `(...)` and pipeline-stage paths already call
7160                // clearjobtab in their forked children; cmd-subst runs
7161                // in-process, so snapshot the globals clearjobtab
7162                // mutates and restore them below. freejob (c:1457) is
7163                // struct-local — no waitpid/kill — so the restore is
7164                // exact.
7165                // c:Src/exec.c:4782 — same fork, one more thing it copies:
7166                // the completion-match arena (c:Src/Zle/compcore.c:124-259).
7167                // A `compadd` run inside `$(…)` lands in the CHILD's
7168                // `matches`/`amatches`/`mgroup`, which die with it, so the
7169                // completing parent never sees those matches. zshrs's
7170                // in-process cmd-subst shares the arena, so `_tmux`'s
7171                // `desc="$(_tmux-backup)"` description probe leaked five
7172                // whole completion groups into `tmux <TAB>` (551 matches vs
7173                // zsh's 450). Snapshot/restore it by hand, exactly as the
7174                // param/opts/trap/job snaps above do.
7175                let comp_arena_snap = crate::comp_match_handles::comp_arena_save();
7176                // !!! WARNING: RUST-ONLY LOCK DISCIPLINE !!! zhandler's SIGCHLD
7177                // branch locks JOBTAB (c:Src/signals.c:429-431 → update_bg_job)
7178                // on whichever thread the signal interrupts. C's jobtab is a
7179                // plain array; here a SIGCHLD landing while this thread holds
7180                // the JOBTAB mutex (the snapshot, clearjobtab, and the restore
7181                // below, which also drops the child's table under the lock)
7182                // blocks the handler forever: `echo $(echo a &) w` hung.
7183                // Queue signals across both windows (c:Src/signals.c:410-424).
7184                crate::ported::signals_h::queue_signals();
7185                let jobtab_snap = crate::ported::jobs::JOBTAB
7186                    .get()
7187                    .and_then(|t| t.lock().ok().map(|g| g.clone()));
7188                let maxjob_snap = crate::ported::jobs::MAXJOB
7189                    .get()
7190                    .and_then(|m| m.lock().ok().map(|g| *g));
7191                let thisjob_snap = crate::ported::jobs::THISJOB
7192                    .get()
7193                    .and_then(|t| t.lock().ok().map(|g| *g));
7194                // curjob/prevjob (c:Src/jobs.c) are plain globals in C,
7195                // so the forked child's setcurjob calls never reach the
7196                // parent — restore them alongside the table, else the
7197                // `+`/`-` markers are lost after a `$(jobs)`.
7198                let curjob_snap = crate::ported::jobs::CURJOB
7199                    .get()
7200                    .and_then(|t| t.lock().ok().map(|g| *g));
7201                let prevjob_snap = crate::ported::jobs::PREVJOB
7202                    .get()
7203                    .and_then(|t| t.lock().ok().map(|g| *g));
7204                // Pipeline job slots the body takes come from the cleared
7205                // table below; their records go when the parent's comes back.
7206                let slots_snap = crate::exec_jobs::execpline_slots_mark();
7207                {
7208                    let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
7209                    crate::ported::jobs::clearjobtab(&mut self.jobs, monitor);
7210                }
7211                crate::ported::signals_h::unqueue_signals();
7212                let mut vm = fusevm::VM::new(chunk);
7213                register_builtins(&mut vm);
7214                vm.set_shell_host(Box::new(ZshrsHost));
7215                // Seed inner $? with the outer's last_status so the
7216                // sub-shell inherits the parent's exit code. Direct
7217                // port of Src/exec.c:4783 around execcmd_exec — the
7218                // child inherits `lastval` at fork time, so `false;
7219                // echo $(echo $?)` reads 1, not the freshly-zeroed
7220                // sub-VM default. Without this, every cmd-subst
7221                // started with $?==0 regardless of the parent's
7222                // last command.
7223                vm.last_status = self.last_status();
7224                // `exit N` inside a cmd-subst should terminate ONLY
7225                // the sub-shell (C zsh: cmd-subst forks, the child
7226                // `_exit(N)`s; status reaches the parent as
7227                // cmd-subst exit). zshrs runs in-process, so we
7228                // route through the SUBSHELL_DEPTH-gated deferred
7229                // path inside zexit (builtin.rs:7713): bump
7230                // SUBSHELL_DEPTH so `exit` sets EXIT_PENDING/
7231                // EXIT_VAL instead of calling realexit (which would
7232                // process::exit and kill the parent shell). After
7233                // the sub-VM returns, harvest EXIT_PENDING/EXIT_VAL
7234                // as the cmd-subst's status, then restore the
7235                // parent's flags so the outer VM continues normally.
7236                use crate::ported::builtin::{
7237                    BREAKS, EXIT_PENDING, EXIT_VAL, RETFLAG, SHELL_EXITING, SUBSHELL_DEPTH,
7238                };
7239                use std::sync::atomic::Ordering::Relaxed;
7240                let saved_exit_pending = EXIT_PENDING.swap(0, Relaxed);
7241                let saved_exit_val = EXIT_VAL.swap(0, Relaxed);
7242                let saved_shell_exiting = SHELL_EXITING.swap(0, Relaxed);
7243                let saved_retflag = RETFLAG.swap(0, Relaxed);
7244                let saved_breaks = BREAKS.swap(0, Relaxed);
7245                // c:Src/exec.c:4784 — `execode(prog, 0, 1, "cmdsubst");`.
7246                // execode (c:1245-1266) APPENDS its `context` argument to
7247                // `zsh_eval_context` for the duration of the body, so code
7248                // inside `$(…)` / backticks sees `cmdarg:cmdsubst` where the
7249                // top level sees just `cmdarg`. zshrs pushed "shfunc" at the
7250                // function-call site but never pushed "cmdsubst", so
7251                // `$(print $ZSH_EVAL_CONTEXT)` reported `cmdarg` and
7252                // `$(f)` reported `cmdarg:shfunc` instead of
7253                // `cmdarg:cmdsubst:shfunc`. Popped on every return path by the
7254                // guard below, mirroring execode's stack discipline.
7255                // Bug #1065.
7256                let sync_eval_ctx = |stack: &[String]| {
7257                    crate::ported::exec::EvalContextFrame::sync(stack);
7258                };
7259                if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
7260                    ctx.push("cmdsubst".to_string());
7261                    sync_eval_ctx(&ctx);
7262                }
7263                struct CmdsubstEvalCtxGuard<F: Fn(&[String])>(F);
7264                impl<F: Fn(&[String])> Drop for CmdsubstEvalCtxGuard<F> {
7265                    fn drop(&mut self) {
7266                        if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
7267                            ctx.pop();
7268                            (self.0)(&ctx);
7269                        }
7270                    }
7271                }
7272                let _cs_eval_ctx_guard = CmdsubstEvalCtxGuard(sync_eval_ctx);
7273                SUBSHELL_DEPTH.fetch_add(1, Relaxed);
7274                let _ctx = ExecutorContext::enter(self);
7275                let _ = vm.run();
7276                let inner_exit_pending = EXIT_PENDING.load(Relaxed);
7277                let inner_exit_val = EXIT_VAL.load(Relaxed);
7278                let inner_status = if inner_exit_pending != 0 {
7279                    inner_exit_val & 0xFF
7280                } else {
7281                    vm.last_status
7282                };
7283                cmd_status = Some(inner_status);
7284                SUBSHELL_DEPTH.fetch_sub(1, Relaxed);
7285                // c:Src/exec.c — `$(…)` is a FORK in C: an errflag
7286                // abort inside the child ends the child (its lastval
7287                // becomes the cmd-subst status) and the flag dies
7288                // with the child process — the parent's lists keep
7289                // running. zsh 5.9: `v=$(typeset -A q; q=(odd));
7290                // echo "after $?"` prints `after 1`. Mirror the fork
7291                // isolation by clearing ERRFLAG_ERROR at the
7292                // cmd-subst boundary.
7293                //
7294                // ERRFLAG_HARD dies here too: `${u:?msg}` inside the
7295                // child sets it (c:Src/subst.c:3344) then `_exit(1)`s
7296                // (c:3353) — C's parent never sees the bit. A leaked
7297                // HARD bit makes every later zerr() silent
7298                // (c:Src/utils.c:175-177) and silently fails every
7299                // later parse. Same fix as subshell_end.
7300                // A nofork `${ … }` is no fork: c:Src/subst.c:2056 leaves the
7301                // body's errflag set, so the command around it aborts.
7302                if !shared_state {
7303                    errflag.fetch_and(
7304                        !(ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
7305                        Relaxed,
7306                    );
7307                }
7308                // c:Src/exec.c:4783 execcmdoutsubst — `$(...)` is a
7309                // subshell, and zsh fires the EXIT trap when the
7310                // subshell ends BUT only if the trap was installed
7311                // INSIDE the subshell. An EXIT trap inherited from
7312                // the parent fires when the parent shell exits, not
7313                // again at cmdsub end. Detect "installed inside" by
7314                // comparing the current traps_table["EXIT"] entry
7315                // against the pre-cmdsub snapshot — fire only when
7316                // the body differs (newly set, removed, or replaced).
7317                // Pop the body before execute_script to avoid the
7318                // re-fire inside execute_script_zsh_pipeline's own
7319                // EXIT-handler tail at vm_helper.rs:1490. Bug #354.
7320                let snap_exit = traps_snap.get("EXIT").cloned();
7321                let live_exit = crate::ported::builtin::traps_table()
7322                    .lock()
7323                    .ok()
7324                    .and_then(|t| t.get("EXIT").cloned());
7325                if !shared_state && live_exit != snap_exit {
7326                    if let Some(body) = live_exit {
7327                        if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
7328                            t.remove("EXIT");
7329                        }
7330                        let _ = crate::ported::exec::execute_script(&body);
7331                    }
7332                }
7333                // c:Src/signals.c::dotrap(SIGEXIT) — also fire the
7334                // TRAPEXIT() function-named form (ZSIG_FUNC) — but
7335                // only if it was defined INSIDE the subshell (the
7336                // parent's TRAPEXIT fires at parent exit, not here).
7337                // ZSIG_FUNC bit on sigtrapped[SIGEXIT] tells us
7338                // whether a TRAPEXIT function is registered; it is read
7339                // BEFORE the snapshot restore, and an inherited one (flag
7340                // already set in the parent's snapshot) is left alone.
7341                if let Some(parent) = &sigtrapped_snap {
7342                    let func = crate::ported::zsh_h::ZSIG_FUNC as i32;
7343                    let exit_idx = crate::signals_h::SIGEXIT as usize;
7344                    let live = crate::ported::signals::sigtrapped
7345                        .lock()
7346                        .ok()
7347                        .and_then(|g| g.get(exit_idx).copied())
7348                        .unwrap_or(0);
7349                    let inherited = parent.get(exit_idx).copied().unwrap_or(0);
7350                    if live & func != 0 && inherited & func == 0 {
7351                        crate::ported::signals::intrap.fetch_add(1, Ordering::SeqCst); // c:1123
7352                        let _ = crate::ported::exec::execute_script("TRAPEXIT");
7353                        crate::ported::signals::intrap.fetch_sub(1, Ordering::SeqCst); // c:1236
7354                    }
7355                }
7356                // Restore parent's exit / loop / function-return
7357                // state so the outer VM continues normally.
7358                //
7359                // A nofork `${ … }` runs in the CURRENT shell, so only `return`
7360                // stops at it (c:Src/subst.c:2049-2053 `if (retflag) { retflag
7361                // = 0; breaks = obreaks; }`): an `exit` stays pending for the
7362                // caller's c:2094 zexit and a `break` keeps unwinding the loop
7363                // around the command.
7364                if shared_state {
7365                    if RETFLAG.load(Relaxed) != 0 {
7366                        RETFLAG.store(saved_retflag, Relaxed); // c:2051
7367                        BREAKS.store(saved_breaks, Relaxed); // c:2052
7368                    }
7369                } else {
7370                    EXIT_PENDING.store(saved_exit_pending, Relaxed);
7371                    EXIT_VAL.store(saved_exit_val, Relaxed);
7372                    SHELL_EXITING.store(saved_shell_exiting, Relaxed);
7373                    RETFLAG.store(saved_retflag, Relaxed);
7374                    BREAKS.store(saved_breaks, Relaxed);
7375                }
7376                // Restore parent state. The inner cmd-subst's stdout
7377                // (the captured pipe contents) is the only thing
7378                // that leaks out.
7379                //
7380                // A funsub/valsub skips ALL of it: that is the entire
7381                // difference between `${ list; }` and `$(list)`.
7382                if !shared_state {
7383                    // First: rolling a module back looks its parameters up
7384                    // in the LIVE paramtab (see SubshForkCopy::restore).
7385                    if let Some(snap) = tables_snap {
7386                        snap.restore();
7387                    }
7388                    // Before fd 1 and 2 go back to the parent below.
7389                    drop(fd_frame);
7390                    if let Ok(mut t) = crate::ported::params::paramtab().write() {
7391                        *t = paramtab_snap;
7392                    }
7393                    if let Ok(mut m) = crate::ported::params::paramtab_hashed_storage().lock() {
7394                        *m = paramtab_hashed_snap;
7395                    }
7396                    self.set_pparams(pparams_snap);
7397                    crate::ported::options::opt_state_restore(opts_snap);
7398                    // Restore the parent's IFS (subshell isolation): the body's
7399                    // `IFS=` must not leak out and word-split the parent's use
7400                    // of the cmdsub result. Runtime word-splitting reads the
7401                    // IFS *string* (this `ifs_lock` global), so restoring it is
7402                    // sufficient. Deliberately do NOT call inittyptab() here —
7403                    // that rewrites the process-global typtab the LEXER reads
7404                    // on every character, and firing it per-cmdsub races
7405                    // concurrent lexing in zshrs's worker threads, producing
7406                    // spurious "parse error" flakes (HEAD ran clean 3/3; the
7407                    // per-cmdsub inittyptab flaked ~50%). The typtab only
7408                    // affects re-lexing — the parent is already compiled — and
7409                    // leaving it at the body's value is strictly less divergent
7410                    // than the prior behavior, which leaked the whole IFS.
7411                    // Except when the body CHANGED IFS: its `ifssetfn` (or the
7412                    // unset's c:Src/params.c:4748-4749) already rebuilt the typtab
7413                    // that word splitting reads, so the parent's table has to come
7414                    // back with its value (`IFS=:; print $(IFS=,; :); s="p,q";
7415                    // print -rl -- ${=s}` is one word in zsh). Only that case
7416                    // rebuilds, so the per-cmdsub race above does not return.
7417                    let ifs_changed = crate::ported::params::ifs_lock()
7418                        .lock()
7419                        .map(|mut g| {
7420                            let changed = *g != ifs_snap;
7421                            *g = ifs_snap;
7422                            changed
7423                        })
7424                        .unwrap_or(false);
7425                    if ifs_changed {
7426                        crate::ported::utils::inittyptab();
7427                    }
7428                    // The body's `LC_ALL=` / `LANG=` changed only the
7429                    // forked child's locale in C.
7430                    subsh_locale_restore(locale_snap.as_deref());
7431                    if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
7432                        *t = traps_snap;
7433                    }
7434                    // The trap flags, and the `sigaction`s the body installed
7435                    // for its own traps, go back to the parent's.
7436                    if let Some(parent) = &sigtrapped_snap {
7437                        let child = match crate::ported::signals::sigtrapped.lock() {
7438                            Ok(mut st) => std::mem::replace(&mut *st, parent.clone()),
7439                            Err(_) => Vec::new(),
7440                        };
7441                        crate::fusevm_bridge::subshell_restore_signal_dispositions(parent, &child);
7442                    }
7443                    // Restore function tables (parallel to the trap/param
7444                    // restore above). Bug #455.
7445                    if let Ok(mut t) = crate::ported::hashtable::shfunctab_lock().write() {
7446                        t.restore(shfunctab_snap);
7447                    }
7448                    self.functions_compiled = functions_compiled_snap;
7449                    self.function_source = function_source_snap;
7450                    // Discard anything the substitution added to the completion
7451                    // arena — the in-process stand-in for the forked child's
7452                    // address space going away (see comp_arena_save above).
7453                    crate::comp_match_handles::comp_arena_restore(comp_arena_snap);
7454                    // Undo the clearjobtab above — in C the cleared table
7455                    // belongs to the forked child and dies with it, so the
7456                    // parent's table must come back untouched. Signals stay
7457                    // queued while the job mutexes are held (see the snapshot).
7458                    crate::ported::signals_h::queue_signals();
7459                    if let (Some(js), Some(t)) = (jobtab_snap, crate::ported::jobs::JOBTAB.get()) {
7460                        if let Ok(mut g) = t.lock() {
7461                            *g = js;
7462                        }
7463                    }
7464                    if let (Some(mj), Some(m)) = (maxjob_snap, crate::ported::jobs::MAXJOB.get()) {
7465                        if let Ok(mut g) = m.lock() {
7466                            *g = mj;
7467                        }
7468                    }
7469                    if let (Some(tj), Some(t)) = (thisjob_snap, crate::ported::jobs::THISJOB.get())
7470                    {
7471                        if let Ok(mut g) = t.lock() {
7472                            *g = tj;
7473                        }
7474                    }
7475                    if let (Some(cj), Some(t)) = (curjob_snap, crate::ported::jobs::CURJOB.get()) {
7476                        if let Ok(mut g) = t.lock() {
7477                            *g = cj;
7478                        }
7479                    }
7480                    if let (Some(pj), Some(t)) = (prevjob_snap, crate::ported::jobs::PREVJOB.get())
7481                    {
7482                        if let Ok(mut g) = t.lock() {
7483                            *g = pj;
7484                        }
7485                    }
7486                    crate::exec_jobs::execpline_slots_forget_to(slots_snap);
7487                    crate::ported::signals_h::unqueue_signals();
7488                    // Now that the parent's traps are back, run the ones
7489                    // whose signal arrived during the body.
7490                    if sigtrapped_snap.is_some() {
7491                        crate::fusevm_bridge::subshell_signal_leave();
7492                    }
7493                } // if !shared_state
7494            }
7495        }
7496        // Restore LINENO so outer xtrace sees the outer line. LINENO
7497        // carries PM_READONLY (matching zsh's `integer-readonly-special`
7498        // GSU), so the restore must bypass the generic readonly guard
7499        // exactly like BUILTIN_SET_LINENO (fusevm_bridge.rs:5156) — write
7500        // the param's `u_val` directly and mirror the file-static /
7501        // lexer line counters. The previous `set_scalar` went through the
7502        // readonly-checked path: harmless on the `-c` route (LINENO not
7503        // yet flagged readonly there) but fatal on the faithful
7504        // loop()/zsh_main route, where every `$(...)` in piped/redirected
7505        // input died with `read-only variable: LINENO`.
7506        if let Some(ln) = saved_lineno {
7507            let n: crate::ported::zsh_h::zlong = ln.parse().unwrap_or(0);
7508            if let Ok(mut tab) = crate::ported::params::paramtab().write() {
7509                if let Some(pm) = tab.get_mut("LINENO") {
7510                    // c:Src/utils.c:121 `zlong lineno` — the value lives in the C
7511                    // GLOBAL, reached through LINENO's GSU. A `typeset -h +g LINENO`
7512                    // local shadow has no PM_SPECIAL and no GSU, so C's `lineno = N`
7513                    // never touches it; skip the paramtab mirror for the same reason.
7514                    if (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) != 0 {
7515                        pm.u_val = n;
7516                        pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
7517                    }
7518                }
7519            }
7520            crate::ported::utils::set_lineno(n as i32);
7521            crate::ported::lex::set_lineno(n as u64);
7522        }
7523        cmdpop();
7524        // Propagate the inner cmd's status to the parent shell. zsh:
7525        // `a=$(false); echo $?` → 1 because cmd-subst status leaks to
7526        // $?. Set last_status on the executor so $? reads the right
7527        // value for callers that don't have a SetStatus(0) overwrite
7528        // (echo, test, etc.). Bare assignment paths still get the
7529        // SetStatus(0) from compile_simple — that's a separate gap.
7530        // Empty cmd-subst (`\`\``, `$()`) resets status to 0 per
7531        // Src/exec.c — the inner ran no command so the "last
7532        // command's exit" is the implicit success of "did nothing".
7533        // Without this branch, a prior command's non-zero status
7534        // leaked through the empty cmd-subst.
7535        let final_status = cmd_status.unwrap_or(0);
7536        self.set_last_status(final_status);
7537        // c:Src/exec.c:4775 — `getoutput` (the C cmd-subst path used by
7538        // both `$(…)` and `` `…` ``) propagates the inner exit through
7539        // `cmdoutval`, then the caller does `LASTVAL = cmdoutval`. Mirror
7540        // by writing the cmd-subst's exit into the ported `cmdoutval`
7541        // global so `getoutput()`'s post-call `LASTVAL = cmdoutval` (at
7542        // exec.rs:559-562) and the C-equivalent `cmdoutval = lastval`
7543        // bookkeeping in execcmd_exec's assignment paths both see the
7544        // real exit. Without this, backtick assignments (`a=\`false\`;
7545        // echo $?`) reported 0 because getoutput's caller path read a
7546        // cmdoutval that was never updated by the in-process hook.
7547        crate::ported::exec::cmdoutval.store(final_status, std::sync::atomic::Ordering::Relaxed);
7548
7549        // Flush any buffered Rust-side stdout so it reaches the pipe
7550        // before we restore.
7551        let _ = io::stdout().flush();
7552
7553        // Pop the trap-routing stack BEFORE restoring stdout so any
7554        // trap that fires during the restore goes to the cmdsub's
7555        // pipe (matching what zsh's forked cmdsub would do — the
7556        // child's fd 1 is the pipe right up until the child exits).
7557        crate::fusevm_bridge::CMDSUBST_OUTER_FDS.with(|s| {
7558            s.borrow_mut().pop();
7559        });
7560        // c:Bug #353 — restore fd 2 from the saved outer stderr. A
7561        // body that ran `exec 2>&1` (no command, just redirects)
7562        // would have committed fd 2 → the cmdsub's pipe write end.
7563        // In zsh's forked cmdsub the committed redirect dies with
7564        // the child; zshrs's in-process cmdsub would leak the dup
7565        // back to the parent and keep the pipe write-end alive,
7566        // blocking the parent's read on the read_end forever.
7567        // Always restoring fd 2 here rolls back any commit so the
7568        // pipe write-end count drops to zero when we drop the
7569        // local write_fd reference (which already happened above).
7570        if saved_stderr_for_trap >= 0 {
7571            unsafe {
7572                libc::dup2(saved_stderr_for_trap, libc::STDERR_FILENO);
7573            }
7574            crate::ported::utils::zclose(saved_stderr_for_trap);
7575        }
7576        // Restore stdout and read what was captured.
7577        if saved_stdout >= 0 {
7578            unsafe {
7579                libc::dup2(saved_stdout, libc::STDOUT_FILENO);
7580            }
7581            crate::ported::utils::zclose(saved_stdout);
7582        } else {
7583            unsafe { libc::close(libc::STDOUT_FILENO) }; // c:2048 zclose(y)
7584        }
7585        // Collect the concurrently-drained output. With fd 1 restored
7586        // above, the last shell-side write end is closed, so the reader
7587        // hits EOF and join() returns the full buffer regardless of
7588        // size — no pipe-full deadlock. The reader only read (never
7589        // closed) read_fd, so zclose still clears its FDT_INTERNAL mark
7590        // (c:Src/utils.c:2137).
7591        let bytes = reader_handle.join().unwrap_or_default();
7592        crate::ported::utils::zclose(read_fd);
7593        // This is the `$( cmd )` counterpart of `readoutput`'s c:4835-4849
7594        // conversion, and it has to be just as lossless: with
7595        // `String::from_utf8_lossy` a helper that emitted a latin-1 byte —
7596        // which is routine for the legacy completion corpus, where
7597        // `_call_program` shells out to tools whose descriptions predate
7598        // UTF-8 — had that byte replaced by U+FFFD before any completer saw
7599        // it. `decode_script_bytes` Meta-encodes only what is not valid
7600        // UTF-8, and `utils::unmetafy_str` restores the byte on output.
7601        let mut output = crate::script_bytes::decode_script_bytes(&bytes);
7602
7603        // POSIX: trailing newlines stripped from cmd-sub result.
7604        // c:Src/subst.c:1908 + c:2064-2069 — a nofork `${ … }` does its own
7605        // trim (`trim = !qt` under zsh emulation: ONE newline unquoted, NONE
7606        // quoted), so its caller gets the bytes untouched. Stripping them all
7607        // here left `"${ print INNER } $?"` as `INNER 7`, zsh `INNER\n 7`.
7608        while !shared_state && output.ends_with('\n') {
7609            output.pop();
7610        }
7611        // !!! RUST-ONLY: provenance tap. `$(…)` is a lineage ORIGIN —
7612        // these bytes did not exist in the shell before the inner list
7613        // ran. This is the in-process cmd-subst funnel (the host
7614        // `ShellHost::cmd_subst` path taps the same event for chunks
7615        // that reach the VM as sub-chunks instead of source text).
7616        if crate::provenance::active() {
7617            crate::provenance::on_cmd_subst(cmd_str, &output);
7618        }
7619        if parse_failed {
7620            // c:Src/exec.c:4778-4780 — `getoutput` returns NULL when
7621            // `parse_string` fails, and its caller raises the error:
7622            // c:Src/subst.c:400-402 `zerr("parse error in command
7623            // substitution"); return NULL;`, abandoning the command. Only a
7624            // body parsed at run time gets here (`` `…` ``); the lexer has
7625            // already parsed a `$(…)` body.
7626            crate::ported::utils::zerr("parse error in command substitution");
7627        }
7628        output
7629    }
7630}
7631
7632#[cfg(test)]
7633mod tests {
7634    use super::*;
7635
7636    #[test]
7637    fn test_simple_echo() {
7638        let _g = crate::test_util::global_state_lock();
7639        let mut exec = ShellExecutor::new();
7640        let status = exec.execute_script("true").unwrap();
7641        assert_eq!(status, 0);
7642    }
7643
7644    /// A `zsh/parameter` param is an autoload stub until it is READ; the read
7645    /// materializes it. `$parameters` enumerations type a stub as "undefined"
7646    /// (Src/Modules/parameter.c:49-50) and never resolve it, which is what
7647    /// puts those names in the right `_parameters -g` bucket.
7648    ///
7649    /// Reference behavior (`zsh -f`):
7650    ///   `m=( ${(kv)parameters} ); print $m[aliases]`            → undefined
7651    ///   `${parameters[aliases]}` first, then the same scan      → association-…
7652    #[test]
7653    fn module_params_are_autoload_stubs_until_read() {
7654        let _g = crate::test_util::global_state_lock();
7655        // `jobstates` is not touched by shell STARTUP, but
7656        // `MATERIALIZED_MODULE_PARAMS` is a process-global side-set and 10k
7657        // tests share one process — `compsys::_jobs` and friends read the job
7658        // parameters, so by the time this runs the stub may already have been
7659        // materialized. The lock serialises but restores nothing, so put the
7660        // name back to its untouched state here rather than assume nothing
7661        // else in the binary ever reads it. The assertions below are
7662        // unchanged: an untouched stub reads as a stub, and a read
7663        // materializes it.
7664        unmark_module_param_used("jobstates");
7665        assert!(
7666            module_param_is_autoload_stub("jobstates"),
7667            "untouched module param must read as a stub"
7668        );
7669        mark_module_param_used("jobstates");
7670        assert!(
7671            !module_param_is_autoload_stub("jobstates"),
7672            "a read must materialize it"
7673        );
7674        // A core special (not module-provided) is never a stub.
7675        assert!(!module_param_is_autoload_stub("path"));
7676        assert!(!module_param_is_autoload_stub("PATH"));
7677    }
7678
7679    /// Phase 3 diagnostic: a worker must be able to run a USER-DEFINED function
7680    /// (defined on the main executor) — the function source lives in the shared
7681    /// shfunctab, and the worker lazy-compiles it from there. Its `typeset -g`
7682    /// must reach the global param table. This is what `async_precmd` needs.
7683    #[test]
7684    fn phase3_worker_runs_user_defined_function() {
7685        let _g = crate::test_util::global_state_lock();
7686        let mut main = ShellExecutor::new();
7687        main.execute_script("phase3fn() { typeset -g PHASE3_FN_RESULT=fn_ran }")
7688            .unwrap();
7689        // sanity: it ran on main? (define only — not called yet)
7690        assert_eq!(getsparam("PHASE3_FN_RESULT"), None);
7691
7692        let pool = std::sync::Arc::new(crate::worker::WorkerPool::new(2));
7693        let pool2 = std::sync::Arc::clone(&pool);
7694        let (tx, rx) = std::sync::mpsc::channel::<()>();
7695        pool.submit(move || {
7696            let mut wex = ShellExecutor::new_worker(pool2);
7697            let _ = wex.execute_script_zsh_pipeline("phase3fn");
7698            let _ = tx.send(());
7699        });
7700        rx.recv().expect("worker completed");
7701        assert_eq!(
7702            getsparam("PHASE3_FN_RESULT"),
7703            Some("fn_ran".to_string()),
7704            "worker could not run the user-defined function from shared shfunctab"
7705        );
7706    }
7707
7708    /// Phase 1 of the in-process thread-execution model: prove a shell body
7709    /// runs on a POOL WORKER THREAD via `new_worker` and that its `typeset -g`
7710    /// lands in the GLOBAL (RwLock-synchronized) param table. Each worker writes
7711    /// a DISTINCT key, so a green run shows: (a) `ExecutorContext::enter` +
7712    /// `execute_script_zsh_pipeline` work off the main thread, and (b) N
7713    /// concurrent writers don't corrupt the shared table. This is the linchpin
7714    /// for converting the subprocess-forking parallel builtins to threads.
7715    #[test]
7716    fn phase1_worker_shell_writes_reach_global_paramtab() {
7717        let _g = crate::test_util::global_state_lock();
7718        // Seed the globals (options, default params) exactly as a live session
7719        // would — workers SHARE these; new_worker() never re-seeds them.
7720        let _main = ShellExecutor::new();
7721
7722        let pool = std::sync::Arc::new(crate::worker::WorkerPool::new(4));
7723        const N: usize = 16;
7724        let (tx, rx) = std::sync::mpsc::channel::<usize>();
7725        for i in 0..N {
7726            let tx = tx.clone();
7727            let pool_for_worker = std::sync::Arc::clone(&pool);
7728            pool.submit(move || {
7729                // Lightweight per-worker executor; shares the global tables.
7730                let mut wex = ShellExecutor::new_worker(pool_for_worker);
7731                let _ =
7732                    wex.execute_script_zsh_pipeline(&format!("typeset -g PHASE1_WK_{i}=val_{i}"));
7733                let _ = tx.send(i);
7734            });
7735        }
7736        drop(tx);
7737        // Barrier: wait for all N workers.
7738        let mut done = 0usize;
7739        while rx.recv().is_ok() {
7740            done += 1;
7741        }
7742        assert_eq!(done, N, "all {N} workers completed");
7743
7744        // Every worker's write must be visible in the global param table.
7745        for i in 0..N {
7746            assert_eq!(
7747                getsparam(&format!("PHASE1_WK_{i}")),
7748                Some(format!("val_{i}")),
7749                "worker {i} typeset -g did not reach the global paramtab"
7750            );
7751        }
7752    }
7753
7754    #[test]
7755    fn test_if_true() {
7756        let _g = crate::test_util::global_state_lock();
7757        let mut exec = ShellExecutor::new();
7758        let status = exec.execute_script("if true; then true; fi").unwrap();
7759        assert_eq!(status, 0);
7760    }
7761
7762    #[test]
7763    fn test_if_false() {
7764        let _g = crate::test_util::global_state_lock();
7765        let mut exec = ShellExecutor::new();
7766        let status = exec
7767            .execute_script("if false; then true; else false; fi")
7768            .unwrap();
7769        assert_eq!(status, 1);
7770    }
7771
7772    #[test]
7773    fn test_for_loop() {
7774        let _g = crate::test_util::global_state_lock();
7775        let mut exec = ShellExecutor::new();
7776        exec.execute_script("for i in a b c; do true; done")
7777            .unwrap();
7778        assert_eq!(exec.last_status(), 0);
7779    }
7780
7781    #[test]
7782    fn test_and_list() {
7783        let _g = crate::test_util::global_state_lock();
7784        let mut exec = ShellExecutor::new();
7785        let status = exec.execute_script("true && true").unwrap();
7786        assert_eq!(status, 0);
7787
7788        let status = exec.execute_script("true && false").unwrap();
7789        assert_eq!(status, 1);
7790    }
7791
7792    #[test]
7793    fn test_or_list() {
7794        let _g = crate::test_util::global_state_lock();
7795        let mut exec = ShellExecutor::new();
7796        let status = exec.execute_script("false || true").unwrap();
7797        assert_eq!(status, 0);
7798    }
7799
7800    /// Pin: `forklevel` matches the C global declared at
7801    /// `Src/exec.c:1052` (`int forklevel;`). Like `int` in C, the
7802    /// Rust port is an AtomicI32 starting at 0 (no fork has occurred
7803    /// at process start). Per `Src/exec.c:1221` (`forklevel =
7804    /// locallevel;`), every subshell entry copies `locallevel` into
7805    /// the global; the SIGPIPE handler at `Src/signals.c:808` reads
7806    /// it back to distinguish the top-level shell from a subshell.
7807    #[test]
7808    fn test_forklevel_default_zero_and_roundtrip() {
7809        let _g = crate::test_util::global_state_lock();
7810        use std::sync::atomic::Ordering;
7811        let prev = crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed);
7812        // Default state at process start: zero (matches C's BSS init
7813        // of `int forklevel;` to 0).
7814        crate::ported::exec::FORKLEVEL.store(0, Ordering::Relaxed);
7815        assert_eq!(crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed), 0);
7816        // Simulate the c:1221 store: `forklevel = locallevel;`.
7817        crate::ported::exec::FORKLEVEL.store(3, Ordering::Relaxed);
7818        assert_eq!(crate::ported::exec::FORKLEVEL.load(Ordering::Relaxed), 3);
7819        crate::ported::exec::FORKLEVEL.store(prev, Ordering::Relaxed);
7820    }
7821
7822    /// `zexecve_recover` is the PARENT-side half of C's `#!`-recovery block
7823    /// (c:Src/exec.c:534-627). Every `execve` in that block is preceded by
7824    /// `winch_unblock()` (c:565/570/580/584/626) because C is inside the
7825    /// forked child and is about to be replaced. This port returns to the
7826    /// caller instead, so those unblocks left the shell's standing
7827    /// `winch_block()` (c:Src/init.c:1458) dropped with nothing to re-arm
7828    /// it — and a SIGWINCH delivered inside a widget makes `calclist`
7829    /// (c:Src/Zle/compresult.c:1495) count display strings against a width
7830    /// they were not built at.
7831    ///
7832    /// The mask is per-thread, so blocking here cannot leak into other
7833    /// tests.
7834    #[test]
7835    #[cfg(unix)]
7836    fn zexecve_recover_keeps_sigwinch_blocked_in_the_parent() {
7837        fn sigwinch_blocked() -> bool {
7838            unsafe {
7839                let mut cur: libc::sigset_t = std::mem::zeroed();
7840                libc::pthread_sigmask(libc::SIG_BLOCK, std::ptr::null(), &mut cur);
7841                libc::sigismember(&cur, libc::SIGWINCH) == 1
7842            }
7843        }
7844
7845        let dir = std::env::temp_dir().join(format!(
7846            "zshrs_winch_recover_{}_{:?}",
7847            std::process::id(),
7848            std::thread::current().id()
7849        ));
7850        std::fs::create_dir_all(&dir).unwrap();
7851
7852        // c:611-627 — shebang-less text file: the `/bin/sh` fallback.
7853        let noshebang = dir.join("noshebang");
7854        std::fs::write(&noshebang, "echo hi\n").unwrap();
7855        // c:576-585 — `#!` line naming an absolute interpreter.
7856        let shebang = dir.join("shebang");
7857        std::fs::write(&shebang, "#!/bin/cat -v\necho hi\n").unwrap();
7858
7859        let saved = sigwinch_blocked();
7860        crate::ported::signals_h::winch_block(); // c:Src/init.c:1458
7861        assert!(sigwinch_blocked(), "precondition: winch_block() holds");
7862
7863        let argv = vec!["script".to_string()];
7864        let sh = zexecve_recover(noshebang.to_str().unwrap(), &argv, libc::ENOEXEC);
7865        assert_eq!(
7866            sh.as_ref().map(|(p, _)| p.as_str()),
7867            Ok("/bin/sh"),
7868            "c:625-627 — a shebang-less script re-execs through /bin/sh"
7869        );
7870        assert!(
7871            sigwinch_blocked(),
7872            "c:626 — the /bin/sh recovery must not unblock SIGWINCH in the parent"
7873        );
7874
7875        let interp = zexecve_recover(shebang.to_str().unwrap(), &argv, libc::ENOEXEC);
7876        assert_eq!(
7877            interp.as_ref().map(|(p, _)| p.as_str()),
7878            Ok("/bin/cat"),
7879            "c:576-585 — the `#!` line names the interpreter"
7880        );
7881        assert!(
7882            sigwinch_blocked(),
7883            "c:580/c:584 — the `#!` recovery must not unblock SIGWINCH in the parent"
7884        );
7885
7886        if !saved {
7887            crate::ported::signals_h::winch_unblock();
7888        }
7889        let _ = std::fs::remove_dir_all(&dir);
7890    }
7891
7892    /// c:Src/exec.c:544 `*argv = pth;` — the shebang-less `/bin/sh`
7893    /// fallback (c:611-627) hands sh the PATH being executed and applies the
7894    /// c:616 `-`/`+` guard to it, not to the command word the user typed.
7895    /// `PATH=-dir tstcmd` (A05execution.ztst, workers/52515) resolves to
7896    /// `-dir/tstcmd`, which sh must see as an operand.
7897    #[test]
7898    #[cfg(unix)]
7899    fn zexecve_recover_sh_fallback_passes_the_path_not_the_word() {
7900        let dir = std::env::temp_dir().join(format!(
7901            "zshrs_recover_argv_{}_{:?}",
7902            std::process::id(),
7903            std::thread::current().id()
7904        ));
7905        std::fs::create_dir_all(&dir).unwrap();
7906        let script = dir.join("tstcmd");
7907        std::fs::write(&script, "echo no shebang\n").unwrap();
7908        let pth = script.to_str().unwrap().to_string();
7909        // A word spelled like an option must neither be handed to sh nor
7910        // trip the guard: only the executed path counts.
7911        let argv = vec!["-tstcmd".to_string(), "arg".to_string()];
7912        let got = zexecve_recover(&pth, &argv, libc::ENOEXEC);
7913        let _ = std::fs::remove_dir_all(&dir);
7914        let want: Vec<String> = vec!["sh".to_string(), pth.clone(), "arg".to_string()];
7915        assert_eq!(got, Ok(("/bin/sh".to_string(), want)));
7916    }
7917}
7918
7919// Plugin-Framework-Agnostic State-Modification Recorder hook helpers.
7920/// Recorder helper: emit one record for an array/scalar mutation
7921/// targeting a path-family parameter (path/fpath/manpath/module_path/
7922/// cdpath, lower- or upper-cased), or one `assign` record for any
7923/// other name. Centralises the path-family list so `BUILTIN_SET_ARRAY`,
7924/// `BUILTIN_APPEND_ARRAY`, and `BUILTIN_APPEND_SCALAR_OR_PUSH` share
7925/// the same routing.
7926///
7927/// `is_append` distinguishes `arr=(...)` from `arr+=(...)` so the
7928/// emitted event carries the APPEND attr bit and replay can choose
7929/// between fresh-set and extend semantics.
7930///
7931/// `attrs` carries any pre-existing type info from
7932/// `recorder_attrs_for(name)` (readonly/export/global) — array shape
7933/// and APPEND get OR'd in by emit_array_assign.
7934#[cfg(feature = "recorder")]
7935pub(crate) fn emit_path_or_assign(
7936    name: &str,
7937    values: &[String],
7938    attrs: crate::recorder::ParamAttrs,
7939    is_append: bool,
7940    ctx: &crate::recorder::RecordCtx,
7941) {
7942    let lower = name.to_ascii_lowercase();
7943    let kind_name: Option<&'static str> = match lower.as_str() {
7944        "path" => Some("path"),
7945        "fpath" => Some("fpath"),
7946        "manpath" => Some("manpath"),
7947        "module_path" => Some("module_path"),
7948        "cdpath" => Some("cdpath"),
7949        _ => None,
7950    };
7951    match kind_name {
7952        Some(k) => {
7953            for v in values {
7954                crate::recorder::emit_path_mod(v, k, ctx.clone());
7955                // Each fpath addition also surfaces every `_completion`
7956                // file inside the directory — matches zinit-report's
7957                // per-plugin "Completions:" listing. Only fpath dirs
7958                // get this treatment; PATH dirs hold executables, not
7959                // completion functions.
7960                if k == "fpath" {
7961                    crate::recorder::discover_completions_in_fpath_dir(v, ctx);
7962                }
7963            }
7964        }
7965        None => {
7966            // Non-path arrays: emit ONE `assign` event with the
7967            // ordered element list preserved in value_array. Replay
7968            // reconstructs `name=(elem1 elem2 ...)` exactly without
7969            // having to re-split a joined string.
7970            crate::recorder::emit_array_assign(
7971                name,
7972                values.to_vec(),
7973                attrs,
7974                is_append,
7975                ctx.clone(),
7976            );
7977        }
7978    }
7979}
7980
7981use std::os::unix::fs::MetadataExt;
7982
7983bitflags::bitflags! {
7984    /// Flags for zfork()
7985    #[derive(Debug, Clone, Copy, Default)]
7986    pub struct ForkFlags: u32 {
7987        const NOJOB = 1 << 0;    // Don't add to job table
7988        const NEWGRP = 1 << 1;   // Create new process group
7989        const FGTTY = 1 << 2;    // Take foreground terminal
7990        const KEEPSIGS = 1 << 3; // Keep signal handlers
7991    }
7992}
7993
7994bitflags::bitflags! {
7995    /// Flags for entersubsh()
7996    #[derive(Debug, Clone, Copy, Default)]
7997    pub struct SubshellFlags: u32 {
7998        const NOMONITOR = 1 << 0; // Disable job control
7999        const KEEPFDS = 1 << 1;   // Keep file descriptors
8000        const KEEPTRAPS = 1 << 2; // Keep trap handlers
8001    }
8002}
8003
8004/// Result of fork operation
8005#[derive(Debug)]
8006/// `fork()` outcome (parent / child / error).
8007/// Mirrors the integer return of `zfork()` from Src/exec.c:349.
8008pub enum ForkResult {
8009    /// `Parent` variant.
8010    Parent(i32), // Contains child PID
8011    /// `Child` variant.
8012    Child,
8013}
8014
8015/// Redirection mode
8016#[derive(Debug, Clone, Copy)]
8017/// File-redirection mode (`>` / `>>` / `<` / etc.).
8018/// Mirrors the `REDIR_*` enum from Src/zsh.h.
8019pub enum RedirMode {
8020    /// `Dup` variant.
8021    Dup,
8022    /// `Close` variant.
8023    Close,
8024}
8025
8026/// Builtin command type
8027#[derive(Debug, Clone, Copy)]
8028/// Builtin classification.
8029/// Mirrors the `BINF_*` flag set Src/builtin.c uses to
8030/// classify special vs regular builtins.
8031pub enum BuiltinType {
8032    /// `Normal` variant.
8033    Normal,
8034    /// `Disabled` variant.
8035    Disabled,
8036}
8037
8038use crate::fusevm_bridge::with_executor;
8039use crate::ported::glob::*;
8040use crate::ported::hist::*;
8041use crate::ported::jobs::*;
8042use crate::ported::math::*;
8043use crate::ported::module::*;
8044use crate::ported::modules::cap::*;
8045use crate::ported::modules::terminfo::*;
8046use crate::ported::options::*;
8047use crate::ported::params::*;
8048use crate::ported::pattern::*;
8049use crate::ported::prompt::*;
8050use crate::ported::signals::*;
8051use crate::ported::subst::*;
8052use crate::ported::utils::{zerr, zerrnam, zwarn, zwarnnam};
8053use ::regex::{Error as RegexError, Regex, RegexBuilder};
8054
8055pub use crate::ported::modules::regex::posix_ere_bracket_escape;
8056
8057impl ShellExecutor {
8058    /// Every option name in `ZSH_OPTIONS_SET` (port of `optns[]` at
8059    /// `Src/options.c:79+`).
8060    pub(crate) fn all_zsh_options() -> Vec<&'static str> {
8061        ZSH_OPTIONS_SET.iter().copied().collect()
8062    }
8063
8064    /// `name → default-on` map via canonical `default_on_options`
8065    /// (port of `defset()` macro at `Src/options.c:73`).
8066    pub(crate) fn default_options() -> HashMap<String, bool> {
8067        let on = default_on_options();
8068        Self::all_zsh_options()
8069            .into_iter()
8070            .map(|n| (n.to_string(), on.contains(n)))
8071            .collect()
8072    }
8073}
8074impl ShellExecutor {
8075    /// PURE PASSTHRU to the canonical `params::getsparam` (C port of
8076    /// `Src/params.c::getsparam`). Every special-name case the old
8077    /// 316-line body handled lives in `params::lookup_special_var` +
8078    /// `getsparam`'s paramtab/env walk. Returns an empty string for
8079    /// unset names (matching the old fn's signature; callers that
8080    /// need the set/unset distinction call `scalar` / `has_scalar`
8081    /// directly).
8082    pub(crate) fn get_variable(&self, name: &str) -> String {
8083        getsparam(name).unwrap_or_default()
8084    }
8085}
8086
8087// Source-form registration step used by the autoload-load path
8088// (`dispatch_function_call` / `run_function_body_only`). Decides
8089// whether to feed the file body to the funcdef pipeline VERBATIM
8090// (zsh-style: the body's own `function NAME() {...}` definition
8091// registers the function on execution) or to WRAP it in
8092// `NAME() {...}` (ksh-style or multi-statement: the body is just
8093// commands or includes additional statements past the def).
8094//
8095// The classification mirrors c:Src/exec.c:5725 + 5750 — KSHAUTOLOAD-
8096// equivalent vs zsh-style autoload. The structural check is the
8097// canonical `stripkshdef` (Src/exec.c:6291, ported at exec.rs:10548):
8098// parse the body to Eprog, run stripkshdef, and check whether it
8099// returned a stripped (different-length wordcode) Eprog. When it
8100// did, the file is the single-funcdef shape `[function] NAME [()] {
8101// INNER }`; running the file source directly through the funcdef
8102// pipeline registers NAME via the WC_FUNCDEF opcode at
8103// fusevm_bridge.rs:6330, matching C's `shf->funcdef = stripkshdef(
8104// prog, name)` semantics (the inner body becomes the function's
8105// body). When it didn't strip — single statement that isn't a
8106// funcdef, or multiple list nodes (e.g. `function ztm() {...}` +
8107// trailing `ztm "$@"` self-call) — we fall back to wrap-and-run so
8108// the canonical funcdef opcode still fires and any extra
8109// statements run inside the registered body, matching C's
8110// behavior of using the whole prog as funcdef in that case.
8111/// Restore `noaliases` on scope exit.
8112///
8113/// C's `loadautofn` (Src/exec.c:5684-5704) saves `noaliases`, sets it from the
8114/// function's PM_UNALIASED bit for the duration of the body parse, and restores
8115/// it unconditionally. The zshrs autoload block has early `return` paths, so the
8116/// restore has to ride on Drop rather than a trailing statement — otherwise a
8117/// `-U` autoload that failed to load would leave alias expansion disabled for
8118/// the rest of the shell.
8119struct NoAliasesRestore(bool);
8120
8121impl Drop for NoAliasesRestore {
8122    fn drop(&mut self) {
8123        crate::ported::lex::set_noaliases(self.0); // c:5704
8124    }
8125}
8126
8127/// c:Src/exec.c:5735 `loadautofnsetfile(shf, fdir)` + c:5751 — put the load
8128/// directory (and the absolute-path marker) back on a function whose body
8129/// zshrs just re-registered through the funcdef pipeline.
8130///
8131/// C loads an autoloaded body IN PLACE on the existing `Shfunc`, so
8132/// `filename`, PM_LOADDIR and PM_ABSPATH_USED all survive the load:
8133/// `shf->node.flags &= ~PM_UNDEFINED` (c:5751) is the only flag C clears.
8134/// zshrs re-registers the body as SOURCE through the WC_FUNCDEF pipeline,
8135/// which builds a FRESH node whose `filename` is the enclosing script and
8136/// whose flag word starts at zero. Both have to be reinstated, or `whence -v`
8137/// names the calling script instead of the definition file, and the
8138/// PM_LOADDIR|PM_ABSPATH_USED pair that `add_autoload_function`
8139/// (Src/builtin.c:3310-3323) tests — to hand a sibling `autoload -Uz NAME`
8140/// the caller's directory — is gone.
8141///
8142/// One helper rather than four inline copies: all four
8143/// `autoload_register_source` call sites need the identical restore.
8144///
8145/// `ksh_style` must be sampled BEFORE the re-registration (the fresh node's
8146/// flag word no longer carries PM_KSHSTORED / PM_ZSHSTORED, so re-deriving it
8147/// here would read the wrong answer): c:Src/exec.c:5792-5806 is the one arm
8148/// where C does NOT reload the body in place. It runs the whole FILE at top
8149/// level (`execode(prog, 1, 0, "evalautofunc")`, c:5795) and then REFETCHES
8150/// the node (`shf = shfunctab->getnode(shfunctab, n)`, c:5797) because the
8151/// file's own `NAME() { … }` created a brand-new Shfunc. There is no
8152/// `loadautofnsetfile` call on that arm, so zsh itself loses `filename`,
8153/// PM_LOADDIR and PM_ABSPATH_USED there. Verified against the reference shell:
8154///
8155/// ```text
8156/// $ zsh -f -c 'autoload -k /D/kw; kw'
8157/// ksh-kw ran
8158/// kw: ksib: function definition file not found
8159/// $ zsh -f -c 'autoload -k /D/kw; kw >/dev/null; whence -v kw'
8160/// kw is a shell function from zsh
8161/// ```
8162///
8163/// Restoring on that arm would make a ksh-autoloaded function inherit its
8164/// directory to siblings where zsh does not.
8165fn restore_loaddir(name: &str, dir: &str, abspath_used: bool, ksh_style: bool) {
8166    if ksh_style {
8167        return; // c:5792-5806 — no loadautofnsetfile on the ksh arm
8168    }
8169    if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
8170        if let Some(shf) = tab.get_mut(name) {
8171            crate::ported::exec::loadautofnsetfile(shf, Some(dir)); // c:5735
8172            if abspath_used {
8173                shf.node.flags |= crate::ported::zsh_h::PM_ABSPATH_USED as i32; // c:5751
8174            }
8175        }
8176    }
8177}
8178
8179/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8180///
8181/// C never has to ask this question. `Src/Modules/parameter.c:295-315`
8182/// (`setfunction`, the `functions[NAME]=BODY` setter) parses the assigned text
8183/// with `parse_string(val, 1)` and stores the result AS THE BODY —
8184/// `shf->funcdef = dupeprog(prog, 0)` (c:313) — so the function is fully
8185/// defined the moment the assignment runs. `loadautofn`
8186/// (`Src/exec.c:5723-5806`), which reads a FILE and therefore has to decide
8187/// between "the file IS the body" and "the file DEFINES the function"
8188/// (c:5781 `ksh == 2 || (ksh == 1 && isset(KSHAUTOLOAD))`), is a different
8189/// entry point that a parameter assignment never reaches.
8190///
8191/// zshrs stores the assigned text on `shfunc.body` with no compiled chunk, so
8192/// the first CALL lands in the same autoload prelude a real `autoload` stub
8193/// uses — and that prelude was applying `loadautofn`'s file rules to it. The
8194/// two cases are told apart by the flag `loadautofn` stamps and a parameter
8195/// assignment does not: `loadautofnsetfile` sets PM_LOADDIR (c:5657) on the
8196/// node it loaded, while `setfunction` leaves the flag word at `dis` (c:314).
8197/// The `loaded_dir` capture at both call sites already reads the same bit for
8198/// the same reason.
8199fn body_came_from_param_assignment(flags: i32) -> bool {
8200    (flags as u32 & crate::ported::zsh_h::PM_LOADDIR) == 0
8201}
8202
8203/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8204///
8205/// The text to run to install a `functions[NAME]=BODY` assignment: ALWAYS the
8206/// `NAME() { … }` wrap, never the verbatim run that
8207/// [`autoload_definition_source`] falls back to for a file whose contents are
8208/// already a definition of `NAME`. c:Src/Modules/parameter.c:313 makes the
8209/// parsed text the funcdef unconditionally, so text that happens to READ as
8210/// `NAME () { … }` is a BODY whose execution defines `NAME` — measured
8211/// against the reference shell:
8212///
8213/// ```text
8214/// $ zsh -f -c 'functions[g]="g () { print inner }"; g; print "[${functions[g]}]"'
8215/// [	print inner]
8216/// ```
8217///
8218/// Nothing is printed: the call ran the definition. Running that text verbatim
8219/// at install time instead printed `inner` and defined `g` a call too early.
8220fn param_definition_source(name: &str, body: &str) -> String {
8221    format!("{name}() {{\n{body}\n}}")
8222}
8223
8224/// c:Src/exec.c:5781 — `if (ksh == 2 || (ksh == 1 && isset(KSHAUTOLOAD)))`,
8225/// the ksh-style load branch. `ksh` derives from the stub's stored-style bits
8226/// per c:5762-5766 (`PM_KSHSTORED ? 2 : PM_ZSHSTORED ? 0 : 1`; a decisive
8227/// `.zwc` header flag was already folded into these bits by `loadautofn`).
8228///
8229/// Two zshrs steps need the same answer — `autoload_register_source` (wrap vs
8230/// verbatim) and `restore_loaddir` (whether the load kept the original node)
8231/// — so the decision lives in one place.
8232fn autoload_is_ksh_style(name: &str) -> bool {
8233    let flags = crate::ported::utils::getshfunc(name)
8234        .map(|f| f.node.flags as u32)
8235        .unwrap_or(0);
8236    let ksh = if flags & crate::ported::zsh_h::PM_KSHSTORED != 0 {
8237        2 // c:5765
8238    } else if flags & crate::ported::zsh_h::PM_ZSHSTORED != 0 {
8239        0 // c:5766
8240    } else {
8241        1 // c:5766
8242    };
8243    ksh == 2 || (ksh == 1 && crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHAUTOLOAD))
8244    // c:5781
8245}
8246
8247/// The cache key for an autoloaded function: `(resolved fpath dir,
8248/// SHA-256 of the definition text)`, or `None` when the directory
8249/// cannot be pinned down.
8250///
8251/// `loadautofn` records the resolved fpath directory on the shfunc
8252/// (`filename` + `PM_LOADDIR`, c:Src/exec.c:5657); a function whose
8253/// `filename` is still the placeholder `"zsh"` was not resolved through
8254/// `$fpath` and is not cached.
8255///
8256/// The hash is over `registered` — the exact string about to be
8257/// compiled — and NOT over a `stat` of `<dir>/<name>`. Those are not the
8258/// same thing: `getfpfunc` prefers a `<dir>.zwc` digest over the plain
8259/// file whenever the digest is newer (c:Src/parse.c:3771-3777), so the
8260/// body being installed may have no relationship to the bytes of the
8261/// file that path names. Stamping the path let a chunk built from one
8262/// text be served for another.
8263///
8264/// `from_wordcode` salts the digest because it changes what the same bytes
8265/// COMPILE TO: a wordcode-derived body is compiled under [`ZwcRelexGuard`],
8266/// so it resolves quotes and aliases the way `zcompile` did, while a
8267/// plain-file body of those same bytes resolves them against the live
8268/// RCQUOTES / alias state. One cache line must never be served for both.
8269fn autoload_source_key(
8270    name: &str,
8271    registered: &str,
8272    from_wordcode: bool,
8273) -> Option<(String, [u8; 32])> {
8274    let dir = crate::ported::utils::getshfunc(name)
8275        .and_then(|f| f.filename)
8276        .filter(|d| d != "zsh")?;
8277    let sha = if from_wordcode {
8278        crate::autoload_cache::source_digest(&format!("\0zwc\0{registered}"))
8279    } else {
8280        crate::autoload_cache::source_digest(registered)
8281    };
8282    Some((dir, sha))
8283}
8284
8285/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8286///
8287/// Raises `noerrs` (c:Src/utils.c:175 — the gate every `zerr`/`zwarn`
8288/// consults) for the life of the guard, so a pass that only exists because
8289/// zshrs parses text where C carries a parsed `Eprog` cannot report an error
8290/// C would report exactly once. `ERRFLAG_ERROR` is still set while muted, so
8291/// callers that read errflag still see the failure.
8292struct ZerrMute(i32);
8293
8294impl ZerrMute {
8295    fn enter() -> Self {
8296        let mut g = crate::ported::utils::noerrs_lock().lock().unwrap();
8297        let prev = *g;
8298        *g = 1;
8299        Self(prev)
8300    }
8301}
8302
8303impl Drop for ZerrMute {
8304    fn drop(&mut self) {
8305        if let Ok(mut g) = crate::ported::utils::noerrs_lock().lock() {
8306            *g = self.0;
8307        }
8308    }
8309}
8310
8311/// What an autoload of one name will hand the compiler.
8312struct AutoloadSource {
8313    /// The definition text: the file body verbatim, or the `name() { … }` wrap.
8314    text: String,
8315    /// The body is a `.zwc` deparse, so the compile needs [`ZwcRelexGuard`].
8316    from_wordcode: bool,
8317    /// c:Src/exec.c:6264 — `parse_string` rejected the file and has ALREADY
8318    /// reported the error against the loadee's name. C returns NULL from
8319    /// `getfpfunc`, so `loadautofn` abandons the load right there (c:5726) and
8320    /// prints nothing more. zshrs still runs `text` through the compiler
8321    /// (that is where the body becomes a chunk), so the caller silences THAT
8322    /// pass instead — otherwise the same parse error lands twice, the second
8323    /// time one line off because of the wrap's leading `name() {` line.
8324    parse_failed: bool,
8325}
8326
8327/// Returns the text to run to install `name`, plus whether that text came out
8328/// of a `.zwc` (see [`autoload_note_wordcode_body`]) and therefore has to be
8329/// lexed under [`ZwcRelexGuard`] wherever it is lexed, and whether the file's
8330/// one parse rejected it.
8331fn autoload_register_source(name: &str, body: &str) -> AutoloadSource {
8332    let ksh_style = autoload_is_ksh_style(name);
8333    // `loadautofn` took this body from autoloads.rkyv: the registration it
8334    // was stored with is the one the parse below would produce.
8335    if let Some((text, from_wordcode, parse_failed)) =
8336        crate::autoload_cache::take_hit(name, body, ksh_style)
8337    {
8338        return AutoloadSource {
8339            text,
8340            from_wordcode,
8341            parse_failed,
8342        };
8343    }
8344    // c:Src/exec.c:5725 `stripkshdef(prog, …)` — the ksh-vs-zsh wrap decision
8345    // below PARSES `body`, so it is itself a second lex of the deparse and
8346    // needs the same pin as the compile that follows it.
8347    let from_wordcode = autoload_body_from_wordcode(name, body);
8348    let _relex = from_wordcode.then(ZwcRelexGuard::enter);
8349    let (text, parse_failed) = autoload_definition_source(name, body, ksh_style);
8350    crate::autoload_cache::commit_search(name, body, &text, parse_failed, ksh_style);
8351    AutoloadSource {
8352        text,
8353        from_wordcode,
8354        parse_failed,
8355    }
8356}
8357
8358/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8359///
8360/// Pins the lexer to the spelling a `.zwc` deparse was written in, for the
8361/// duration of one compile.
8362///
8363/// C never needs this: the compiled arm of `source()` is `execode(prog, 1,
8364/// 0, "filecode")` (c:Src/init.c:1621) and the ksh-autoload arm is
8365/// `execode(prog, 1, 0, "evalautofunc")` (c:Src/exec.c:5795) — the wordcode
8366/// runs as it stands and NOTHING is lexed. A `.zwc` is resolved once, at
8367/// `zcompile` time. zshrs has no execute-the-wordcode path: it deparses back
8368/// to source with `getpermtext` and lexes that, so every piece of LEXER-TIME
8369/// state that C bypassed has to be neutralised by hand, or the second lex
8370/// resolves the text differently from the first.
8371///
8372/// Two such pieces are known to change the result:
8373///
8374///   * **RCQUOTES.** `untokenize` (c:Src/exec.c:2134) renders every quote
8375///     null through `ztokens[Snull - Pound]`, and that entry is a bare
8376///     single quote (c:Src/lex.c:38), so a closing null followed by an
8377///     opening one deparses to two adjacent quotes. Under RCQUOTES the lexer
8378///     reads that pair inside a quoted word as one LITERAL quote
8379///     (c:Src/lex.c:1328) rather than as two delimiters. `zsh-expand`'s
8380///     plugin entry does `setopt rcquotes` at its line 39, so every later
8381///     `.zwc`-sourced alias with adjacent quoted segments — the whole
8382///     `zsh-openshift-aliases` set — gained literal quotes zsh never had.
8383///
8384///   * **Aliases.** `checkalias` (c:Src/lex.c:1909) fires from the lexer, so
8385///     an alias installed before the `source` rewrote words INSIDE the
8386///     compiled program: with `alias mycmd=…` live, a `.zwc` whose second
8387///     line is `mycmd` ran the alias where zsh runs the function. Global
8388///     aliases are worse — they rewrite any word, so `print -r -- GA: x`
8389///     became `print -r -- GA: LEAKED` under `alias -g x=LEAKED`. This is
8390///     C's `noaliases` (c:Src/lex.c:135), the same switch `par_case` uses to
8391///     keep `in` from being alias-expanded.
8392///
8393/// Both are restored on drop, so the restore survives a panic out of the
8394/// compiler — and, more importantly, the program's own RUNTIME lexing is
8395/// unaffected: a `setopt rcquotes` the `.zwc` performs still takes effect
8396/// and still outlives the source, and a function or `eval` body it runs is
8397/// lexed later, against the live alias table.
8398///
8399/// One residual case is out of reach here and needs a real
8400/// execute-the-wordcode path: a `.zwc` COMPILED while RCQUOTES was set holds
8401/// an unescaped literal quote in its tokenized word (c:Src/lex.c:1329 adds
8402/// the character with no `Bnull` prefix), and `untokenize` cannot tell that
8403/// apart from a quote null. No option state at re-lex time recovers it.
8404///
8405/// `noaliases` is a thread-local (`LEX_NOALIASES`), but the option store is
8406/// process-wide, so the RCQUOTES window is visible to a worker thread that
8407/// lexes concurrently. The window is one synchronous compile.
8408pub(crate) struct ZwcRelexGuard {
8409    rcquotes: bool,
8410    noaliases: bool,
8411}
8412
8413impl ZwcRelexGuard {
8414    pub(crate) fn enter() -> Self {
8415        let rcquotes = crate::ported::zsh_h::isset(crate::ported::zsh_h::RCQUOTES);
8416        if rcquotes {
8417            crate::ported::options::opt_state_set("rcquotes", false);
8418        }
8419        let noaliases = crate::ported::lex::noaliases();
8420        crate::ported::lex::set_noaliases(true); // c:Src/lex.c:1909
8421        Self {
8422            rcquotes,
8423            noaliases,
8424        }
8425    }
8426}
8427
8428impl Drop for ZwcRelexGuard {
8429    fn drop(&mut self) {
8430        if self.rcquotes {
8431            crate::ported::options::opt_state_set("rcquotes", true);
8432        }
8433        crate::ported::lex::set_noaliases(self.noaliases);
8434    }
8435}
8436
8437/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8438///
8439/// The RCQUOTES state each shell-defined function's body was resolved
8440/// under at DEFINITION time, keyed by name and digested body text.
8441///
8442/// C has no such fact to carry. `execfuncdef` compiles the body into
8443/// wordcode when the definition runs (c:Src/exec.c:5389-5391 —
8444/// `shf->funcdef = dupeprog(...)`), and `printshfuncnode` renders THAT
8445/// program back with `getpermtext(f->funcdef, NULL, 1)`
8446/// (c:Src/hashtable.c:954). Every lexer-time decision is already settled
8447/// in the wordcode, so printing cannot revisit it — `functions f` gives
8448/// the same text no matter which options are set when it runs.
8449///
8450/// zshrs has no `Eprog` for a shell-defined function: `shfunc::body` keeps
8451/// the raw source and `printshfuncnode`'s stand-in RE-LEXES it at print
8452/// time. Under RCQUOTES an adjacent quote pair inside a quoted word lexes
8453/// as one LITERAL quote (c:Src/lex.c:1328) instead of as two delimiters,
8454/// so one unchanged function deparsed two different ways across a
8455/// `setopt rcquotes` — docs/BUGS.md #1105. Pinning the print-time lex to
8456/// defaults is wrong in the other direction: a function defined WHILE
8457/// RCQUOTES was set has to keep printing the RCQUOTES resolution, which is
8458/// why the state has to be recorded rather than assumed.
8459///
8460/// The state is sampled where the definition INSTALLS (the
8461/// `BUILTIN_REGISTER_COMPILED_FN` handler), not where its body was lexed.
8462/// In C those are the same instant, because zsh lexes one command list at
8463/// a time as it runs it; zshrs compiles a whole script ahead of running
8464/// it, so a runtime `setopt rcquotes` on an earlier line of the same file
8465/// is already in force at install time but was NOT in force at the ahead
8466/// -of-time lex. Install time is the one that matches zsh's printed
8467/// output (`setopt rcquotes` on line 1, `f() { … }` on line 2 →
8468/// C prints the RCQUOTES resolution), and it collapses onto lex time the
8469/// day the compiler stops running ahead of execution.
8470///
8471/// The mark is a DIGEST of the body rather than a bare flag, exactly like
8472/// [`AUTOLOAD_WORDCODE_BODY`]: `functions[name]=…`, an autoload, and a
8473/// plain redefinition all install different text, and a stale entry
8474/// simply stops matching instead of pinning the lexer for a body it never
8475/// described.
8476static FUNCDEF_LEX_RCQUOTES: Mutex<Option<HashMap<String, ([u8; 32], bool)>>> = Mutex::new(None);
8477
8478/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8479///
8480/// Record the RCQUOTES state `name`'s definition was installed under.
8481/// See [`FUNCDEF_LEX_RCQUOTES`]; called from the funcdef install path
8482/// (c:Src/exec.c:5389 `shf->funcdef = …`, where C bakes the same fact
8483/// into the wordcode instead).
8484pub(crate) fn funcdef_note_rcquotes(name: &str, body: &str, rcquotes: bool) {
8485    let digest = crate::autoload_cache::source_digest(body);
8486    // The state the body was LEXED under wins when the parser recorded it
8487    // (see FUNCDEF_PARSE_RCQUOTES); install time is only the fallback.
8488    let rcquotes = FUNCDEF_PARSE_RCQUOTES
8489        .lock()
8490        .as_ref()
8491        .and_then(|m| m.get(&digest).copied())
8492        .unwrap_or(rcquotes);
8493    FUNCDEF_LEX_RCQUOTES
8494        .lock()
8495        .get_or_insert_with(HashMap::new)
8496        .insert(
8497            name.to_string(),
8498            (digest, rcquotes),
8499        );
8500}
8501
8502/// !!! WARNING: RUST-ONLY HELPER STATE — NO C COUNTERPART !!!
8503///
8504/// RCQUOTES as it stood when the parser lexed each captured function body,
8505/// keyed by the body digest. C bakes the `''` decision into the wordcode at
8506/// lex time (c:Src/lex.c:1326-1328), and zsh lexes one list at a time, so
8507/// `setopt rcquotes; f() { print 'a''b' }` on ONE line lexes `f` before the
8508/// option is on and prints `'a''b'`, while a definition on a later line (or
8509/// through `eval`) prints `'a'b'`. Install time cannot tell those apart.
8510static FUNCDEF_PARSE_RCQUOTES: Mutex<Option<HashMap<[u8; 32], bool>>> = Mutex::new(None);
8511
8512/// !!! WARNING: RUST-ONLY HELPER STATE — NO C COUNTERPART !!!
8513///
8514/// Digests of function bodies the parser captured, which are ALIAS-RESOLVED.
8515///
8516/// C's par_funcdef compiles the body to wordcode after alias expansion, so
8517/// `functions` (getpermtext, c:Src/hashtable.c:954) shows exactly what the
8518/// parse saw, whatever the alias table holds later: an alias removed or
8519/// redefined after the definition changes nothing, and one defined after the
8520/// parse (a `-c` string is parsed whole, c:Src/init.c:1568 execstring) never
8521/// appears. `funcdef_capture::body_text` hands the parser that same
8522/// post-expansion text, so re-lexing it with aliases ON expands a second time
8523/// — `alias ls='ls -G'` listed `ls -G -G x` — and a body recorded here is
8524/// always re-lexed with aliases off. Keyed by digest like
8525/// [`FUNCDEF_LEX_RCQUOTES`]; text that did not come from a capture
8526/// (`functions[f]=…`, an autoload file) has no entry and keeps the live table.
8527static FUNCDEF_ALIAS_RESOLVED: Mutex<Option<std::collections::HashSet<[u8; 32]>>> =
8528    Mutex::new(None);
8529
8530/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8531///
8532/// Called by the parser right after it captures a function body. See
8533/// [`FUNCDEF_ALIAS_RESOLVED`].
8534pub(crate) fn funcdef_note_resolved_body(body: Option<&str>) {
8535    let Some(body) = body else { return };
8536    let digest = crate::autoload_cache::source_digest(body);
8537    FUNCDEF_ALIAS_RESOLVED
8538        .lock()
8539        .get_or_insert_with(std::collections::HashSet::new)
8540        .insert(digest);
8541    // Same parse-time capture point: record the RCQUOTES the body was lexed
8542    // under (FUNCDEF_PARSE_RCQUOTES).
8543    FUNCDEF_PARSE_RCQUOTES
8544        .lock()
8545        .get_or_insert_with(HashMap::new)
8546        .insert(digest, crate::ported::zsh_h::isset(crate::ported::zsh_h::RCQUOTES));
8547}
8548
8549/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8550///
8551/// Pin RCQUOTES to the value `name`'s body was defined under, for the
8552/// duration of one deparse of that body.
8553///
8554/// This is [`ZwcRelexGuard`]'s sibling: same problem (zshrs lexes text
8555/// where C runs wordcode), same RAII shape, but the target value is the
8556/// RECORDED one rather than "off" — the definition may itself have been
8557/// made under RCQUOTES, and then the RCQUOTES resolution is the correct
8558/// one to print. With no record for this exact body (a function installed
8559/// through a path that does not record — `functions[f]=…`, the ported
8560/// walker, an autoload stub) nothing is pinned and the live option stands,
8561/// which is the behaviour that predates the record.
8562///
8563/// The option store is process-wide, so the pin is visible to a worker
8564/// thread that lexes concurrently; the window is one synchronous deparse,
8565/// as it is for [`ZwcRelexGuard`].
8566pub(crate) fn funcdef_lex_pin(name: &str, body: &str) -> FuncdefLexPin {
8567    // c:Src/hashtable.c:954 renders the STORED wordcode, where every alias the
8568    // body's parse expanded is already baked in and nothing else can expand.
8569    // The re-lex runs with `noaliases` (c:Src/lex.c:135) whenever the text is
8570    // already resolved: the parser captured it (FUNCDEF_ALIAS_RESOLVED), or —
8571    // for an autoload body not yet parsed, as after `autoload +X` — it was
8572    // loaded under `autoload -U` (PM_UNALIASED, c:Src/exec.c:5746) or rendered
8573    // from a `.zwc`. Any other body keeps the live alias table.
8574    let noaliases = crate::ported::lex::noaliases();
8575    // c:Src/exec.c:5389 execfuncdef — C deparses the wordcode parsed when the
8576    // function was DEFINED, and a definition inside `emulate sh -c` is parsed
8577    // under that emulation (stamped as `shf->sticky`, c:5527-5532). Re-lex
8578    // under the same emulation (installemulation + its on/off options, as
8579    // doshfunc applies it at c:5977-6010), so an sh-mode `( one | two )` case
8580    // pattern keeps its separate alternatives and prints `(one | two)`. Only a
8581    // function carrying a sticky emulation is touched: `eval` / `-z` / `+X`
8582    // bodies keep the live state.
8583    let sticky_restore = crate::ported::utils::getshfunc(name)
8584        .and_then(|f| f.sticky.clone())
8585        .filter(|s| crate::ported::exec::sticky_emulation_differs(Some(s)) != 0)
8586        .map(|s| install_sticky_emulation(&s));
8587    let captured = FUNCDEF_ALIAS_RESOLVED
8588        .lock()
8589        .as_ref()
8590        .is_some_and(|set| set.contains(&crate::autoload_cache::source_digest(body)));
8591    let deparse_noaliases = captured
8592        || crate::ported::utils::getshfunc(name).is_some_and(|f| {
8593            (f.node.flags as u32 & crate::ported::zsh_h::PM_UNALIASED) != 0
8594        })
8595        || autoload_body_from_wordcode(name, body);
8596    if deparse_noaliases {
8597        crate::ported::lex::set_noaliases(true); // c:Src/lex.c:1909
8598    }
8599    let want = {
8600        let slot = FUNCDEF_LEX_RCQUOTES.lock();
8601        slot.as_ref()
8602            .and_then(|map| map.get(name))
8603            .filter(|(sha, _)| *sha == crate::autoload_cache::source_digest(body))
8604            .map(|(_, rcquotes)| *rcquotes)
8605    };
8606    let live = crate::ported::zsh_h::isset(crate::ported::zsh_h::RCQUOTES);
8607    match want {
8608        Some(w) if w != live => {
8609            crate::ported::options::opt_state_set("rcquotes", w);
8610            FuncdefLexPin {
8611                restore: Some(live),
8612                noaliases,
8613                sticky_restore,
8614            }
8615        }
8616        _ => FuncdefLexPin {
8617            restore: None,
8618            noaliases,
8619            sticky_restore,
8620        },
8621    }
8622}
8623
8624/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8625///
8626/// RAII half of [`funcdef_lex_pin`]. `restore` is `None` when nothing was
8627/// changed, so the common path costs one hash lookup and no option write.
8628pub(crate) struct FuncdefLexPin {
8629    restore: Option<bool>,
8630    noaliases: bool,
8631    /// Options and emulation in force before a sticky emulation was applied
8632    /// for the re-lex; put back on drop.
8633    sticky_restore: Option<(Vec<bool>, i32, i32, bool)>,
8634}
8635
8636impl Drop for FuncdefLexPin {
8637    fn drop(&mut self) {
8638        if let Some(live) = self.restore {
8639            crate::ported::options::opt_state_set("rcquotes", live);
8640        }
8641        crate::ported::lex::set_noaliases(self.noaliases);
8642        if let Some(saved) = self.sticky_restore.take() {
8643            restore_sticky_emulation(saved);
8644        }
8645    }
8646}
8647
8648/// Options and emulation cells saved by [`install_sticky_emulation`].
8649type SavedEmulation = (Vec<bool>, i32, i32, bool);
8650
8651/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8652///
8653/// The option half of doshfunc's sticky-emulation entry (c:Src/exec.c:
8654/// 5991-6008): `installemulation(emulation, opts)` then the on/off option
8655/// lists. Returns what it overwrote for [`restore_sticky_emulation`].
8656fn install_sticky_emulation(s: &crate::ported::zsh_h::emulation_options) -> SavedEmulation {
8657    use std::sync::atomic::Ordering;
8658    let size = crate::ported::zsh_h::OPT_SIZE as usize;
8659    let saved: Vec<bool> = (0..size)
8660        .map(|optno| {
8661            optno > 0
8662                && crate::ported::options::opt_state_get(crate::ported::zsh_h::opt_name(optno as i32))
8663                    .unwrap_or(false)
8664        })
8665        .collect();
8666    let saved_emu = (
8667        crate::ported::options::emulation.load(Ordering::Relaxed),
8668        crate::ported::options::EMULATION.load(Ordering::Relaxed),
8669        crate::ported::options::FULLY_EMULATING.load(Ordering::Relaxed),
8670    );
8671    let mut new_opts = [-1i8; crate::ported::zsh_h::OPT_SIZE as usize];
8672    crate::ported::options::installemulation(s.emulation, &mut new_opts); // c:5993
8673    for (optno, &v) in new_opts.iter().enumerate().skip(1) {
8674        if v >= 0 {
8675            crate::ported::options::opt_state_set(crate::ported::zsh_h::opt_name(optno as i32), v == 1);
8676        }
8677    }
8678    for on in &s.on_opts {
8679        crate::ported::options::opt_state_set(crate::ported::zsh_h::opt_name(*on as i32), true); // c:5995-6001
8680    }
8681    for off in &s.off_opts {
8682        crate::ported::options::opt_state_set(crate::ported::zsh_h::opt_name(*off as i32), false); // c:6002-6008
8683    }
8684    let emu_base = s.emulation & !crate::ported::zsh_h::EMULATE_FULLY;
8685    crate::ported::options::emulation.store(emu_base, Ordering::Relaxed);
8686    crate::ported::options::EMULATION.store(emu_base, Ordering::Relaxed);
8687    crate::ported::options::FULLY_EMULATING
8688        .store((s.emulation & crate::ported::zsh_h::EMULATE_FULLY) != 0, Ordering::Relaxed);
8689    (saved, saved_emu.0, saved_emu.1, saved_emu.2)
8690}
8691
8692/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8693///
8694/// Undo [`install_sticky_emulation`].
8695fn restore_sticky_emulation((opts, emu, emu_cell, fully): SavedEmulation) {
8696    use std::sync::atomic::Ordering;
8697    for (optno, on) in opts.into_iter().enumerate().skip(1) {
8698        crate::ported::options::opt_state_set(crate::ported::zsh_h::opt_name(optno as i32), on);
8699    }
8700    crate::ported::options::emulation.store(emu, Ordering::Relaxed);
8701    crate::ported::options::EMULATION.store(emu_cell, Ordering::Relaxed);
8702    crate::ported::options::FULLY_EMULATING.store(fully, Ordering::Relaxed);
8703}
8704
8705/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8706///
8707/// C loads an autoloaded body from INSIDE doshfunc: the stub's funcdef is
8708/// the `mkautofn` trampoline, so `execautofn` → `loadautofn` → `getfpfunc`
8709/// (c:Src/exec.c:5640, 5697) parses the file after doshfunc has entered the
8710/// stub's sticky emulation (c:5978-6010), and the body is installed IN
8711/// PLACE on the same Shfunc, keeping `shf->sticky` (stamped by
8712/// `shfunc_set_sticky` in bin_autoload, c:Src/builtin.c:3766). zshrs loads
8713/// and registers the body before doshfunc runs, so this guard opens the
8714/// same window for the load: the stub's emulation for the parse, and the
8715/// stub's snapshot as the pending `sticky` so the re-registration stamps it
8716/// onto the loaded function.
8717struct AutoloadStickyGuard {
8718    saved_opts: SavedEmulation,
8719    saved_sticky: Option<crate::ported::zsh_h::Emulation_options>,
8720}
8721
8722impl AutoloadStickyGuard {
8723    fn enter(stub: &crate::ported::zsh_h::shfunc) -> Option<Self> {
8724        let s = stub.sticky.as_deref()?;
8725        if crate::ported::exec::sticky_emulation_differs(Some(s)) == 0 {
8726            return None;
8727        }
8728        let dup = crate::ported::exec::sticky_emulation_dup(s, 1);
8729        let saved_opts = install_sticky_emulation(&dup);
8730        let saved_sticky = std::mem::replace(
8731            &mut *crate::ported::options::sticky
8732                .lock()
8733                .unwrap_or_else(|e| e.into_inner()),
8734            Some(dup),
8735        );
8736        Some(Self {
8737            saved_opts,
8738            saved_sticky,
8739        })
8740    }
8741}
8742
8743impl Drop for AutoloadStickyGuard {
8744    fn drop(&mut self) {
8745        *crate::ported::options::sticky
8746            .lock()
8747            .unwrap_or_else(|e| e.into_inner()) = self.saved_sticky.take();
8748        restore_sticky_emulation(std::mem::take(&mut self.saved_opts));
8749    }
8750}
8751
8752/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8753///
8754/// Names whose installed autoload body was rendered back from WORDCODE
8755/// (`getpermtext` over a `.zwc` program) rather than read from a source file,
8756/// each mapped to a digest of that body.
8757///
8758/// C has no such fact to carry. `loadautofn` hands the dump's program
8759/// straight to `shf->funcdef = stripkshdef(prog, …)` (c:Src/exec.c:5753-5755),
8760/// and the ksh-style arm runs it with `execode(prog, 1, 0, "evalautofunc")`
8761/// (c:Src/exec.c:5795): a dump-loaded funcdef is never lexed again, at load
8762/// time or ever. zshrs executes function bodies as TEXT, so `loadautofn`
8763/// deparses the program and the REAL compile happens later — at the call that
8764/// installs the function, through [`ShellExecutor::run_autoload_definition`].
8765/// The two are separated by arbitrary user code, so "this text came from
8766/// wordcode" has to survive the gap or the deparse is re-lexed against
8767/// whatever RCQUOTES / alias state is live at call time. See
8768/// [`ZwcRelexGuard`] for exactly what that costs; the `source` leg of the same
8769/// bug is fixed by [`ShellExecutor::execute_zwc_program`].
8770///
8771/// The mark is a DIGEST of the body, not a bare flag, so it self-invalidates.
8772/// `functions[name]=…` (c:Src/Modules/parameter.c setpmfunction) and a later
8773/// load of the same name from a plain file both install different text, and
8774/// the digest simply stops matching — no writer anywhere else has to know this
8775/// table exists, and a stale entry cannot pin the lexer for a body that was
8776/// never wordcode.
8777static AUTOLOAD_WORDCODE_BODY: Mutex<Option<HashMap<String, [u8; 32]>>> = Mutex::new(None);
8778
8779/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8780///
8781/// Record (`Some`) or drop (`None`) the "body came from wordcode" mark for
8782/// `name`. Called from `loadautofn` (c:Src/exec.c:5682) on every load, so a
8783/// plain-file load clears a mark an earlier dump load left.
8784pub(crate) fn autoload_note_wordcode_body(name: &str, body: Option<&str>) {
8785    let mut slot = AUTOLOAD_WORDCODE_BODY.lock();
8786    match body {
8787        Some(text) => {
8788            slot.get_or_insert_with(HashMap::new)
8789                .insert(name.to_string(), crate::autoload_cache::source_digest(text));
8790        }
8791        None => {
8792            if let Some(map) = slot.as_mut() {
8793                map.remove(name);
8794            }
8795        }
8796    }
8797}
8798
8799/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
8800///
8801/// True when `body` is the exact text [`autoload_note_wordcode_body`] recorded
8802/// for `name` — i.e. this is a `.zwc` deparse about to be lexed a second time.
8803pub(crate) fn autoload_body_from_wordcode(name: &str, body: &str) -> bool {
8804    let slot = AUTOLOAD_WORDCODE_BODY.lock();
8805    slot.as_ref()
8806        .and_then(|map| map.get(name))
8807        .is_some_and(|sha| *sha == crate::autoload_cache::source_digest(body))
8808}
8809
8810/// The exact source text an autoload of `name` installs — either the
8811/// file body verbatim (ksh style, or a file that already defines the
8812/// function) or `name() { <body> }`.
8813///
8814/// Split out of [`autoload_register_source`] with the ksh decision
8815/// passed IN so the prewarm (`autoload_prewarm`, which has no shfunc
8816/// flags to consult because nothing is registered yet) compiles
8817/// byte-identical text to what the loader will run. The two drifting
8818/// apart is precisely what made the pre-v2 shard unusable: it cached a
8819/// different program than the one the loader installs.
8820/// Returns the definition text plus whether that parse REJECTED the body
8821/// (c:Src/exec.c:6264 `r = parse_string(d, 1)` returning NULL, which makes
8822/// `loadautofn` abandon the load at c:5726 with no further diagnostic).
8823pub(crate) fn autoload_definition_source(
8824    name: &str,
8825    body: &str,
8826    ksh_style: bool,
8827) -> (String, bool) {
8828    // c:Src/exec.c:5781 — a ksh-style load executes the file contents at top
8829    // level (c:5795 `execode(prog, 1, 0, "evalautofunc")`) and expects the
8830    // file itself to define the function — so the body goes through the
8831    // pipeline VERBATIM, never wrapped.
8832    if ksh_style {
8833        return (body.to_string(), false); // c:5795 execode(prog, ..., "evalautofunc")
8834    }
8835    // c:6257-6265 — `getfpfunc` parses the file under the LOADEE's name:
8836    //     char *oldscriptname = scriptname;
8837    //     scriptname = dupstring(s);
8838    //     r = parse_string(d, 1);
8839    //     scriptname = oldscriptname;
8840    // so a syntax error in an autoloaded file is reported as
8841    // `_files:40: parse error: …`, not against the frame that triggered the
8842    // load. zshrs runs the only parse of the raw body right here, so the
8843    // same window belongs around it — without it the diagnostic came out as
8844    // `g:40:` for `g(){ emulate ksh -c _files }`.
8845    //
8846    // `scriptname` is one process-wide cell where C's is a single-thread
8847    // global, and a worker-thread compile prints nothing anyway (`zwarning`,
8848    // utils.rs:150, routes its diagnostics to the log) — so only the shell
8849    // thread takes the window, and no worker can blank out a prefix the shell
8850    // thread is in the middle of writing.
8851    let swap_scriptname = !crate::worker::in_worker_thread();
8852    let old_scriptname = crate::ported::utils::scriptname_get(); // c:6257
8853    if swap_scriptname {
8854        crate::ported::utils::set_scriptname(Some(name.to_string())); // c:6263
8855    }
8856    let saved_errflag = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
8857    crate::ported::utils::errflag.fetch_and(
8858        !crate::ported::utils::ERRFLAG_ERROR,
8859        std::sync::atomic::Ordering::Relaxed,
8860    );
8861    let prog = crate::ported::exec::parse_string(body, 1); // c:6329 `r = parse_string(d, 1);` — reset_lineno: errors count lines within the file
8862    let parse_failed = (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
8863        & crate::ported::utils::ERRFLAG_ERROR)
8864        != 0
8865        || prog.is_none();
8866    crate::ported::utils::errflag.store(saved_errflag, std::sync::atomic::Ordering::Relaxed);
8867    if swap_scriptname {
8868        crate::ported::utils::set_scriptname(old_scriptname); // c:6265
8869    }
8870    let stripped = prog
8871        .map(|prog| {
8872            let original_len = prog.prog.len();
8873            // stripkshdef returns the input untouched when the prog
8874            // doesn't match the single-`function NAME` shape, and a
8875            // shorter (body-only) prog when it does. Compare the
8876            // wordcode length to detect the strip without owning the
8877            // post-strip Eprog (we only need the yes/no answer here).
8878            let prog_box = Box::new(prog);
8879            crate::ported::exec::stripkshdef(Some(prog_box), name)
8880                .map(|p| p.prog.len() != original_len)
8881                .unwrap_or(false)
8882        })
8883        .unwrap_or(false);
8884    let text = if stripped {
8885        body.to_string()
8886    } else {
8887        format!("{name}() {{\n{body}\n}}")
8888    };
8889    (text, parse_failed)
8890}
8891
8892// zsh_eval_context push/pop/sync relocated 2026-06-12 INTO doshfunc
8893// (src/ported/exec.rs) — its sole caller, and `zsh_eval_context` is
8894// that module's own static. The shell-visible mirror writes inline
8895// at the push site + the guard's Drop. No bridge indirection.
8896
8897impl ShellExecutor {
8898    /// Execute the trap body for a signal name from the REPL signal
8899    /// loop (bins/zshrs.rs CtrlC/CtrlD dispatch). Thin passthru to
8900    /// `traps_table` lookup + `execute_script` — kept as a method
8901    /// because the REPL loop owns `&mut ShellExecutor` and needs a
8902    /// single call point. The async signal-handler dispatch path
8903    /// goes through `crate::ported::signals::dotrap` instead.
8904    pub fn run_trap(&mut self, signal: &str) {
8905        let action = crate::ported::builtin::traps_table()
8906            .lock()
8907            .ok()
8908            .and_then(|t| t.get(signal).cloned());
8909        if let Some(body) = action {
8910            if !body.is_empty() {
8911                let _ = self.execute_string_without_exit_hooks(&body);
8912            }
8913        }
8914    }
8915}
8916
8917impl ShellExecutor {
8918    /// Expand glob pattern via canonical `glob_path` (port of
8919    /// `Src/glob.c::zglob`). Adds executor-side `current_command_glob_failed`
8920    /// cell so the dispatch layer skips the current command on NOMATCH +
8921    /// looks_like_glob instead of exiting the shell.
8922    pub fn expand_glob(&self, pattern: &str) -> Vec<String> {
8923        // c:Src/glob.c:1230-1233 — `if (unset(GLOBOPT) || !haswilds(ostr) ||
8924        // unset(EXECOPT)) { untokenize(ostr); return; }`: under NO_EXEC zglob
8925        // never globs, so no word (the command word included) can report
8926        // NOMATCH.
8927        if !crate::ported::zsh_h::isset(crate::ported::zsh_h::EXECOPT) {
8928            return vec![crate::ported::lex::untokenize(pattern)];
8929        }
8930        let errflag_before =
8931            crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
8932        let expanded = glob_path(pattern);
8933        if !expanded.is_empty() {
8934            // c:Src/glob.c:1871-1872 — `if (matchct) badcshglob |= 2;`
8935            // (at least one expansion on this command line worked).
8936            // Only real glob patterns count — C's zglob early-returns
8937            // before the matchct accounting for non-wild words, so
8938            // gate on haswilds like the failure path below. Consumed
8939            // per command by fusevm_bridge::consume_badcshglob.
8940            if crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLGLOB) {
8941                let mut pattern_tok = pattern.to_string();
8942                crate::ported::glob::tokenize(&mut pattern_tok);
8943                if crate::ported::pattern::haswilds(&pattern_tok) {
8944                    crate::ported::glob::BADCSHGLOB
8945                        .fetch_or(2, std::sync::atomic::Ordering::Relaxed);
8946                }
8947            }
8948            return expanded;
8949        }
8950        // c:Src/glob.c:1786-1788 — `if (errflag) { restore_globstate(saved);
8951        // return; }`. A qualifier-parse error returns from `zglob` outright,
8952        // so C never reaches the c:1873-1886 nullglob/nomatch dispatch below.
8953        // The port has to re-derive `gf_nullglob` from the pattern because
8954        // `glob_path` hands back only a `Vec` — and that SECOND qualifier
8955        // parse re-runs every diagnostic the first one already emitted. It is
8956        // normally invisible because `zerr` suppresses itself while
8957        // ERRFLAG_ERROR is set (c:Src/utils.c:175), but a subscript qualifier
8958        // runs the lexer (`getindex` → `parse_subscript` → `strinbeg` →
8959        // `hbegin`, c:Src/hist.c:1115 `errflag &= ~ERRFLAG_ERROR`), which
8960        // clears exactly that bit — so `*(N[1,])` printed `bad math
8961        // expression: empty string` twice. Bail out where C's `return` lands.
8962        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
8963            // A diagnostic THIS glob raised (`bad pattern: a[B`, a bad
8964            // qualifier) belongs to the same per-command carrier as the
8965            // c:1877 NOMATCH below. C runs an external command's prefork
8966            // expansion in the child forked at Src/exec.c:3719, so the
8967            // `zerr` there only costs that child (status 1) and the list
8968            // goes on; a builtin expands in the shell and the errflag aborts
8969            // the list. The dispatch layer makes exactly that split for
8970            // `current_command_glob_failed`; without it `/bin/echo a[B;
8971            // print after` aborted the list and the `find` shadow ran with
8972            // the raw word after printing the diagnostic.
8973            if errflag_before == 0 {
8974                self.current_command_glob_failed.set(true);
8975            }
8976            return Vec::new();
8977        }
8978        // c:1871-1872 — `if (matchct) badcshglob |= 2;`: files DID match and
8979        // only the `[first,last]` subscript left none (`*([100])`), so this
8980        // is not a failed expansion — the word simply expands to nothing.
8981        if crate::ported::glob::CURGLOBDATA
8982            .lock()
8983            .unwrap_or_else(|e| e.into_inner())
8984            .matchct
8985            > 0
8986        {
8987            if crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLGLOB) {
8988                crate::ported::glob::BADCSHGLOB.fetch_or(2, std::sync::atomic::Ordering::Relaxed);
8989            }
8990            return Vec::new();
8991        }
8992        // No matches. Mirror zsh's `setopt nullglob` / `nomatch`
8993        // dispatch (Src/glob.c:1873-1886) here because glob_path
8994        // returns an empty Vec without knowing executor state.
8995        // c:Src/glob.c:1567-1569 `gf_nullglob` per-glob — the `(N)`
8996        // qualifier acts like `setopt nullglob` for this expression
8997        // alone. parse_qualifiers detects the suffix `(...)` block;
8998        // the resulting `qualifiers.nullglob` mirrors C's gf_nullglob
8999        // carrier.
9000        let per_glob_nullglob = crate::ported::glob::parse_qualifiers(pattern)
9001            .1
9002            .map(|q| q.nullglob)
9003            .unwrap_or(false);
9004        let nullglob = opt_state_get("nullglob").unwrap_or(false) || per_glob_nullglob;
9005        if nullglob {
9006            // c:Src/glob.c:1888-1894 —
9007            //   `else if (in_expandredir) {`
9008            //     `/* if completing for redirection, we can't remove the`
9009            //     `   pattern even if NULL_GLOB is in effect */`
9010            //     `zerr("redirection failed (no match): %s", ostr);`
9011            //     `zfree(matchbuf, 0);`
9012            //     `restore_globstate(saved);`
9013            //     `return;`
9014            //   `}`
9015            // Reached ONLY when gf_nullglob is set (the `else if` chain at
9016            // c:1873 owns every other no-match case), which is exactly the
9017            // `> file(N)` shape: dropping the word would leave the
9018            // redirection with no target at all, so `echo > nope(N)` failed
9019            // with an empty filename in `no such file or directory:`.
9020            if crate::ported::glob::IN_EXPANDREDIR.load(std::sync::atomic::Ordering::SeqCst) != 0 {
9021                zerr(&format!(
9022                    "redirection failed (no match): {}",
9023                    crate::ported::lex::untokenize(pattern)
9024                )); // c:1891
9025                self.current_command_glob_failed.set(true);
9026                return Vec::new(); // c:1894
9027            }
9028            return Vec::new();
9029        }
9030        let nomatch = opt_state_get("nomatch").unwrap_or(true);
9031        // Use canonical `haswilds` (port of Src/pattern.c:4306-4376)
9032        // instead of the Rust-only `looks_like_glob`. C zsh's
9033        // `Src/glob.c:1876` NOMATCH branch fires whenever the input
9034        // tripped haswilds during the `zglob` entry check —
9035        // including patterns whose internal `(` / `)` form a group
9036        // or alternation but don't end with `)` (e.g. `abc(a)def`,
9037        // `(abc`). The previous `looks_like_glob` only caught
9038        // trailing-`(...)` qualifiers, leaving mid-word groups and
9039        // unclosed parens to fall through to the literal-passthrough
9040        // branch. #170 in docs/BUGS.md.
9041        //
9042        // haswilds scans TOKENIZED strings (C's zglob gets the
9043        // lexer-tokenized word at Src/glob.c:1230); this entry point
9044        // receives untokenized fast-path patterns, so tokenize a
9045        // local copy first — the same preparation C applies to
9046        // runtime-built strings (compcore.c:2231 tokenizes fignore
9047        // entries before its haswilds call). tokenize Bnull's
9048        // backslash-escaped metachars, so `\*` stays literal here
9049        // exactly as in C. Bug #627: plain multibyte text (`↔`)
9050        // passes through tokenize unchanged and matches no token.
9051        let mut pattern_tok = pattern.to_string();
9052        crate::ported::glob::tokenize(&mut pattern_tok); // c:Src/glob.c:3548
9053        let is_glob = crate::ported::pattern::haswilds(&pattern_tok);
9054        // c:Src/glob.c:1874-1875 — `if (isset(CSHNULLGLOB)) {
9055        // badcshglob |= 1; }` — the else-if chain means neither the
9056        // NOMATCH error nor the literal passthrough runs: the failed
9057        // word is silently DROPPED here, and the per-command boundary
9058        // (fusevm_bridge::consume_badcshglob, Src/subst.c:505-507)
9059        // emits the csh-style `no match` iff NO glob on the line
9060        // matched.
9061        if is_glob && crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLGLOB) {
9062            crate::ported::glob::BADCSHGLOB.fetch_or(1, std::sync::atomic::Ordering::Relaxed);
9063            return Vec::new();
9064        }
9065        if nomatch && is_glob {
9066            // c:Src/glob.c:1876-1880 — `else if (isset(NOMATCH)) {`
9067            //   `zerr("no matches found: %s", ostr);`
9068            //   `zfree(matchbuf, 0);`
9069            //   `restore_globstate(saved);`
9070            //   `return;`
9071            // `}`
9072            // C aborts via ERRFLAG_ERROR set by zerr() at c:Src/utils.c
9073            // and the matchbuf/state cleanup. The Rust port mirrors
9074            // both: zerr() in utils.rs sets ERRFLAG_ERROR via
9075            // `errflag.fetch_or(ERRFLAG_ERROR, ...)` already; we then
9076            // re-set explicitly (defensive — historically this line
9077            // had `fetch_and(!ERRFLAG_ERROR)` which CLEARED the flag
9078            // immediately after zerr, making `echo /never/*` print
9079            // the literal and exit 0 instead of erroring like zsh —
9080            // parity bug #13).
9081            // c:1877 `zerr("no matches found: %s", ostr);` — `ostr` is
9082            // the TOKENIZED word, and zerrmsg's `%s` arm renders it
9083            // through `nicezputs` → `sb_niceformat`, which calls
9084            // `untokenize(ums)` (Src/utils.c). Without that step the
9085            // token bytes are dropped by the terminal and the message
9086            // reads `no matches found: /tmp/nope_.txt`.
9087            zerr(&format!(
9088                "no matches found: {}",
9089                crate::ported::lex::untokenize(pattern)
9090            )); // c:1877
9091            self.current_command_glob_failed.set(true);
9092            // c:Src/glob.c:1876-1880 — zerr sets ERRFLAG_ERROR and
9093            // glob_failed cell carries the signal. The ERRFLAG_ERROR
9094            // clear (so subsequent sublists run) now lives at the
9095            // dispatcher's post-command-boundary at
9096            // fusevm_bridge.rs:299 where current_command_glob_failed
9097            // is consumed — matches C's execlist behavior of clearing
9098            // command-error errflag between sublists.
9099            return Vec::new(); // c:1880 return
9100        }
9101        // Pattern has no glob meta — pass through literally.
9102        // c:Src/glob.c:1882-1886 — `/* treat as an ordinary string */
9103        // untokenize(matchptr->name = dupstring(ostr));`. The word
9104        // arrives here in LEXER-TOKENIZED form (c:1221 `ostr =
9105        // getdata(np)`), so the literal fallback MUST untokenize or the
9106        // raw token bytes reach stdout: `unsetopt nomatch; echo
9107        // /tmp/nope_*.txt` printed `/tmp/nope_\u{e187}.txt`.
9108        vec![crate::ported::lex::untokenize(pattern)]
9109    }
9110    /// True iff the literal `pattern` actually contains a glob metachar
9111    /// in a position that would have triggered globbing. Used to avoid
9112    /// spurious "no matches" errors when expand_glob is called on a
9113    /// plain path that happened to route through this code (e.g. some
9114    /// fast paths bridge unconditionally).
9115    pub(crate) fn looks_like_glob(pattern: &str) -> bool {
9116        // A trailing `(qualifier)` is itself a glob trigger — e.g.
9117        // `path(L+10)` should be treated as a glob even when the
9118        // body has no `*`/`?`/`[...]`.
9119        let has_qual_suffix = if let Some(open) = pattern.rfind('(') {
9120            pattern.ends_with(')') && open + 1 < pattern.len() - 1
9121        } else {
9122            false
9123        };
9124        // Strip trailing `(...)` qualifier so we test the pattern body.
9125        let body = if let Some(open) = pattern.rfind('(') {
9126            if pattern.ends_with(')') {
9127                &pattern[..open]
9128            } else {
9129                pattern
9130            }
9131        } else {
9132            pattern
9133        };
9134        // Walk character-by-character so escaped metachars (`\*`, `\?`,
9135        // `\[`) are NOT counted as glob triggers. zsh: `echo \*` prints
9136        // a literal `*`; without the unescaped check, looks_like_glob
9137        // returned true on the bare `*` and the runtime glob expansion
9138        // aborted with NOMATCH.
9139        let chars: Vec<char> = body.chars().collect();
9140        let mut i = 0;
9141        let mut has_unescaped_star = false;
9142        let mut has_unescaped_question = false;
9143        let mut has_unescaped_bracket_open: Option<usize> = None;
9144        while i < chars.len() {
9145            let c = chars[i];
9146            if c == '\\' && i + 1 < chars.len() {
9147                // Escaped char — skip both.
9148                i += 2;
9149                continue;
9150            }
9151            match c {
9152                '*' => has_unescaped_star = true,
9153                '?' => has_unescaped_question = true,
9154                '[' if has_unescaped_bracket_open.is_none() => {
9155                    has_unescaped_bracket_open = Some(i);
9156                }
9157                _ => {}
9158            }
9159            i += 1;
9160        }
9161        // `[` only counts when there's a matching `]` after it.
9162        let has_bracket_class = has_unescaped_bracket_open
9163            .map(|i| body[i + 1..].contains(']'))
9164            .unwrap_or(false);
9165        // `<N-M>` numeric range glob is also a trigger — match shape
9166        // `<` + optional digits + `-` + optional digits + `>` outside
9167        // any bracket expression.
9168        let has_numeric_range =
9169            body.contains('<') && body.contains('>') && !extract_numeric_ranges(body).is_empty();
9170        has_unescaped_star
9171            || has_unescaped_question
9172            || has_bracket_class
9173            || has_qual_suffix
9174            || has_numeric_range
9175    }
9176}
9177
9178impl ShellExecutor {
9179    pub(crate) fn copy_dir_recursive(src: &Path, dest: &Path) -> io::Result<()> {
9180        if !dest.exists() {
9181            fs::create_dir_all(dest)?;
9182        }
9183        for entry in fs::read_dir(src)? {
9184            let entry = entry?;
9185            let file_type = entry.file_type()?;
9186            let src_path = entry.path();
9187            let dest_path = dest.join(entry.file_name());
9188
9189            if file_type.is_dir() {
9190                Self::copy_dir_recursive(&src_path, &dest_path)?;
9191            } else {
9192                fs::copy(&src_path, &dest_path)?;
9193            }
9194        }
9195        Ok(())
9196    }
9197}
9198
9199// Magic-assoc scan-by-name aggregator. C's per-table getfn/scanfn
9200// pointers in paramdef[] (Src/Modules/parameter.c:825+) handle this
9201// indirectly via paramtab dispatch; this Rust-only helper exposes a
9202// single `partab_get` / `partab_scan_keys` entry that the bridge
9203// uses for name → keys lookup.
9204use std::cell::RefCell;
9205thread_local! {
9206    static SCAN_KEYS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
9207}
9208
9209/// Lookup helper for `${name[key]}` magic-assoc reads — dispatches
9210/// through canonical `PARTAB` (Src/Modules/parameter.c:2235 ports).
9211/// Returns `None` if name isn't a known magic-assoc.
9212/// Module parameters that have actually been touched this session.
9213///
9214/// zshrs-original bookkeeping for a C behavior that falls out of the module
9215/// system there. zsh registers `zsh/parameter`'s params (`aliases`,
9216/// `commands`, `functions`, …) as PM_AUTOLOAD stubs in `realparamtab`;
9217/// touching ONE of them materializes only that name — its siblings stay
9218/// stubs even though the module is now loaded. `paramtypestr`
9219/// (Src/Modules/parameter.c:49-50) reports a PM_AUTOLOAD node as
9220/// "undefined", which is what an enumeration of `$parameters` shows.
9221/// zshrs seeds all of them eagerly (init_partab_params), so without this
9222/// set every one reported its real type and `${(@k)parameters[(R)a*]}`
9223/// matched 56 names against zsh's 18 — putting them in the wrong
9224/// `_parameters -g` bucket (`unset <TAB>`: 418 entries vs zsh's 496).
9225static MATERIALIZED_MODULE_PARAMS: std::sync::OnceLock<Mutex<HashSet<String>>> =
9226    std::sync::OnceLock::new();
9227
9228/// Record that `name` was read/written, so `$parameters` stops reporting it
9229/// as an unmaterialized autoload stub. See [`MATERIALIZED_MODULE_PARAMS`].
9230pub fn mark_module_param_used(name: &str) {
9231    let set = MATERIALIZED_MODULE_PARAMS.get_or_init(|| Mutex::new(HashSet::new()));
9232    let first_touch = {
9233        let mut g = set.lock();
9234        // Drop the guard before the module load below — `boot_` runs shell
9235        // code (setsparam/setiparam) that can re-enter this function.
9236        g.insert(name.to_string())
9237    };
9238    if first_touch {
9239        materialize_module_param(name);
9240    }
9241}
9242
9243/// The side effect C's `loadparamnode` (`Src/params.c:563-585`) has beyond
9244/// clearing PM_AUTOLOAD: `(void)ensurefeature(mn, "p:", nam)`
9245/// (`Src/module.c:3419-3432`) actually LOADS the owning module, running its
9246/// `setup_`/`boot_`. `zsh/watch`'s `boot_` (`Src/Modules/watch.c:750-753`)
9247/// seeds `WATCHFMT`/`LOGCHECK` when absent, so in zsh
9248/// `${parameters[watch]}` leaves `${parameters[LOGCHECK]}` == "integer".
9249///
9250/// !!! WARNING: RUST-ONLY HELPER !!!
9251/// C has no separate function: `loadparamnode` calls `ensurefeature`
9252/// inline. zshrs models PM_AUTOLOAD as a side-set rather than a node flag
9253/// (see [`MATERIALIZED_MODULE_PARAMS`]), so the load side effect needs its
9254/// own hook off the marking point. The `try_lock` and the re-entrancy guard
9255/// are also Rust-only: C serialises on `queue_signals`, whereas zshrs's
9256/// `MODULESTAB` is a real mutex that several callers of
9257/// `mark_module_param_used` already hold.
9258fn materialize_module_param(name: &str) {
9259    use std::cell::Cell;
9260    thread_local! {
9261        static LOADING: Cell<bool> = const { Cell::new(false) };
9262    }
9263    // c:Src/params.c:566 — only PM_AUTOLOAD stubs carry `pm->u.str` (the
9264    // owning module name); anything else falls straight through.
9265    let Some((_, modname)) = AUTOLOAD_PARAMS.iter().find(|(p, _)| *p == name) else {
9266        return;
9267    };
9268    if LOADING.with(|f| f.get()) {
9269        return;
9270    }
9271    // The whole load chain wants `&mut modulestab`. Every other caller takes
9272    // the same lock for a moment; if one of them is mid-flight (or is our own
9273    // caller), skip rather than deadlock — the mark itself already landed.
9274    let Ok(mut tab) = crate::ported::module::MODULESTAB.try_lock() else {
9275        return;
9276    };
9277    // c:Src/module.c:2352 — require_module short-circuits on an already
9278    // booted module, but check first so the common case never pays for the
9279    // find_module/alias walk.
9280    if tab
9281        .modules
9282        .get(*modname)
9283        .is_some_and(|m| (m.node.flags & crate::ported::zsh_h::MOD_INIT_B) != 0)
9284    {
9285        return;
9286    }
9287    LOADING.with(|f| f.set(true));
9288    // c:3419-3432 ensurefeature(mn, "p:", nam) — `silent` is 0 in C, but a
9289    // failure here is not user-visible in the autoload path (c:571-580 only
9290    // errors when the parameter is still undefined afterwards), and zshrs's
9291    // require_module warns on any module it cannot static-link.
9292    let _ = crate::ported::module::ensurefeature(&mut tab, modname, "p:", Some(name)); // c:3419
9293    LOADING.with(|f| f.set(false));
9294}
9295
9296/// Undo [`mark_module_param_used`] — the `pd->pm = NULL` half of C's
9297/// `deleteparamdef` (`Src/module.c:1128-1179`), reached from
9298/// `setparamdefs` (`c:1176-1180`) whenever a module feature is turned OFF
9299/// (`zmodload -F zsh/parameter -p:functions`, or a single-feature load
9300/// leaving the siblings disabled).
9301///
9302/// !!! WARNING: RUST-ONLY HELPER !!!
9303/// C has no separate function: `deleteparamdef` removes the node from
9304/// `paramtab` outright. zshrs seeds every magic parameter eagerly and models
9305/// PM_AUTOLOAD as the [`MATERIALIZED_MODULE_PARAMS`] side-set, so "removed"
9306/// is "back to being an unmaterialized stub".
9307pub fn unmark_module_param_used(name: &str) {
9308    let set = MATERIALIZED_MODULE_PARAMS.get_or_init(|| Mutex::new(HashSet::new()));
9309    set.lock().remove(name);
9310}
9311
9312/// True when `name` is still an untouched module-parameter stub — i.e. zsh
9313/// would report it as PM_AUTOLOAD ("undefined") when enumerating
9314/// `$parameters`. See [`MATERIALIZED_MODULE_PARAMS`].
9315pub fn module_param_is_autoload_stub(name: &str) -> bool {
9316    if !AUTOLOAD_PARAMS.iter().any(|(p, _)| *p == name) {
9317        return false;
9318    }
9319    MATERIALIZED_MODULE_PARAMS
9320        .get_or_init(|| Mutex::new(HashSet::new()))
9321        .lock()
9322        .contains(name)
9323        .eq(&false)
9324}
9325
9326/// True when the magic special parameter `name` (a `partab[]` row from
9327/// `Src/Modules/parameter.c:2235-2298` — `options`, `functions`,
9328/// `commands`, `parameters`, `dirstack`, …) is currently SHADOWED by a
9329/// plain user parameter of the same name.
9330///
9331/// Rust-only helper with no C counterpart BY CONSTRUCTION: C zsh keeps
9332/// specials and user parameters in the ONE `paramtab` hash, so ordinary
9333/// hash lookup already implements this. `createparam`
9334/// (c:Src/params.c:1090-1115) finds the existing special node, stashes
9335/// it in `pm->old`, and inserts a fresh plain node under the same key;
9336/// every later `getvalue` / `fetchvalue` / `gethashparam` therefore hits
9337/// the plain node and the special's `gsu` callbacks are unreachable
9338/// until `endparamscope` restores `pm->old`. zshrs instead keeps the
9339/// magic rows in SEPARATE static tables (`PARTAB`, `PARTAB_ARRAY`) and
9340/// matches them BY NAME, so a name-only match resurrected the special
9341/// even while a local shadowed it. This predicate re-imposes C's
9342/// shadowing on the split-table layout; it is architecture glue, not a
9343/// port.
9344///
9345/// `init_partab_params` (below) seeds every magic row into `paramtab`
9346/// with `PM_SPECIAL` (C's `SPECIALPMDEF` macro), and `local`/`typeset`
9347/// replaces that node with one carrying no `PM_SPECIAL`, so the live
9348/// node's `PM_SPECIAL` bit is exactly C's "is the special still the
9349/// visible binding" test.
9350///
9351/// Returns false when a MODULE-GATED row (`sysparams`, `errnos`,
9352/// `mapfile`, `langinfo`) has no `paramtab` node and its module is not
9353/// loaded: those are seeded on
9354/// demand by `seed_partab_param`, and the PARTAB walk must still answer
9355/// for them (their own `module` gate decides).
9356///
9357/// For every other magic row an ABSENT node means the binding is gone —
9358/// `unset` removed it. C reaches the same answer through one gate:
9359/// `Src/params.c:2264-2266` `if (!pm || ((pm->node.flags & PM_UNSET) &&
9360/// !(pm->node.flags & PM_DECLARED))) return NULL;` in `fetchvalue`, the
9361/// single choke point every `${X}` / `${#X}` / `${(k)X}` / `${(t)X}` /
9362/// `${X[k]}` read passes through. Both of its arms show up here:
9363///  * `!pm` — `unset functions` at a point where the name is still the
9364///    `PM_AUTOLOAD` stub (`Src/module.c:1218-1223`) finds a PLAIN
9365///    `PM_SCALAR` node with neither `PM_SPECIAL` nor `PM_READONLY`, so
9366///    `unsetparam_pm`'s c:3851-3852 keep-the-node test
9367///    (`(flags & (PM_SPECIAL|PM_REMOVABLE)) == PM_SPECIAL`) is false and
9368///    c:3874 `paramtab->removenode` drops it outright.
9369///  * `PM_UNSET && !PM_DECLARED` — once the special HAS been
9370///    materialized, c:3851-3852 keeps the node and `stdunsetfn`
9371///    (c:3939) marking `PM_UNSET` is the entire record of the unset;
9372///    `setpmfunctions(pm, NULL)` returns immediately on `if (!ht)
9373///    return` (`Src/Modules/parameter.c:361-362`), so `shfunctab` — the
9374///    real table behind the row — survives untouched and `ff` still runs.
9375///
9376/// Both arms mean the same thing for a split-table port: the magic row
9377/// is no longer the visible binding for this name, exactly as when a
9378/// `local` shadows it. Answering that one question here is what lets
9379/// every PARTAB dispatch site keep a single guard.
9380///
9381/// Symptom this fixes: git's `git-completion.bash`
9382/// `__git_resolve_builtins` does `local options; eval
9383/// "options=\${$var-}"` (git 2.55.0
9384/// share/zsh/site-functions/git-completion.bash:500-501). `$options`
9385/// read back the zsh/parameter option table (`off on off …`) instead of
9386/// the local, so `git checkout --<TAB>` completed nothing.
9387pub fn magic_special_shadowed(name: &str) -> bool {
9388    // Only `partab[]` names can be shadowed in this sense — an ordinary
9389    // user assoc / array has no special behind it, and its own paramtab
9390    // node legitimately carries no PM_SPECIAL.
9391    if !PARTAB.iter().any(|e| e.name == name) && !PARTAB_ARRAY.iter().any(|e| e.name == name) {
9392        return false;
9393    }
9394    // A module-gated row (`errnos`, `sysparams`, `mapfile`, `langinfo`)
9395    // gets its paramtab node from the owning module's setparamdefs
9396    // (Src/module.c:1169-1196), so while that module is loaded an absent
9397    // node means its `p:` feature is off: deleteparamdef (c:1190) removed
9398    // it, or the module was loaded with other features only. Read the
9399    // module state before taking the paramtab lock.
9400    let gated_loaded = module_gated_partab_module(name)
9401        .map(|m| crate::ported::module::MODULESTAB.lock().unwrap().is_loaded(m));
9402    crate::ported::params::paramtab()
9403        .read()
9404        .map_or(false, |tab| {
9405            let Some(pm) = tab.get(name) else {
9406                // c:Src/params.c:2264 `if (!pm ...) return NULL` —
9407                // `unset` removed the node (see the doc comment). Only
9408                // a valid reading once the rows have been seeded. A
9409                // gated row whose module is not loaded is left to the
9410                // module gate in partab_get / partab_array_get.
9411                return match gated_loaded {
9412                    Some(loaded) => loaded,
9413                    None => PARTAB_SEEDED.load(std::sync::atomic::Ordering::Acquire),
9414                };
9415            };
9416            {
9417                // c:Src/params.c:2264-2266 — `(pm->node.flags & PM_UNSET)
9418                // && !(pm->node.flags & PM_DECLARED)`: the materialized
9419                // special was unset and the node kept (c:3851-3852).
9420                let f = pm.node.flags as u32;
9421                if (f & crate::ported::zsh_h::PM_UNSET) != 0
9422                    && (f & crate::ported::zsh_h::PM_DECLARED) == 0
9423                {
9424                    return true;
9425                }
9426            }
9427            {
9428                // c:Src/module.c:1029-1052 checkaddparam — `if (pm->level ||
9429                // !(pm->node.flags & PM_AUTOLOAD))` is C's OWN test for "is
9430                // this node a blocker or the module's own placeholder": a
9431                // GLOBAL PM_AUTOLOAD node is the autoload STUB
9432                // `add_autoparam` planted (c:1222-1223 `setsparam(pnam,
9433                // module); pm->node.flags |= PM_AUTOLOAD` — its VALUE is the
9434                // owning module's name), and C replaces it with the real
9435                // special via `unsetparam_pm` (c:1051) + `createspecialhash`
9436                // (c:1068) the moment the module loads. Reading the name is
9437                // what triggers that: c:Src/params.c:563-585 loadparamnode
9438                // runs `ensurefeature(mn, "p:", nam)` and re-fetches the node.
9439                // So a stub NEVER makes the special unreachable — treating it
9440                // as a shadow left `${options}` reading the stub's own scalar
9441                // value ("zsh/parameter") and killed every magic-assoc read
9442                // for that name for the rest of the session. A LOCAL stub
9443                // (pm->level != 0) still blocks, exactly as c:1032 says.
9444                let f = pm.node.flags as u32;
9445                if pm.level == 0 && (f & crate::ported::zsh_h::PM_AUTOLOAD) != 0 {
9446                    return false;
9447                }
9448                (f & crate::ported::zsh_h::PM_SPECIAL) == 0
9449            }
9450        })
9451}
9452
9453/// [`magic_special_shadowed`] AND the shadow is not itself a hash.
9454///
9455/// "No longer the magic row" is not the same as "no longer a hash". C
9456/// decides the subscript dispatch — and every whole-map read — from the
9457/// TYPE of the node paramtab actually returned:
9458///   c:Src/params.c:2270 `if (PM_TYPE(pm->node.flags) & (PM_ARRAY|PM_HASHED))`
9459///   c:Src/params.c:1597-1606 getarg —
9460///       if (ishash) {
9461///           HashTable ht = v->pm->gsu.h->getfn(v->pm);
9462///           ...
9463///           if (!(v->pm = (Param) ht->getnode(ht, s))) ...
9464/// So a shadow that IS itself PM_HASHED (`local -A commands`, or
9465/// `local -A +h commands` as in Completion/Zsh/Type/_command_names:70)
9466/// owns a table of its own and must be SERVED FROM IT, while a scalar or
9467/// array shadow (`local options`, `local -a options`) must fall through
9468/// to the non-hash arms.
9469///
9470/// Bailing on every shadowed magic name made the hashed shadow invisible
9471/// to each reader in turn: the keyed read (`$commands[fake]`), then
9472/// `${#commands}`, `${(k)commands}`, `${(kv)commands}` and
9473/// `${commands[(I)pat]}`. The values were in `paramtab_hashed_storage`
9474/// the whole time — `${commands[@]}` found them — only these guards
9475/// refused to look. Every dispatch site that means "the magic row is not
9476/// the visible binding, and there is no hash behind it either" asks
9477/// through here so the qualification cannot drift between them.
9478pub fn magic_special_shadowed_by_nonhash(name: &str) -> bool {
9479    magic_special_shadowed(name)
9480        && crate::ported::params::paramtab()
9481            .read()
9482            .ok()
9483            .and_then(|t| t.get(name).map(|pm| pm.node.flags as u32))
9484            // c:2270 — PM_TYPE decides; absent node ⇒ nothing to serve; c:2264-2266 — nor
9485            // does an unset, undeclared node (`zmodload zsh/parameter; unset functions`).
9486            .map_or(true, |f| {
9487                use crate::ported::zsh_h::{PM_DECLARED, PM_TYPE, PM_UNSET};
9488                PM_TYPE(f) != PM_HASHED || (f & (PM_UNSET | PM_DECLARED)) == PM_UNSET
9489            })
9490}
9491
9492pub fn partab_get(name: &str, key: &str) -> Option<String> {
9493    // C's paramtab lookup would already have found the shadowing local;
9494    // the split-table port needs the explicit check.
9495    if magic_special_shadowed(name) {
9496        return None;
9497    }
9498    mark_module_param_used(name);
9499    // c:Src/Modules/system.c:902,904 — `sysparams` and `errnos` are
9500    // bound by zsh/system's boot_/setup_ chain. Same for `mapfile`
9501    // from zsh/mapfile. Without explicit `zmodload`, these names
9502    // are unset in zsh; gate the PARTAB dispatch here so they
9503    // resolve via the empty-fallback path (matching ${sysparams[k]:-x}
9504    // taking the default). Bug #69 in docs/BUGS.md.
9505    if let Some(modname) = module_gated_partab_module(name) {
9506        if !crate::ported::module::MODULESTAB
9507            .lock()
9508            .unwrap()
9509            .is_loaded(modname)
9510        {
9511            return None;
9512        }
9513    }
9514    for entry in PARTAB.iter() {
9515        if entry.name == name {
9516            // c:Src/Modules/parameter.c — a row getfn (`getpmfunction`,
9517            // `getpmcommand`, `getpmoption`, …) answers a missing key with a
9518            // node carrying `PM_UNSET` (e.g. getfunction, parameter.c:449 `pm->node.flags |=
9519            // (PM_UNSET|PM_SPECIAL)`), which `IS_UNSET_VALUE` (params.c:479)
9520            // and `issetvar` (params.c:764-765) read as unset. Keep that
9521            // distinction: dropping the flag turned a miss into a set empty
9522            // value, so `[[ -v functions[nosuch] ]]` was true.
9523            return (entry.getfn)(std::ptr::null_mut(), key)
9524                .filter(|p| p.node.flags as u32 & crate::ported::zsh_h::PM_UNSET == 0)
9525                .and_then(|p| p.u_str);
9526        }
9527    }
9528    None
9529}
9530
9531/// Returns the owning module name for partab entries that are
9532/// bound by an explicit zmodload — `sysparams`/`errnos` from
9533/// zsh/system, `mapfile` from zsh/mapfile. Other partab entries
9534/// (aliases/commands/functions/...) are part of zsh/main and
9535/// always available.
9536fn module_gated_partab_module(name: &str) -> Option<&'static str> {
9537    match name {
9538        "sysparams" | "errnos" => Some("zsh/system"),
9539        "mapfile" => Some("zsh/mapfile"),
9540        "langinfo" => Some("zsh/langinfo"),
9541        ".zle.esc" | ".zle.sgr" => Some("zsh/hlgroup"),
9542        ".sh.match" => Some("zsh/ksh93"),
9543        _ => None,
9544    }
9545}
9546
9547/// Publish a value into a read-only special from shell-INTERNAL code.
9548///
9549/// C binds these params to C variables through a gsu vtable —
9550/// `compvarscalar_gsu` for `$QIPREFIX`/`$QISUFFIX`
9551/// (Src/Zle/complete.c:1308-1324), `keymap_gsu` for `$KEYMAP`
9552/// (Src/Zle/zle_params.c:151) — and the shell's own writes go straight
9553/// to that variable. PM_READONLY is only consulted on the ASSIGNMENT
9554/// path (`assignsparam`, Src/params.c), so the bit stops a user's
9555/// `QIPREFIX=x` without ever standing in the way of the completion
9556/// machinery's own publish.
9557///
9558/// zshrs keeps the value in the param itself, so the internal publish
9559/// has to step around the same gate explicitly: drop PM_READONLY,
9560/// assign through the canonical path, put the bit back.
9561pub fn set_readonly_special(name: &str, value: &str) {
9562    use crate::ported::zsh_h::PM_READONLY;
9563    let was_readonly = crate::ported::params::paramtab()
9564        .write()
9565        .ok()
9566        .and_then(|mut tab| {
9567            tab.get_mut(name).map(|pm| {
9568                let ro = (pm.node.flags & PM_READONLY as i32) != 0;
9569                pm.node.flags &= !(PM_READONLY as i32);
9570                ro
9571            })
9572        })
9573        .unwrap_or(false);
9574    let _ = crate::ported::params::setsparam(name, value);
9575    if was_readonly {
9576        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
9577            if let Some(pm) = tab.get_mut(name) {
9578                pm.node.flags |= PM_READONLY as i32;
9579            }
9580        }
9581    }
9582}
9583
9584/// PM_ARRAY lookup for `${name}` / `${name[N]}` — walks
9585/// PARTAB_ARRAY and dispatches the whole-array getfn (Src/Modules/
9586/// parameter.c:2239-2291 ports). Returns `None` if name isn't a
9587/// known PM_ARRAY magic-assoc.
9588pub fn partab_array_get(name: &str) -> Option<Vec<String>> {
9589    // c:Src/params.c:1090-1115 createparam — see `partab_get`.
9590    if magic_special_shadowed(name) {
9591        return None;
9592    }
9593    mark_module_param_used(name);
9594    // Bug #69 — gate module-bound PARTAB names on the owning
9595    // module's MOD_LINKED && !MOD_UNLOAD state.
9596    if let Some(modname) = module_gated_partab_module(name) {
9597        if !crate::ported::module::MODULESTAB
9598            .lock()
9599            .unwrap()
9600            .is_loaded(modname)
9601        {
9602            return None;
9603        }
9604    }
9605    for entry in PARTAB_ARRAY.iter() {
9606        if entry.name == name {
9607            return Some((entry.getfn)(std::ptr::null_mut()));
9608        }
9609    }
9610    None
9611}
9612
9613/// Scan helper for `${(k)name}` — enumerates keys via canonical
9614/// scanfn, collected into Vec via SCAN_KEYS thread-local.
9615pub fn partab_scan_keys(name: &str) -> Option<Vec<String>> {
9616    // c:Src/params.c:1090-1115 createparam — see `partab_get`.
9617    if magic_special_shadowed(name) {
9618        return None;
9619    }
9620    mark_module_param_used(name);
9621    // Bug #69 — gate module-bound PARTAB names on the owning
9622    // module's MOD_LINKED && !MOD_UNLOAD state.
9623    if let Some(modname) = module_gated_partab_module(name) {
9624        if !crate::ported::module::MODULESTAB
9625            .lock()
9626            .unwrap()
9627            .is_loaded(modname)
9628        {
9629            return None;
9630        }
9631    }
9632    for entry in PARTAB.iter() {
9633        if entry.name == name {
9634            SCAN_KEYS.with(|k| k.borrow_mut().clear());
9635            // c:Src/Modules/parameter.c — a param-table ScanFunc receives
9636            // `&pm.node` of a fully populated `struct param`; the Rust side
9637            // models that as `ParamScanFunc = fn(&param, i32)`.
9638            fn cb(pm: &crate::ported::zsh_h::param, _flags: i32) {
9639                SCAN_KEYS.with(|k| k.borrow_mut().push(pm.node.nam.clone()));
9640            }
9641            // c:Src/params.c:3138 — `paramvalarr(…, SCANPM_WANTKEYS)`: keys
9642            // only, so a scanfn need not materialize the value side.
9643            (entry.scanfn)(
9644                std::ptr::null_mut(),
9645                Some(cb),
9646                crate::ported::zsh_h::SCANPM_WANTKEYS as i32,
9647            );
9648            return Some(SCAN_KEYS.with(|k| k.borrow().clone()));
9649        }
9650    }
9651    None
9652}
9653/// Populate paramtab with PM_SPECIAL placeholder Params for every
9654/// PARTAB / PARTAB_ARRAY entry — Rust-only init helper, no direct
9655/// C counterpart (closest is `handlefeatures` walking `partab[]`
9656/// in `Src/Modules/parameter.c:2341` boot/enables chain).
9657///
9658/// Each magic-assoc name gets a Param with `entry.flags | PM_SPECIAL`.
9659/// Value reads still route through `partab_get` / `partab_array_get`;
9660/// having the Param in paramtab makes `paramtab.get(name)` return
9661/// Some(Param) so `${+name}` / `${(t)name}` / `typeset -p name` see
9662/// the entry. Without this, those reads returned empty for every
9663/// magic-assoc (aliases, commands, functions, etc.).
9664///
9665/// Called from ShellExecutor::new() since zshrs's bin entry skips
9666/// the canonical module-bootstrap chain.
9667pub fn init_partab_params() {
9668    use crate::ported::modules::parameter::{PARTAB, PARTAB_ARRAY};
9669    use crate::ported::zsh_h::{
9670        hashnode, param, Param, PM_HIDE, PM_HIDEVAL, PM_READONLY, PM_SPECIAL,
9671    };
9672    let mut tab = match paramtab().write() {
9673        Ok(t) => t,
9674        Err(_) => return,
9675    };
9676    // c:Src/zsh.h SPECIALPMDEF macro: `flags | PM_SPECIAL | PM_HIDE |
9677    // PM_HIDEVAL`. All magic-assoc/array params get HIDE+HIDEVAL added
9678    // by the macro itself.
9679    //
9680    // PM_READONLY is preserved on the stub for params that legitimately
9681    // need user-write protection (reswords, dis_reswords, patchars,
9682    // dis_patchars — all compute via getfn and have no legitimate
9683    // internal-write path). Other specials that DO have internal-write
9684    // paths (e.g. funcstack from function-call tracking) get the bit
9685    // stripped so the runtime can mutate their u_arr. Bug #374.
9686    // `parameters` is computed entirely by getpmparameter and has no
9687    // internal-write path either (zsh: PM_READONLY_SPECIAL, c:2287), so it
9688    // keeps the bit — `${parameters[parameters]}` reads
9689    // `association-readonly-hide-hideval-special` in zsh.
9690    // The remaining names below are the rest of C's PM_READONLY_SPECIAL
9691    // rows whose `partab[]` entry has a NULL gsu (c:2237/2243/2255/2265/
9692    // :2272/2276-2280/2284/2296-2298 + Src/Zle/zleparameter.c:133): they
9693    // are computed purely by their getfn/scanfn, so there is nothing for
9694    // the runtime to write and the bit is safe to keep. Without them
9695    // `${(t)builtins}` and friends reported
9696    // `association-hide-hideval-special` where zsh reports
9697    // `association-readonly-hide-hideval-special`.
9698    let user_protected: &[&str] = &[
9699        "parameters",
9700        "reswords",
9701        "dis_reswords",
9702        "patchars",
9703        "dis_patchars",
9704        "historywords",
9705        "errnos",
9706        "keymaps",
9707        "builtins",             // c:2237
9708        "dis_builtins",         // c:2243
9709        "functions_source",     // c:2265
9710        "dis_functions_source", // c:2247
9711        "history",              // c:2272
9712        "jobdirs",              // c:2276
9713        "jobstates",            // c:2278
9714        "jobtexts",             // c:2280
9715        "modules",              // c:2284
9716        "userdirs",             // c:2296
9717        "usergroups",           // c:2297
9718        "widgets",              // c:Src/Zle/zleparameter.c:133
9719        // c:2279-2280 — `SPECIALPMDEF("funcstack", PM_ARRAY|
9720        // PM_READONLY_SPECIAL, &funcstack_gsu, NULL, NULL)`. The bit was
9721        // being stripped here on the theory that the runtime writes
9722        // `funcstack`'s `u_arr`; it does not — `funcstackgetfn`
9723        // (PARTAB_ARRAY, parameter.rs:4726-4732) computes the value from
9724        // the `FUNCSTACK` global on every read and the row's `setfn` is
9725        // `None`, so there is nothing to protect against. Without the bit
9726        // `${(t)funcstack}` read `array-hide-hideval-special` where zsh
9727        // reads `array-readonly-hide-hideval-special`, which put it in the
9728        // wrong `_parameters -g '^*(readonly|association)*'` bucket and
9729        // added one candidate zsh does not offer.
9730        "funcstack", // c:2279
9731        // Same argument as `funcstack` above, for the three sibling trace
9732        // arrays: `SPECIALPMDEF(..., PM_ARRAY|PM_READONLY_SPECIAL, ...)` in C
9733        // and `setfn: None` in PARTAB_ARRAY (parameter.rs:4717/4725/4741), so
9734        // they are getfn-computed with no internal-write path to protect.
9735        "funcfiletrace",   // c:2275
9736        "funcsourcetrace", // c:2277
9737        "functrace",       // c:2285
9738        // c:Src/Modules/termcap.c:312 / Src/Modules/terminfo.c:305 —
9739        // `SPECIALPMDEF("termcap", PM_READONLY, NULL, gettermcap, scantermcap)`
9740        // and the terminfo twin: NULL gsu, value produced entirely by the
9741        // getnode/scan fns, so the readonly bit has nothing to fight.
9742        "termcap",  // c:Src/Modules/termcap.c:312
9743        "terminfo", // c:Src/Modules/terminfo.c:305
9744        // c:Src/Builtins/sched.c:382 — `SPECIALPMDEF(
9745        // "zsh_scheduled_events", PM_ARRAY|PM_READONLY, &sched_gsu,
9746        // NULL, NULL)`. Same shape as `funcstack` above: `schedgetfn`
9747        // (sched.rs:582) walks the schedcmds list on every read and the
9748        // PARTAB_ARRAY row's `setfn` is `None`, so no internal write
9749        // needs the bit cleared. `sched`/`sched -N` mutate the list,
9750        // never the param.
9751        "zsh_scheduled_events", // c:Src/Builtins/sched.c:382
9752    ];
9753    let mk_pm = |name: &str, flags: i32| -> Param {
9754        let keep_readonly = user_protected.contains(&name);
9755        let pre_readonly_mask = if keep_readonly {
9756            !0i32
9757        } else {
9758            !(PM_READONLY as i32)
9759        };
9760        Box::new(param {
9761            node: hashnode {
9762                next: None,
9763                nam: name.to_string(),
9764                flags: (flags & pre_readonly_mask)
9765                    | PM_SPECIAL as i32
9766                    | PM_HIDE as i32
9767                    | PM_HIDEVAL as i32,
9768            },
9769            u_data: 0,
9770            u_tied: None,
9771            u_arr: None,
9772            u_str: None,
9773            u_val: 0,
9774            u_dval: 0.0,
9775            u_hash: None,
9776            gsu_s: None,
9777            gsu_i: None,
9778            gsu_f: None,
9779            gsu_a: None,
9780            gsu_h: None,
9781            base: 0,
9782            width: 0,
9783            env: None,
9784            ename: None,
9785            old: None,
9786            level: 0,
9787        })
9788    };
9789    // c:Src/Modules/system.c:902,904 + Src/Modules/mapfile.c — these
9790    // params are provided by modules that real zsh requires explicit
9791    // `zmodload` for. Seeding them unconditionally makes
9792    // `${+sysparams}` return 1 by default (bug #69 in docs/BUGS.md),
9793    // diverging from zsh which returns 0 until the user runs
9794    // `zmodload zsh/system`. Skip here; `seed_partab_param` below adds
9795    // them on demand from the module's load path.
9796    let module_gated: &[&str] = &[
9797        "sysparams", // zsh/system
9798        "errnos",    // zsh/system
9799        "mapfile",   // zsh/mapfile
9800        "langinfo",  // zsh/langinfo
9801        ".zle.esc",  // zsh/hlgroup
9802        ".zle.sgr",  // zsh/hlgroup
9803        ".sh.match", // zsh/ksh93
9804    ];
9805    // c:Src/module.c:1065 `addparamdef` — `checkaddparam` (c:1026) finds
9806    // the PM_AUTOLOAD stub `init_bltinmods` planted, calls
9807    // `unsetparam_pm(pm, 0, 1)` which UNLINKS the node (c:1052), and only
9808    // then does `createparam` re-add it — so the real param takes a FRESH
9809    // chain slot, it does not inherit the stub's. `hashtable_nodes::
9810    // insert` is `addhashnode2` (Src/hashtable.c:168), which replaces an
9811    // existing key IN PLACE (c:187-203) and would pin the special to the
9812    // stub's slot; remove first to reproduce C. Visible in
9813    // `${(k)parameters}`: without the remove, `dis_reswords` and
9814    // `usergroups` came out one position off from `zsh -f`.
9815    for entry in PARTAB.iter() {
9816        if module_gated.contains(&entry.name) {
9817            continue;
9818        }
9819        tab.remove(entry.name); // c:1052 unsetparam_pm
9820        tab.insert(entry.name.to_string(), mk_pm(entry.name, entry.flags));
9821    }
9822    for entry in PARTAB_ARRAY.iter() {
9823        if module_gated.contains(&entry.name) {
9824            continue;
9825        }
9826        tab.remove(entry.name); // c:1052 unsetparam_pm
9827        tab.insert(entry.name.to_string(), mk_pm(entry.name, entry.flags));
9828    }
9829    // See `PARTAB_SEEDED` — every magic row now has its paramtab node,
9830    // so from here on a MISSING node is a real "no binding" answer.
9831    PARTAB_SEEDED.store(true, std::sync::atomic::Ordering::Release);
9832}
9833
9834/// !!! WARNING: RUST-ONLY HELPER !!!
9835///
9836/// True once [`init_partab_params`] has finished planting a paramtab
9837/// node for every `PARTAB` / `PARTAB_ARRAY` row.
9838///
9839/// No C counterpart BY CONSTRUCTION. In C the magic rows only ENTER
9840/// `paramtab` when `zsh/parameter` boots (`handlefeatures` →
9841/// `addparamdef` → `createspecialhash`, `Src/module.c:1065`), and before
9842/// that the name still resolves — to the `PM_AUTOLOAD` stub
9843/// `init_bltinmods` planted (`Src/module.c:1218-1223`). Either way C's
9844/// `paramtab->getnode(name)` answers, so C never has to distinguish
9845/// "not seeded yet" from "unset". zshrs seeds the rows from
9846/// `ShellExecutor::new` (vm_helper.rs:2566) and matches `PARTAB` BY NAME
9847/// out of a separate static table, so a magic read that runs BEFORE that
9848/// seeding would see an absent node. `magic_special_shadowed` reads
9849/// absence as "unset" (C: `getnode` → NULL → `fetchvalue` NULL,
9850/// `Src/params.c:2264-2266`), which is only a valid inference once the
9851/// seeding has run; this flag is that precondition.
9852static PARTAB_SEEDED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
9853
9854/// Insert a single PARTAB / PARTAB_ARRAY entry into paramtab. Called
9855/// from `zmodload <module>` once the module's boot completes, so that
9856/// `${+sysparams}` (etc.) flip from 0 → 1 only after explicit load.
9857/// No direct C counterpart — the C path runs through the module's
9858/// `setup_/boot_` chain which adds the SPECIALPMDEF entry via the
9859/// general hashtable machinery. Bug #69 in docs/BUGS.md.
9860pub fn seed_partab_param(name: &str) {
9861    use crate::ported::modules::parameter::{PARTAB, PARTAB_ARRAY};
9862    use crate::ported::zsh_h::{hashnode, param, PM_HIDE, PM_HIDEVAL, PM_READONLY, PM_SPECIAL};
9863    let mut tab = match crate::ported::params::paramtab().write() {
9864        Ok(t) => t,
9865        Err(_) => return,
9866    };
9867    // c:Src/module.c:1026-1052 `checkaddparam` — the node already present may
9868    // be the PM_AUTOLOAD STUB that `add_autoparam` planted (c:1218-1223,
9869    // ported at src/ported/module.rs), not a seeded special: forcing
9870    // MODULESTAB registers `p:builtins` and friends as autoload scalars. C
9871    // does NOT treat that as "already added" — `unsetparam_pm(pm, 0, 1)`
9872    // UNLINKS the stub (c:1052) and only then does `createparam` install the
9873    // real special (c:1065). The bulk path here already models that
9874    // (`tab.remove(entry.name); // c:1052 unsetparam_pm`); the single-name
9875    // sibling omitted it, so `builtins` stayed PM_SCALAR and `gethkparam`'s
9876    // PM_TYPE == PM_HASHED test fell through to None.
9877    //
9878    // A genuine user parameter shadowing the name carries no PM_AUTOLOAD, so
9879    // it still short-circuits exactly as before.
9880    if tab
9881        .get(name)
9882        .is_some_and(|pm| (pm.node.flags as u32 & crate::ported::zsh_h::PM_AUTOLOAD) == 0)
9883    {
9884        return; // a real special is already seeded
9885    }
9886    let flags = PARTAB
9887        .iter()
9888        .find(|e| e.name == name)
9889        .map(|e| e.flags)
9890        .or_else(|| {
9891            PARTAB_ARRAY
9892                .iter()
9893                .find(|e| e.name == name)
9894                .map(|e| e.flags)
9895        });
9896    let Some(flags) = flags else {
9897        return;
9898    };
9899    // c:1052 `unsetparam_pm(pm, 0, 1)` — unlink the autoload stub, but only
9900    // once we know a real special is going in to replace it. Doing it before
9901    // the PARTAB lookup above would destroy an existing node for any name
9902    // that is NOT a known special and then return, which is strictly worse
9903    // than the early-return it replaced.
9904    tab.remove(name);
9905    let pm = Box::new(param {
9906        node: hashnode {
9907            next: None,
9908            nam: name.to_string(),
9909            // Keep C's PM_READONLY. `init_partab_params` strips it from
9910            // rows the RUNTIME writes internally (funcstack pushes and
9911            // friends) and keeps it on the getfn/scanfn-computed rows —
9912            // see its `user_protected` list. Every name reaching THIS
9913            // seeder is a zmodload-gated row
9914            // (`module_gated_params_for`), and all of them are the
9915            // computed kind: `SPECIALPMDEF("sysparams", PM_READONLY,
9916            // NULL, getpmsysparams, scanpmsysparams)`
9917            // (Src/Modules/system.c:906), `errnos` (c:904), `langinfo`
9918            // (Src/Modules/langinfo.c:455); `mapfile` carries flags 0 in
9919            // C so it is unaffected either way. Stripping the bit made
9920            // `${(t)sysparams}` read `association-hide-hideval-special`
9921            // against zsh's `association-readonly-hide-hideval-special`,
9922            // and let `unset sysparams` succeed where zsh rejects with
9923            // `read-only variable: sysparams`.
9924            flags: flags | PM_SPECIAL as i32 | PM_HIDE as i32 | PM_HIDEVAL as i32,
9925        },
9926        u_data: 0,
9927        u_tied: None,
9928        u_arr: None,
9929        u_str: None,
9930        u_val: 0,
9931        u_dval: 0.0,
9932        u_hash: None,
9933        gsu_s: None,
9934        gsu_i: None,
9935        gsu_f: None,
9936        gsu_a: None,
9937        gsu_h: None,
9938        base: 0,
9939        width: 0,
9940        env: None,
9941        ename: None,
9942        old: None,
9943        level: 0,
9944    });
9945    tab.insert(name.to_string(), pm);
9946}
9947
9948/// Default autoloadable parameters: name → owning module. Port of the
9949/// `autofeatures` `p:` rows in Src/Modules/parameter.mdd, watch.mdd,
9950/// termcap.mdd, terminfo.mdd, Src/Zle/zleparameter.mdd and
9951/// Src/Builtins/sched.mdd, registered at startup through
9952/// `setautofeatures` → `add_autoparam` (Src/module.c:1198-1229): each
9953/// name becomes a scalar paramtab stub whose VALUE is the module name,
9954/// flagged PM_AUTOLOAD (module.c:1218-1219). Matches `zmodload -ap`
9955/// output of the reference zsh build.
9956pub const AUTOLOAD_PARAMS: &[(&str, &str)] = &[
9957    // Src/Modules/watch.mdd:5 autofeatures
9958    ("WATCH", "zsh/watch"),
9959    ("watch", "zsh/watch"),
9960    // Src/Modules/parameter.mdd:5 autofeatures
9961    ("aliases", "zsh/parameter"),
9962    ("builtins", "zsh/parameter"),
9963    ("commands", "zsh/parameter"),
9964    ("dirstack", "zsh/parameter"),
9965    ("dis_aliases", "zsh/parameter"),
9966    ("dis_builtins", "zsh/parameter"),
9967    ("dis_functions", "zsh/parameter"),
9968    ("dis_functions_source", "zsh/parameter"),
9969    ("dis_galiases", "zsh/parameter"),
9970    ("dis_patchars", "zsh/parameter"),
9971    ("dis_reswords", "zsh/parameter"),
9972    ("dis_saliases", "zsh/parameter"),
9973    ("funcfiletrace", "zsh/parameter"),
9974    ("funcsourcetrace", "zsh/parameter"),
9975    ("funcstack", "zsh/parameter"),
9976    ("functions", "zsh/parameter"),
9977    ("functions_source", "zsh/parameter"),
9978    ("functrace", "zsh/parameter"),
9979    ("galiases", "zsh/parameter"),
9980    ("history", "zsh/parameter"),
9981    ("historywords", "zsh/parameter"),
9982    ("jobdirs", "zsh/parameter"),
9983    ("jobstates", "zsh/parameter"),
9984    ("jobtexts", "zsh/parameter"),
9985    ("modules", "zsh/parameter"),
9986    ("nameddirs", "zsh/parameter"),
9987    ("options", "zsh/parameter"),
9988    ("parameters", "zsh/parameter"),
9989    ("patchars", "zsh/parameter"),
9990    ("reswords", "zsh/parameter"),
9991    ("saliases", "zsh/parameter"),
9992    ("userdirs", "zsh/parameter"),
9993    ("usergroups", "zsh/parameter"),
9994    // Src/Zle/zleparameter.mdd:5 autofeatures
9995    ("keymaps", "zsh/zleparameter"),
9996    ("widgets", "zsh/zleparameter"),
9997    // Src/Modules/termcap.mdd:5 / terminfo.mdd:5 autofeatures
9998    ("termcap", "zsh/termcap"),
9999    ("terminfo", "zsh/terminfo"),
10000    // Src/Builtins/sched.mdd:5 autofeatures
10001    ("zsh_scheduled_events", "zsh/sched"),
10002];
10003
10004/// Autoload stubs whose owning module is NOT loaded — the rows zsh's
10005/// `typeset` listings print as `undefined NAME` (printparamnode's
10006/// PM_AUTOLOAD pmtypes row, Src/params.c:6011 + the PM_AUTOLOAD
10007/// NAMEONLY arm at Src/params.c:6146-6155). Once a module loads, its
10008/// stubs drop out and the real params list instead.
10009pub fn autoload_param_stubs() -> Vec<(&'static str, &'static str)> {
10010    use crate::ported::zsh_h::{MOD_INIT_B, MOD_UNLOAD};
10011    let tab = crate::ported::module::MODULESTAB.lock().unwrap();
10012    AUTOLOAD_PARAMS
10013        .iter()
10014        .copied()
10015        .filter(|(_, m)| {
10016            // "Boot ran" is MOD_INIT_B && !MOD_UNLOAD (the criterion
10017            // printmodulenode uses, src/ported/module.rs:246 — C's
10018            // `m->u.handle` union check at Src/module.c:218-241).
10019            // modulestab::is_loaded checks MOD_LINKED which
10020            // register_builtin_modules pre-seeds for EVERY compiled-in
10021            // module, so it would report zsh/parameter "loaded" in a
10022            // fresh `zsh -f` where real zsh still shows the stubs.
10023            !tab.modules.get(*m).is_some_and(|md| {
10024                (md.node.flags & MOD_INIT_B) != 0 && (md.node.flags & MOD_UNLOAD) == 0
10025            })
10026        })
10027        .collect()
10028}
10029
10030/// Names provided by `zsh/system` / `zsh/mapfile` etc. that are
10031/// gated on explicit `zmodload`. Used by the bin_zmodload path to
10032/// re-seed paramtab after the module's boot completes.
10033pub fn module_gated_params_for(module: &str) -> &'static [&'static str] {
10034    match module {
10035        "zsh/system" => &["sysparams", "errnos"],
10036        "zsh/mapfile" => &["mapfile"],
10037        "zsh/langinfo" => &["langinfo"],
10038        "zsh/hlgroup" => &[".zle.esc", ".zle.sgr"],
10039        "zsh/ksh93" => &[".sh.match"],
10040        _ => &[],
10041    }
10042}
10043impl ShellExecutor {
10044    /// `enter_posix_mode` — see implementation.
10045    pub fn enter_posix_mode(&mut self) {
10046        self.posix_mode = true;
10047        self.plugin_cache = None;
10048        self.compsys_cache = std::cell::OnceCell::new();
10049        self.compinit_pending = None;
10050        self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
10051        // Direct call to the canonical `emulate()` port
10052        // (Src/options.c:533) — `-R` semantics = fully=true.
10053        // bin_emulate goes through dispatch_builtin which needs an
10054        // ExecutorContext that isn't set up yet at apply_cli_flags
10055        // time; the underlying emulate() doesn't need one.
10056        crate::ported::options::emulate("sh", true);
10057    }
10058    /// `enter_ksh_mode` — see implementation.
10059    pub fn enter_ksh_mode(&mut self) {
10060        self.plugin_cache = None;
10061        self.compsys_cache = std::cell::OnceCell::new();
10062        self.compinit_pending = None;
10063        self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
10064        crate::ported::options::emulate("ksh", true);
10065    }
10066    /// `enter_dash_mode` — strict-dash (Debian Almquist Shell) runtime.
10067    /// Same executor setup as [`enter_posix_mode`] (dash IS `sh` for every
10068    /// option), but calls `emulate("dash")` so the Rust-only DASH_STRICT
10069    /// flag is raised (and NOT cleared, as `emulate("sh")` would). See
10070    /// `src/extensions/dash_mode.rs`.
10071    pub fn enter_dash_mode(&mut self) {
10072        self.posix_mode = true;
10073        self.plugin_cache = None;
10074        self.compsys_cache = std::cell::OnceCell::new();
10075        self.compinit_pending = None;
10076        self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
10077        crate::ported::options::emulate("dash", true);
10078    }
10079}
10080
10081/// Thin (text, pattern) → bool wrapper over the canonical
10082/// `patcompile()` + `pattry()` pair from `Src/pattern.c`. Argument
10083/// order is flipped so callers read naturally. Lives in vm_helper.rs
10084/// (non-port file) as the public convenience entry for extensions
10085/// and the VM bridge; `src/ported/*` files inline the compile+match
10086/// idiom directly to preserve PORT.md Rule 1 faithfulness.
10087pub fn glob_match_static(s: &str, pattern: &str) -> bool {
10088    let Some(prog) = patcompile(
10089        &{
10090            let mut __pat_tok = (pattern).to_string();
10091            crate::ported::glob::tokenize(&mut __pat_tok);
10092            __pat_tok
10093        },
10094        PAT_HEAPDUP as i32,
10095        None,
10096    ) else {
10097        return false;
10098    };
10099    // c:Src/pattern.c:2570-2621 — `else if (prog->patnpar && !(patflags &
10100    // PAT_FILE))`: when the caller passes NO nump/begp/endp, `pattryrefs`
10101    // ITSELF publishes $match / $mbegin / $mend. That arm is ported in
10102    // pattern.rs, so this layer must not re-derive them from begp/endp.
10103    //
10104    // Re-deriving required compensating for the EXCLUSIVE end index
10105    // `pattryrefs` used to return, and the compensation was wrong twice over:
10106    //   * it would double-correct the c:2562-2564 `- 1` now applied there, and
10107    //     collide with the `endp[i] < 0` unset-group sentinel — an empty
10108    //     capture at offset 0 reports (0, -1) and was misread as an UNMATCHED
10109    //     alternation branch;
10110    //   * `saturating_sub(1)` clamped at 0, so under KSHARRAYS an empty capture
10111    //     at offset 0 gave `mend=0` where C computes `0 + 0 + 0 - 1` = -1.
10112    //     Measured: `setopt ksharrays; [[ abc = (#b)(x#)abc ]]` → zsh mend=-1,
10113    //     zshrs mend=0, while the substitution path (which already uses the
10114    //     c:2570-2621 arm) printed -1 correctly.
10115    let matched = pattry(&prog, s);
10116    // $MATCH / $MBEGIN / $MEND are set by the MATCHER (pattern.rs, c:2526),
10117    // which is where C decides it — gated on the GLOBAL patglobflags so a later
10118    // `(#M)` can turn GF_MATCHREF back off (c:1099-1100).
10119    //
10120    // This layer used to re-do it with `pattern.contains("(#m)")`, which can
10121    // only ever answer "on": it re-set $MATCH after the matcher had correctly
10122    // declined to, so `(#m)(#M)a*` reported a match string where zsh leaves it
10123    // unset. Wrong mechanism (a substring test cannot see which flag came last)
10124    // and wrong layer (the matcher already has the compiled flags).
10125    matched
10126}
10127
10128pub use crate::ported::lex::untokenize_ztokens;
10129
10130pub use crate::ported::utils::unmetafy_str;
10131
10132pub use crate::ported::utils::zsh_errno_msg;
10133
10134// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
10135// PM_NAMEREF bridge helpers (typeset -n / named references).
10136//
10137// Rust-only adapters around the canonical C nameref machinery in
10138// Src/params.c (resolve_nameref_rec c:6332, setscope c:6382,
10139// upscope c:6455) and Src/builtin.c (bin_typeset nameref arm
10140// c:3117-3150). zshrs's paramtab is a name-keyed HashMap handing
10141// out clones, so the chain walk operates by NAME against the live
10142// table instead of by Param pointer — same hop rule, same loop
10143// detection, same upscope old-chain walk. The ported fns in
10144// params.rs/builtin.rs call into these at the exact C deref points
10145// (getparamnode c:570-575, getvalue/fetchvalue c:2247-2270,
10146// assignsparam c:3252/3258, assignaparam c:3392-3398, bin_unset
10147// c:3939-3951, typeset_single c:2032-2050).
10148// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
10149
10150pub use crate::ported::params::*;
10151
10152/// True for an `fpath` entry that is a host zsh **distribution's own**
10153/// function tree — the copy of `Completion/**` + `Functions/**` that
10154/// `make install` flattens into the zsh package.
10155///
10156/// c:Src/init.c:1132-1143 — zsh's `setupvals` seeds `fpath` with
10157/// `FIXED_FPATH_DIR`, `SITEFPATH_DIR`, then
10158/// `<prefix>/share/zsh/<version>/functions`. zshrs keeps the first two and
10159/// drops the last, from the compiled-in default and from an inherited
10160/// `FPATH` alike. This is startup construction only: like zsh, a later
10161/// shell-level `fpath=( … )` stores exactly what was assigned.
10162///
10163/// The distribution tree goes because zshrs ships its own: `vendor/zsh` is
10164/// packed into the binary by `build.rs` and materialised into
10165/// `~/.zshrs/functions`, which takes the host tree's index on `fpath`. A host tree is not a
10166/// fallback for that — it is a second, differently-versioned copy of the
10167/// same `compinit`, `_git` and `_describe`, and letting it linger means a
10168/// Homebrew zsh upgrade can change how zshrs completes.
10169///
10170/// `share/zsh/site-functions` STAYS. Nothing in it comes from zsh: it is
10171/// where third-party formulae install their completions (`_brew`,
10172/// `_docker`, `_kubectl`, `_gh` …), the bundle has no copy of them, and
10173/// dropping the directory would silently disable completion for every
10174/// tool installed through a package manager.
10175///
10176/// The two shapes to reject differ in depth — Homebrew's Cellar layout has
10177/// no version component under `share/zsh` — so the test is a `share`
10178/// component followed by `zsh`, plus a final component of exactly
10179/// `functions`:
10180///     /usr/share/zsh/5.9/functions                          drop
10181///     /opt/homebrew/Cellar/zsh/5.9.2/share/zsh/functions    drop
10182///     /opt/homebrew/share/zsh/site-functions                keep
10183///     ~/.zinit/plugins/…/src                                keep
10184pub(crate) fn is_host_zsh_function_tree(p: &Path) -> bool {
10185    if p.file_name() != Some(OsStr::new("functions")) {
10186        return false;
10187    }
10188    let mut comps = p.components().map(|c| c.as_os_str());
10189    while let Some(c) = comps.next() {
10190        if c == "share" && comps.clone().next() == Some(OsStr::new("zsh")) {
10191            return true;
10192        }
10193    }
10194    false
10195}
10196
10197/// Default `fpath` when `FPATH` is absent from the environment.
10198///
10199/// c:Src/init.c:1143 — `SITEFPATH_DIR`, listed by zsh even when it does
10200/// not exist on disk. zshrs adds the same directory under whichever
10201/// prefixes are actually present, because that is where package managers
10202/// put third-party completions and the bundled tree has none of them.
10203///
10204/// The versioned `<prefix>/share/zsh/<version>/functions` that C also
10205/// seeds here is deliberately NOT included — see
10206/// [`is_host_zsh_function_tree`]. `~/.zshrs/functions` supplies it, and is
10207/// appended by both constructors.
10208///
10209/// The prefix list is the set of places a package manager puts
10210/// `share/zsh/site-functions` on the platforms zshrs targets. `/usr/local`
10211/// and `/usr` cover Linux distributions and a from-source install;
10212/// `/opt/homebrew` is Homebrew on Apple silicon, `/usr/local` doubles as
10213/// Homebrew on Intel macOS, and `/home/linuxbrew/.linuxbrew` is
10214/// Homebrew's Linux prefix — which is NOT `/opt/homebrew`, so a Linux box
10215/// with brew-installed completions found none of them before it was
10216/// listed. Only directories that exist are added, so naming a prefix
10217/// costs nothing on a machine that lacks it.
10218fn default_fpath() -> Vec<PathBuf> {
10219    let mut out: Vec<PathBuf> = vec![PathBuf::from("/usr/local/share/zsh/site-functions")];
10220    for pfx in [
10221        "/usr/local",
10222        "/opt/homebrew",
10223        "/home/linuxbrew/.linuxbrew",
10224        "/usr",
10225    ] {
10226        let site = PathBuf::from(format!("{pfx}/share/zsh/site-functions"));
10227        if site.is_dir() && !out.contains(&site) {
10228            out.push(site);
10229        }
10230    }
10231    out
10232}