Skip to main content

zsh/
exec.rs

1//! Shell executor state for zshrs.
2//!
3//! **Not a port of Src/exec.c.** C zsh runs compiled programs on the native
4//! **wordcode VM** in `Src/exec.c` (`execlist` / `execpline` / `execcmd`).
5//! zshrs uses fusevm bytecode instead; the bridge lives in `src/fusevm_bridge.rs`.
6//! This file holds:
7//! - `ShellExecutor` — the runtime state struct that the VM and
8//!   every ported builtin/utility threads through
9//! - VM-adjacent helpers that read/write that state
10//! - drift extension scaffolding still being moved out
11//!
12//! Path-wise this file lives at the crate root (`src/exec.rs`) rather
13//! than in `src/ported/` because nothing here corresponds 1:1 to a
14//! `Src/*.c` source file. `crate::ported::exec` is kept as a
15//! re-export alias so existing call-sites continue to compile.
16
17use crate::history::HistoryEngine;
18// MathState is private to math.rs (per math.c — no public state struct);
19// math API surface is matheval/mathevali/mnumber.
20use crate::options::ZSH_OPTIONS_SET;
21// TcpSessions struct deleted — see modules/tcp.rs ZTCP_SESSIONS thread_local.
22// `Profiler`/`ProfileEntry` deleted in the zprof.rs strict-rules
23// rewrite — zprof state now lives in module-level statics
24// (`CALLS`/`NCALLS`/`ARCS`/`NARCS`/`STACK`/`ZPROF_MODULE`) matching
25// the C file-statics at zprof.c:66-71.
26use compsys::cache::CompsysCache;
27use compsys::CompInitResult;
28use parking_lot::Mutex;
29use std::collections::HashSet;
30use crate::ported::utils::{errflag, ERRFLAG_ERROR};
31use std::sync::atomic::Ordering;
32use crate::ported::parse::ECBUF;
33use crate::ported::zsh_h::{wc_code, wc_data, WC_END, WC_LIST};
34use crate::ported::zsh_h::WC_SUBLIST;
35use crate::ported::zsh_h::WC_PIPE;
36use crate::ported::zsh_h::{WC_ARITH, WC_CASE, WC_COND, WC_CURSH, WC_FOR, WC_FUNCDEF, WC_IF, WC_REPEAT, WC_SELECT, WC_SIMPLE, WC_SUBSH, WC_TIMED, WC_TRY, WC_WHILE, };
37use crate::ported::zsh_h::{WC_FOR_LIST, WC_FOR_SKIP, WC_FOR_TYPE};
38use crate::ported::zsh_h::{WC_CASE_SKIP, WC_CASE_TYPE};
39use crate::ported::builtin::RETFLAG;
40use crate::ported::zsh_h::{WC_IF_SKIP, WC_IF_TYPE};
41use crate::ported::builtin::{BREAKS, CONTFLAG, LOOPS};
42use crate::ported::zsh_h::{WC_WHILE_SKIP, WC_WHILE_TYPE};
43use crate::ported::math::mathevali;
44use crate::ported::parse::ecgetstr_wordcode;
45use crate::ported::subst::singsub;
46use crate::ported::zsh_h::WC_REPEAT_SKIP;
47use crate::ported::parse::ecgetstr_wordcode as ecgetstr;
48use std::fs;
49use std::os::unix::io::FromRawFd;
50use std::io::Read;
51use std::ffi::CString;
52use std::os::unix::ffi::OsStrExt;
53use crate::ported::zsh_h::{options, MAX_OPS};
54use crate::ported::zsh_h::{PM_INTEGER, PM_EFLOAT, PM_FFLOAT, PM_ARRAY, PM_HASHED, PM_LOWER, PM_UPPER, PM_READONLY, PM_EXPORTED, PM_LEFT, PM_RIGHT_B, PM_RIGHT_Z};
55use std::ffi::CStr;
56use crate::ported::zle::zle_thingy::{listwidgets, getwidgettarget};
57use std::time::{SystemTime, UNIX_EPOCH};
58use walkdir::WalkDir;
59use std::os::unix::fs::FileTypeExt;
60use std::os::unix::fs::PermissionsExt;
61use std::sync::atomic::AtomicI32;
62use crate::ported::modules::parameter::*;
63use crate::ported::zsh_h::PM_UNDEFINED;
64
65// Backward-compat re-exports for free fns recently relocated to their
66// canonical-C-file Rust modules. Existing call-sites in this file (and
67// elsewhere) still reference these unqualified.
68#[allow(unused_imports)]
69#[allow(unused_imports)]
70pub(crate) use crate::ported::glob::{expand_glob_alternation, find_top_level_tilde};
71#[allow(unused_imports)]
72pub use crate::ported::params::convbase as format_int_in_base;
73pub use crate::ported::params::convbase_underscore;
74#[allow(unused_imports)]
75pub(crate) use crate::ported::math::{parse_assign, parse_compound, parse_pre_inc};
76#[allow(unused_imports)]
77pub(crate) use crate::ported::params::getarrvalue;
78#[allow(unused_imports)]
79#[allow(unused_imports)]
80// drift imports removed: apply_subst_modifier, slice_scalar, strip_match_op
81#[allow(unused_imports)]
82pub(crate) use crate::func_body_fmt::FuncBodyFmt;
83#[allow(unused_imports)]
84pub(crate) use crate::ported::utils::base64_decode;
85#[allow(unused_imports)]
86pub(crate) use crate::ported::utils::{ispwd, printprompt4, quotedzputs};
87#[allow(unused_imports)]
88pub(crate) use crate::ported::hist::bufferwords as bufferwords_z_tuple;
89
90pub(crate) use crate::intercepts::intercept_matches;
91/// AOP advice type — before, after, or around.
92pub use crate::intercepts::{AdviceKind, Intercept};
93
94/// Result from background compinit thread.
95pub use crate::compinit_bg::CompInitBgResult;
96use std::io::Write;
97use std::sync::LazyLock;
98
99
100/// State snapshot for plugin delta computation.
101pub(crate) use crate::plugin_cache::PluginSnapshot;
102
103
104/// Cached compiled regexes for hot paths
105pub(crate) static REGEX_CACHE: LazyLock<Mutex<std::collections::HashMap<String, regex::Regex>>> =
106    LazyLock::new(|| Mutex::new(std::collections::HashMap::with_capacity(64)));
107
108/// Port of `int trap_state;` from `Src/exec.c:134`. Tracks whether
109/// a trap handler is currently being processed and, paired with
110/// `TRAP_RETURN` below, whether a `return` inside the trap should
111/// promote to `TRAP_STATE_FORCE_RETURN` to unwind the trap caller.
112///
113/// Values: `TRAP_STATE_INACTIVE = 0`, `TRAP_STATE_PRIMED = 1`,
114/// `TRAP_STATE_FORCE_RETURN = 2` (see `Src/zsh.h`).
115pub static TRAP_STATE: std::sync::atomic::AtomicI32 =                       // c:134 (Src/exec.c)
116    std::sync::atomic::AtomicI32::new(0);
117
118/// Port of `int trap_return;` from `Src/exec.c:155`. Carries the
119/// pending exit status from inside a trap; sentinel `-2` means
120/// "running an EXIT/DEBUG-style trap at the current level"
121/// (signals.c:1166). Promoted to the user's `return N` value by
122/// `bin_return` when POSIX-trap semantics apply (builtin.c:5852).
123pub static TRAP_RETURN: std::sync::atomic::AtomicI32 =                      // c:155 (Src/exec.c)
124    std::sync::atomic::AtomicI32::new(0);
125
126/// Port of `int forklevel;` from `Src/exec.c:1052`. Records the
127/// `locallevel` at the most recent fork point (set at c:1221:
128/// `forklevel = locallevel;` inside `entersubsh()`). Used by:
129///   - `signals.c:808` SIGPIPE handler — `!forklevel` distinguishes
130///     the top-level shell from a forked subshell.
131///   - `exec.c:6146` — `if (locallevel > forklevel)` decides whether
132///     a function-defined trap should fire on this subshell exit.
133///   - `params.c:3724` — WARNCREATEGLOBAL nest-depth check.
134///
135/// Initialised to 0 (no fork has occurred yet). Set to `locallevel`
136/// at every `entersubsh()` entry per c:1221.
137pub static FORKLEVEL: std::sync::atomic::AtomicI32 =                        // c:1052 (Src/exec.c)
138    std::sync::atomic::AtomicI32::new(0);
139
140// ───────────────────────────────────────────────────────────────────────────
141// fusevm VM bridge (extension; not a port of Src/exec.c) lives in
142// src/fusevm_bridge.rs. The bridge re-exports the symbols that the
143// rest of the codebase imports as `crate::ported::exec::X`.
144// ───────────────────────────────────────────────────────────────────────────
145pub use crate::fusevm_bridge::*;
146pub(crate) use crate::fusevm_bridge::{ExecutorContext};
147
148/// `ZSH_VERSION` / `ZSH_PATCHLEVEL` / `ZSH_VERSION_DATE` consts
149/// generated by `build.rs` from `src/zsh/Config/version.mk`. Use
150/// `zsh_version::ZSH_VERSION` etc. at call sites so version bumps
151/// pick up automatically.
152pub mod zsh_version {
153    include!(concat!(env!("OUT_DIR"), "/zsh_version.rs"));
154}
155
156/// Convert a here-document into a here-string. Line-by-line port of
157/// `gethere()` from `Src/exec.c:4569-4652`. Reads the body from the
158/// input stream via `hgetc()` until the terminator line is matched,
159/// returning the collected body as a string. `strp` is in/out: on
160/// entry the raw terminator (possibly with token markers + leading
161/// tabs); on return the munged terminator (after `quotesubst` +
162/// `untokenize` and, for `REDIR_HEREDOCDASH`, leading-tab strip).
163///
164/// Returns `None` on out-of-memory (C `zalloc`/`realloc` failure).
165/// Rust's `String` auto-grows so the OOM branch is effectively
166/// unreachable, but the return type stays `Option<String>` to mirror
167/// the C signature which can return NULL.
168///
169/// Port of `gethere(char **strp, int typ)` from `Src/exec.c:4573`.
170pub fn gethere(strp: &mut String, typ: i32) -> Option<String> {                  // c:4573 (Src/exec.c)
171    let mut buf: String;                                                          // c:4575 char *buf
172    let mut bsiz: usize;                                                          // c:4576 int bsiz
173    let mut qt: i32 = 0;                                                          // c:4576 int qt = 0
174    let mut strip: i32 = 0;                                                       // c:4576 int strip = 0
175    // c:4577 — char *s, *t, *bptr, c. zshrs uses byte-offsets into
176    // `buf` for `t` and tracks `bptr` implicitly as `buf.len()` (the
177    // C `bptr++` increment is `buf.push(c)`; `bptr--` is `buf.pop()`).
178    // `s` (the loop iterator for the inull-scan) stays local to its
179    // for-loop. `c` mirrors the C `char c`.
180    let mut t: usize;                                                             // c:4577 char *t
181    let mut c: Option<char>;                                                      // c:4577 char c
182    let mut str: String = strp.clone();                                           // c:4578 char *str = *strp
183
184    // c:4580-4584 — for (s = str; *s; s++) if (inull(*s)) { qt = 1; break; }
185    for s in str.bytes() {
186        if crate::ported::ztype_h::inull(s) {                                     // c:4581
187            qt = 1;                                                               // c:4582
188            break;                                                                // c:4583
189        }
190    }
191    str = crate::ported::subst::quotesubst(&str);                                 // c:4585
192    str = crate::ported::lex::untokenize(&str);                                   // c:4586
193    if typ == crate::ported::zsh_h::REDIR_HEREDOCDASH {                           // c:4587
194        strip = 1;                                                                // c:4588
195        // c:4589-4590 — while (*str == '\t') str++;
196        while str.starts_with('\t') {
197            str.remove(0);
198        }
199    }
200    *strp = str.clone();                                                          // c:4592 *strp = str
201
202    // c:4593 — bptr = buf = zalloc(bsiz = 256);
203    bsiz = 256;
204    buf = String::with_capacity(bsiz);
205    let _ = bsiz; // bsiz is tracked by C for zfree; Rust drops automatically
206
207    // c:4594 — for (;;)
208    loop {
209        t = buf.len();                                                            // c:4595 t = bptr
210
211        // c:4597-4598 — while ((c = hgetc()) == '\t' && strip) ;
212        loop {
213            c = crate::ported::lex::hgetc();
214            if !(c == Some('\t') && strip != 0) {
215                break;
216            }
217        }
218
219        // c:4599 — for (;;) — inner body-read loop
220        loop {
221            // c:4600-4613 — buffer-growth realloc dance. Rust's
222            // String auto-grows; nothing to do.
223            // c:4614 — if (lexstop || c == '\n') break;
224            if crate::ported::lex::LEX_LEXSTOP.with(|f| f.get()) || c == Some('\n') || c.is_none() {
225                break;
226            }
227            // c:4616 — if (!qt && c == '\\')
228            if qt == 0 && c == Some('\\') {
229                buf.push('\\');                                                   // c:4617 *bptr++ = c
230                c = crate::ported::lex::hgetc();                                  // c:4618
231                if c == Some('\n') {                                              // c:4619
232                    buf.pop();                                                    // c:4620 bptr--
233                    c = crate::ported::lex::hgetc();                              // c:4621
234                    continue;                                                     // c:4622
235                }
236            }
237            if let Some(ch) = c {                                                 // c:4625 *bptr++ = c
238                buf.push(ch);
239            }
240            c = crate::ported::lex::hgetc();                                      // c:4626
241        }
242        // c:4628 — *bptr = '\0'; (implicit — Rust String tracks len)
243
244        // c:4629-4630 — if (!strcmp(t, str)) break;
245        if &buf[t..] == str.as_str() {
246            break;
247        }
248        // c:4631-4634 — if (lexstop) { t = bptr; break; }
249        if crate::ported::lex::LEX_LEXSTOP.with(|f| f.get()) {
250            t = buf.len();
251            break;
252        }
253        // c:4635 — *bptr++ = '\n';
254        buf.push('\n');
255    }
256    // c:4637 — *t = '\0';
257    buf.truncate(t);
258
259    // c:4638-4640 — s = buf; buf = dupstring(buf); zfree(s, bsiz);
260    // The C dance frees the realloc'd block and re-allocates via the
261    // string-heap allocator. Rust drops the old String when reassigned.
262    buf = crate::ported::mem::dupstring(&buf);
263
264    if qt == 0 {                                                                  // c:4641
265        // c:4642 — int ef = errflag;
266        let ef = errflag.load(Ordering::Relaxed);
267        // c:4644 — parsestr(&buf);
268        if let Ok(parsed) = crate::ported::lex::parsestr(&buf) {
269            buf = parsed;
270        }
271        // c:4646-4649 — if (!(errflag & ERRFLAG_ERROR)) errflag = ef | (errflag & ERRFLAG_INT);
272        if (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) == 0 {
273            let cur = errflag.load(Ordering::Relaxed);
274            errflag.store(ef | (cur & crate::ported::zsh_h::ERRFLAG_INT), Ordering::Relaxed);
275        }
276    }
277    Some(buf)                                                                     // c:4651 return buf
278}
279
280/// Free-function wrapper for `getoutput()` from `Src/exec.c:4712`.
281/// Runs a command-substitution body in the active executor and
282/// returns its captured stdout. The C signature is `LinkList
283/// getoutput(char *cmd, int qt)` but every caller in subst.rs
284/// joins the list back into a string, so the Rust port collapses
285/// the intermediate.
286///
287/// Uses `with_executor` (panics on missing VM context), not
288/// `try_with_executor + unwrap_or_default()`. C `getoutput` calls
289/// `execpline` directly — there's no "no shell" code path. The
290/// silent-no-op pattern (return empty string when no executor) would
291/// mask catastrophic state corruption as "command produced no output",
292/// which is the failure mode the `subst.rs:496` warning block flags.
293pub fn getoutput(cmd: &str) -> String {                                      // c:4712 (Src/exec.c)
294    with_executor(|exec| exec.run_command_substitution(cmd))
295}
296
297/// Match an intercept pattern against a command name or full command string.
298/// Supports: exact match, glob ("git *", "_*", "*"), or "all".
299
300/// Get or compile a regex, caching the result
301pub(crate) fn cached_regex(pattern: &str) -> Option<regex::Regex> {
302    let mut cache = REGEX_CACHE.lock();
303    if let Some(re) = cache.get(pattern) {
304        return Some(re.clone());
305    }
306    match regex::Regex::new(pattern) {
307        Ok(re) => {
308            cache.insert(pattern.to_string(), re.clone());
309            Some(re)
310        }
311        Err(_) => None,
312    }
313}
314
315/// O(1) builtin-name lookup set derived from the canonical
316/// `BUILTINS` table (`src/ported/builtin.rs:122`, the 1:1 port of
317/// `static struct builtin builtins[]` at `Src/builtin.c:40-137`).
318/// Earlier incarnation hardcoded a separate 130-entry list which
319/// drifted whenever new builtins landed in the canonical table — and
320/// shadowed the `fusevm::shell_builtins::BUILTIN_SET` u16 opcode
321/// constant. Renaming to `BUILTIN_NAMES` removes the shadow; the
322/// initialiser walks `BUILTINS` so the set stays in sync.
323///
324/// The hardcoded entries inside `LazyLock::new` below are kept as
325/// the union of: (1) names from `BUILTINS` (walked at first access),
326/// (2) zshrs daemon-side builtins from `ZSHRS_BUILTIN_NAMES`. Both
327/// arms run once at static init.
328pub(crate) static BUILTIN_NAMES: LazyLock<HashSet<String>> = LazyLock::new(|| {
329    let mut s: HashSet<String> = HashSet::new();
330    // Walk the canonical `BUILTINS` table — the 1:1 port of
331    // `static struct builtin builtins[]` at `Src/builtin.c:40-137`
332    // (ported at `src/ported/builtin.rs:122`). Every name in there is
333    // a real zsh builtin; the set stays in sync as new ports land.
334    for b in crate::ported::builtin::BUILTINS.iter() {
335        s.insert(b.node.nam.clone());
336    }
337    // Daemon-side (zshrs-specific extensions).
338    for &n in crate::daemon::builtins::ZSHRS_BUILTIN_NAMES.iter() {
339        s.insert(n.to_string());
340    }
341    s
342});
343
344/// Slice an array per zsh `${arr:offset[:length]}` semantics: the
345/// offset is 0-based "skip N elements" (so `${arr:1:2}` returns
346/// elements at indices 1,2). Negative offset counts from the end.
347/// `length < 0` means "to the end".
348pub(crate) fn slice_array_zero_based(arr: &[String], offset: i64, length: i64) -> Vec<String> {
349    let n = arr.len() as i64;
350    if n == 0 {
351        return Vec::new();
352    }
353    let start = if offset < 0 {
354        (n + offset).max(0) as usize
355    } else {
356        (offset as usize).min(arr.len())
357    };
358    let take = if length < 0 {
359        arr.len().saturating_sub(start)
360    } else {
361        (length as usize).min(arr.len().saturating_sub(start))
362    };
363    arr.iter().skip(start).take(take).cloned().collect()
364}
365
366/// Same shape but for positional params (`@`/`*`). zsh treats
367/// position 0 as `$0` (the script/shell name). For `${@:0}` it
368/// includes `$0`; for `${@:1}` it skips `$0` and starts at `$1`.
369/// Internally `positional_params[0]` is `$1`, so we prepend `$0`
370/// then slice 0-based.
371pub(crate) fn slice_positionals(exec: &ShellExecutor, offset: i64, length: i64) -> Vec<String> {
372    let pp = exec.pparams();
373    let mut all: Vec<String> = Vec::with_capacity(pp.len() + 1);
374    all.push(
375        exec.scalar("0")
376            .unwrap_or_else(|| std::env::args().next().unwrap_or_default()),
377    );
378    for p in pp {
379        all.push(p);
380    }
381    slice_array_zero_based(&all, offset, length)
382}
383
384use crate::exec_jobs::{JobState, JobTable};
385use crate::parse::{Redirect, RedirectOp, ShellCommand, ShellWord, VarModifier, ZshParamFlag};
386use crate::zwc::ZwcFile;
387use indexmap::IndexMap;
388use std::collections::HashMap;
389use std::env;
390use std::fs::{File, OpenOptions};
391use std::io;
392use std::path::{Path, PathBuf};
393use std::process::{Child, Command, Stdio};
394
395
396// Drift structs moved to their canonical-C-file modules
397// (src/ported/zle/computil.rs, modules/{zutil,zpty,zprof,socket}.rs,
398// builtins/sched.rs). Re-exported here so existing call-sites that
399// reference `crate::ported::exec::<Name>` keep compiling.
400pub use crate::bash_complete::{CompSpec, CompMatch, CompGroup, CompState};
401pub use crate::ported::modules::zutil::zstyle_entry;
402// `ProfileEntry` re-export deleted — was unused outside
403// `ShellExecutor::profile_data` (which itself is now removed).
404// `ScheduledCommand` (Rust-only) deleted; use `crate::builtins::sched::schedcmd`
405// (port of `struct schedcmd` from Src/Builtins/sched.c:43) for live state.
406pub use crate::ported::builtin::AutoloadFlags;
407
408
409/// Cross-VM loop-control signal. When `break`/`continue` is hit inside a body
410/// that runs on a sub-VM (e.g. select's body), the inline patches mechanism
411/// can't reach the outer loop — set this flag and the outer-loop builtin
412/// drains it after each iteration.
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414/// Loop control signal from a command body.
415/// Mirrors the `LF_*` set Src/loop.c uses to thread
416/// `break`/`continue`/`return` flags up through the executor.
417pub enum LoopSignal {
418    Break,
419    Continue,
420}
421
422/// Snapshot of subshell-isolated state. Captured at `(` entry, restored at
423/// `)` exit. zsh subshell semantics: assignments inside `(…)` don't leak to
424/// the outer scope — and that includes `export`. zsh forks a child for the
425/// subshell so the child's env::set_var dies with the child; without a fork
426/// (zshrs runs subshells in-process for perf), we snapshot+restore the OS
427/// env table around the subshell. Otherwise `(export y=v)` would leak `y`
428/// to the parent shell, breaking every script that uses a subshell to
429/// scope an env override.
430/// Snapshot of mutable executor state across a subshell
431/// boundary.
432/// Port of the `entersubsh()` save/restore Src/exec.c does at
433/// line 1084 — captures everything that must be replaced when a
434/// `(...)` group fires.
435pub struct SubshellSnapshot {
436    /// Snapshot of `paramtab` (the C-canonical parameter store) at
437    /// subshell entry. Step 1 of the unification mirrors writes to
438    /// paramtab, so subshell-scoped assignments now show up there
439    /// too — without this snapshot, restoring only `variables` /
440    /// `arrays` / `assoc_arrays` leaks the subshell's writes to the
441    /// parent via paramtab (e.g. `x=outer; (x=inner); echo $x` returned
442    /// `inner` because paramsubst reads through paramtab).
443    pub paramtab: HashMap<String, crate::ported::zsh_h::Param>,
444    pub paramtab_hashed_storage: HashMap<String, indexmap::IndexMap<String, String>>,
445    pub positional_params: Vec<String>,
446    pub env_vars: HashMap<String, String>,
447    /// Process working directory at subshell entry. `cd` inside the
448    /// subshell shouldn't leak to the parent; we restore on End.
449    pub cwd: Option<std::path::PathBuf>,
450    /// File-creation mask at subshell entry. zsh forks for `(...)` so
451    /// `umask` set inside dies with the child; we run subshells in
452    /// process so we must restore the mask on End. Otherwise
453    /// `umask 022; (umask 077); umask` shows 077 in the parent.
454    pub umask: u32,
455    /// Parent's traps at subshell entry. zsh's `(trap "echo X" EXIT;
456    /// true)` runs the trap when the subshell exits — BEFORE the parent
457    /// continues. Without this snapshot, the trap inherited from parent
458    /// would fire, OR a trap set inside the subshell would leak to the
459    /// parent's process exit. Restored on subshell_end after the
460    /// subshell's own EXIT trap (if any) has fired.
461    pub traps: HashMap<String, String>,
462}
463
464/// Variable attribute record + kind enum — moved to params.rs.
465
466
467// Pattern helpers moved to src/ported/pattern.rs.
468#[allow(unused_imports)]
469pub(crate) use crate::ported::pattern::{
470    extract_numeric_ranges, numeric_range_contains, numeric_ranges_to_star,
471};
472
473// `impl VarAttr` moved to src/ported/params.rs.
474
475/// Top-level shell executor state.
476/// Port of the file-static globals + `Estate` chain Src/exec.c
477/// uses — `execlist()` (line 1349) drives every list, with
478/// `execpline()` (line 1668), `execpline2()` (line 1991),
479/// `execsimple()` (line 1290), and the per-`WC_*` `execfuncs[]`
480/// table (line 268) feeding off it. The Rust port collapses
481/// everything into one `ShellExecutor` so we don't need
482/// thread-local globals.
483pub struct ShellExecutor {
484    /// Mirrors C zsh's file-static `scriptname` (Src/init.c). Used by
485    /// PS4's `%N` and the `scriptname:line: …` prefix on error
486    /// messages. Inside a function, MUTATES to the function name
487    /// (Src/exec.c:5903 `scriptname = dupstring(name)`). Init sets
488    /// this in `-c` mode to the binary basename per init.c:479; when
489    /// sourcing a file via `source`/`bin_dot`, it becomes the
490    /// resolved file path; otherwise it falls back through `$0` →
491    /// `$ZSH_ARGZERO`.
492    pub scriptname: Option<String>,
493    /// Mirrors C zsh's `scriptfilename` global (Src/init.c). Tracks
494    /// the FILE BEING READ (vs scriptname which tracks the active
495    /// function name during a call). Used by PS4's `%x` and certain
496    /// error-message prefixes that want the file location, NOT the
497    /// function name.
498    ///
499    /// At -c-mode init, scriptname == scriptfilename == "zsh"
500    /// (Src/init.c:479). When entering a function, ONLY scriptname
501    /// updates (exec.c:5903); scriptfilename stays at the outer
502    /// file path, so `%x` inside a function still shows the file
503    /// the function was called from.
504    pub scriptfilename: Option<String>,
505    // `expanding_aliases` deleted — was a Rust-only HashSet recursion
506    // guard duplicating C's `alias.inuse` field (`Src/zsh.h:1256`).
507    // Callers now bump/clear `inuse` on the canonical alias node in
508    // `aliastab` (`hashtable.rs:1804`), matching C's lexer behavior.
509    /// Set by `break`/`continue` keywords when no enclosing loop in the
510    /// current chunk's patch lists. Outer-loop builtins (BUILTIN_RUN_SELECT)
511    /// observe + clear this after each body run.
512    pub loop_signal: Option<LoopSignal>,
513    /// Stack of subshell-state snapshots. Each `(…)` subshell pushes a copy
514    /// of variables/arrays/assoc_arrays at entry and pops/restores at exit.
515    /// Without this, `(x=inner; …); echo $x` shows `inner` instead of the
516    /// outer-scope value.
517    pub subshell_snapshots: Vec<SubshellSnapshot>,
518    /// Stack of inline-assignment scopes — `X=foo Y=bar cmd` pushes
519    /// a frame at the start, the assigns run inside it, and `cmd`
520    /// returns into END_INLINE_ENV which restores both shell-vars
521    /// and process-env to the pre-frame state. Each frame holds
522    /// `(name, prev_var, prev_env)` per assigned name. zsh's
523    /// equivalent is the parser-level "addvar" list executed under
524    /// `addvars()` (Src/exec.c) right before the command exec.
525    pub inline_env_stack: Vec<Vec<(String, Option<String>, Option<String>)>>,
526    /// Set by `expand_glob`'s no-match arm when `nomatch` is on (zsh
527    /// default) — instructs the simple-command dispatcher to skip
528    /// executing the current command, set last_status=1, and continue
529    /// to the next command in the script. zsh's bin_simple uses the
530    /// errflag global for the same role: error printed, command
531    /// suppressed, script continues. Without this we were calling
532    /// `process::exit(1)` deep inside expand_glob, killing the whole
533    /// shell on any unmatched glob even with multi-statement input.
534    /// `Cell` because the no-match site only has a `&self` borrow.
535    pub current_command_glob_failed: std::cell::Cell<bool>,
536    pub jobs: JobTable,
537    pub fpath: Vec<PathBuf>,
538    pub zwc_cache: HashMap<PathBuf, ZwcFile>,
539    pub history: Option<HistoryEngine>,
540    /// Session-relative history line counter. Starts at 0; incremented
541    /// when an interactive command is recorded. Used by `%h`/`%!` in
542    /// prompt expansion (zsh's "current history line number"), distinct
543    /// from the persistent disk history total.
544    pub session_histnum: i64,
545    pub(crate) process_sub_counter: u32,
546    pub traps: HashMap<String, String>,
547    // `options` field deleted — dup of canonical `OPTS_LIVE` in
548    // `src/ported/options.rs:1112`. Callers route through
549    // `opt_state_get`/`opt_state_set`/`opt_state_unset`/`opt_state_snapshot`.
550    pub completions: HashMap<String, CompSpec>, // command -> completion spec
551    // `dir_stack` field deleted — canonical `DIRSTACK` lives in
552    // `modules/parameter.rs:398` (mirror of C `dirstack` global at
553    // `Src/builtin.c:1456`). Callers go through that Mutex directly.
554    // zsh completion system state
555    pub comp_matches: Vec<CompMatch>, // Current completion matches
556    pub comp_groups: Vec<CompGroup>,  // Completion groups
557    pub comp_state: CompState,        // compstate associative array
558    pub zstyles: Vec<zstyle_entry>,         // zstyle configurations
559    pub comp_words: Vec<String>,      // words on command line
560    pub comp_current: i32,            // current word index (1-based)
561    pub comp_prefix: String,          // PREFIX parameter
562    pub comp_suffix: String,          // SUFFIX parameter
563    pub comp_iprefix: String,         // IPREFIX parameter
564    pub comp_isuffix: String,         // ISUFFIX parameter
565    // `readonly_vars` deleted — was a never-populated HashSet
566    // duplicating the canonical `PM_READONLY` flag check on Param
567    // (`zsh_h::PM_READONLY` bit on `Param.node.flags`). Callers go
568    // through `is_readonly_param(name)`.
569    // `last_subst` deleted — 0 callers. Canonical `hsubl`/`hsubr`
570    // globals live in `Src/hist.c` and are ported on demand when
571    // `:&` history-modifier replay arrives in zshrs.
572    // `sub_flags` deleted — zero real callers; canonical lives in
573    // `SUB_FLAGS` thread_local at `src/ported/subst.rs:498` (`sub_flags`
574    // global in `Src/subst.c:2169`), accessed via `sub_flags_get` /
575    // `sub_flags_set`.
576    /// Current function scope depth for `local` tracking.
577    pub local_scope_depth: usize,
578    /// Last arg of the currently-running command, deferred into `$_`
579    /// when the next command dispatches. zsh: `$_` reflects the LAST
580    /// command's last arg, so `echo hi; echo $_` prints `hi` (not the
581    /// `_` arg of `echo $_` itself). Promoted in `pop_args` and
582    /// `host.exec` before the command's args are read.
583    pub pending_underscore: Option<String>,
584    /// True while expanding inside a double-quoted context. Set by
585    /// `BUILTIN_EXPAND_TEXT` mode 1 around `expand_string` calls.
586    /// Used by parameter-flag application to suppress array-only flags
587    /// (`(o)`/`(O)`/`(n)`/`(i)`/`(M)`/`(u)`) — zsh's behaviour: those
588    /// flags only fire in array context.
589    pub in_dq_context: u32,
590    // `in_paramsubst_nest` deleted — canonical lives in
591    // `IN_PARAMSUBST_NEST` thread_local at `subst.rs:464` (mirrors
592    // `paramsub_nest` global in `Src/subst.c`). Callers read it
593    // directly via `crate::ported::subst::IN_PARAMSUBST_NEST.with(...)`.
594    /// True (>0) while expanding the RHS of a scalar assignment.
595    /// Direct port of zsh's `PREFORK_SINGLE` bit set by
596    /// Src/exec.c::addvars line 2546 (`prefork(vl, isstr ?
597    /// (PREFORK_SINGLE|PREFORK_ASSIGN) : PREFORK_ASSIGN, ...)`).
598    /// Subst_port's paramsubst reads this via `ssub` and suppresses
599    /// `(f)` / `(s:STR:)` / `(0)` / `(z)` split flags per
600    /// Src/subst.c:1759 + 3902, so `y="${(f)x}"` preserves x's
601    /// original separator (newlines) instead of re-joining with
602    /// IFS-first-char (space).
603    pub in_scalar_assign: u32,
604    // `cmd_stack` deleted — duplicated the canonical `prompt::CMDSTACK`
605    // thread_local (`Src/prompt.c:56 unsigned char *cmdstack`).
606    // `BUILTIN_CMD_PUSH`/`BUILTIN_CMD_POP` now call `cmdpush`/`cmdpop`
607    // on the canonical TLS only; prompt expansion reads it directly.
608    /// IDs of history entries explicitly added during this session
609    /// via `print -s`. `fc -l` uses this to scope listings to just
610    /// the script-added entries (matches zsh's `-c` semantics where
611    /// session history is the only thing visible to the script).
612    pub session_history_ids: Vec<i64>,
613    // `autoload_pending` deleted — dup of canonical shfunctab entries
614    // with PM_UNDEFINED flag bit (port of C autoload_func stub at
615    // `Src/exec.c:5215`). The -U/-z/-k/-t/-d AutoloadFlags details
616    // were never consumed beyond serialization, dropped along with
617    // the field.
618    // `hook_functions` deleted — Rust-only side-store duplicating zsh's
619    // canonical `<hook>_functions` paramtab arrays (the add-zsh-hook
620    // idiom). `add_hook` / `delete_hook` now mutate those arrays
621    // directly via `setaparam`.
622    // `named_dirs` deleted — canonical `nameddirtab` lives in
623    // `src/ported/hashnameddir.rs:36` (port of C `nameddirtab` in
624    // `Src/hashnameddir.c`). Callers route through that Mutex.
625    // bin_sysopen - file descriptor management
626    pub open_fds: HashMap<i32, std::fs::File>,
627    pub next_fd: i32,
628    // sched (Src/Builtins/sched.c) — schedcmds list lives in module
629    // statics in the canonical port; nothing to carry on ShellExecutor.
630    // zprof — profiling data lives in `crate::zprof` module statics
631    // (CALLS/NCALLS/ARCS/NARCS/STACK), matching the C file-statics
632    // at zprof.c:66-71. Only the user's "is profiling on?" toggle
633    // stays here, set by the `profile` extension builtin.
634    pub profiling_enabled: bool,
635    // compsys - completion system cache
636    pub compsys_cache: Option<CompsysCache>,
637    // Background compinit — receiver for async fpath scan result
638    pub compinit_pending: Option<(
639        std::sync::mpsc::Receiver<CompInitBgResult>,
640        std::time::Instant,
641    )>,
642    // Plugin source cache — stores side effects of source/. in SQLite
643    pub plugin_cache: Option<crate::plugin_cache::PluginCache>,
644    // cdreplay - deferred compdef calls for zinit turbo mode
645    pub deferred_compdefs: Vec<Vec<String>>,
646    // `command_hash` deleted — never-populated dup of canonical
647    // `cmdnamtab` (`hashtable.rs:1780`, port of `Src/exec.c:5260`
648    // findcmd's hash table). Callers route through cmdnamtab.
649    // Control flow signals
650    pub returning: Option<i32>, // Set by return builtin, cleared after function returns
651    pub breaking: i32,          // break level (0 = not breaking, N = break N levels)
652    pub continuing: i32,        // continue level
653    // New module state — TcpSessions struct dissolved into the
654    // thread_local ZTCP_SESSIONS in modules/tcp.rs (matches C's
655    // file-static `ztcp_sessions` linked list).
656    // `zftp` field deleted — 0 callers. Module-level state lives in
657    // `ZFTP_STATE_INNER` (Src/Modules/zftp.c file-statics analogue).
658    // `profiler: Profiler` deleted — see comment above.
659    // `style_table` field deleted — 0 callers. Canonical `zstyletab`
660    // lives in `src/ported/modules/zutil.rs::zstyletab` (LazyLock
661    // Mutex matching C's `static HashTable zstyletab` at zutil.c:209).
662    // termcap state dissolved per strict-rules audit — no Rust-only
663    // Termcap struct; capability_lookup is stateless on $TERM.
664    // Watch state — dissolved per PORT_PLAN Phase 2. C
665    // (Src/Modules/watch.c:150-156) keeps `wtab`/`lastwatch`/
666    // `lastutmpcheck`/`watch` as file-statics; zshrs mirrors them
667    // as `thread_local!`s in src/ported/modules/watch.rs.
668    // curses (Src/Modules/curses.c) — windows/colour-pairs/init flag
669    // now live in module-static OnceLock<Mutex<…>>'s in
670    // src/ported/modules/curses.rs (matching C's file-statics
671    // `zcurses_windows`, `colorpairs`, `next_pair`).
672    // pty_cmds moved to PTYCMDS global static in src/ported/modules/
673    // zpty.rs (port of C `static struct ptycmd *ptycmds` file-static).
674    // sched: scheduled commands now live in `SCHEDCMDS` static in
675    // `src/ported/builtins/sched.rs` (port of `static struct schedcmd
676    // *schedcmds` from Src/Builtins/sched.c:52). No state on
677    // ShellExecutor.
678    /// zsh compatibility mode - use .zcompdump, fpath scanning, etc.
679    /// Also serves as the `--zsh` parity-test flag: caches off, daemon
680    /// off, plugin_cache replay off so every `source` re-runs the file
681    /// fresh per Src/builtin.c:6080-6123 bin_dot semantics.
682    pub zsh_compat: bool,
683    /// bash compatibility mode (`--bash`). Same parity-mode semantics
684    /// as `zsh_compat` (caches/daemon/replay off) plus bash-specific
685    /// behavior tweaks where bash 5.x diverges from zsh — e.g.
686    /// `BASH_VERSION` / `BASH_REMATCH` exposed, `[[ =~ ]]` populates
687    /// match indices the bash way, mapfile/readarray as builtins.
688    pub bash_compat: bool,
689    /// POSIX sh strict mode — no SQLite, no worker pool, no zsh extensions
690    pub posix_mode: bool,
691    /// Worker thread pool for background tasks (compinit, process subs, etc.)
692    pub worker_pool: std::sync::Arc<crate::worker::WorkerPool>,
693    /// AOP intercept table: command/function name → advice chain.
694    /// Glob patterns supported (e.g. "git *", "*").
695    pub intercepts: Vec<Intercept>,
696    /// Async job handles: id → receiver for (status, stdout)
697    pub async_jobs: HashMap<u32, crossbeam_channel::Receiver<(i32, String)>>,
698    /// Next async job ID
699    pub next_async_id: u32,
700    /// Defer stack: commands to run on scope exit (LIFO).
701    pub defer_stack: Vec<Vec<String>>,
702    /// Per-scope saved-fd stacks for `Op::WithRedirectsBegin/End`. Each entry
703    /// is a Vec of (fd, saved_dup_fd) pairs taken from `dup(fd)` before the
704    /// redirect was applied; `with_redirects_end` `dup2`s them back and closes.
705    pub redirect_scope_stack: Vec<Vec<(i32, i32)>>,
706    /// Set by `host_apply_redirect` when a redirect target couldn't be
707    /// opened (permission denied, no such directory, etc). The next
708    /// builtin/command checks this at entry and short-circuits with
709    /// status 1 instead of running. Mirrors zsh's "command skip" on
710    /// redirect failure.
711    pub redirect_failed: bool,
712    /// Stdin content set by `Op::HereDoc(idx)` / `Op::HereString` for the next
713    /// command/builtin in this VM. Consumed (and cleared) by the next command.
714    pub pending_stdin: Option<String>,
715    /// Compiled function bodies — name → fusevm::Chunk. Populated by
716    /// `BUILTIN_REGISTER_FUNCTION` (from `FunctionDef` lowering) and lazily by
717    /// `ZshrsHost::call_function` when only an AST exists in `self.functions`
718    /// (autoloaded, sourced, etc.). `Op::CallFunction` dispatches through here.
719    pub functions_compiled: HashMap<String, fusevm::Chunk>,
720    /// Canonical source text for functions. Populated by autoload paths (the
721    /// raw file/cache body), runtime FuncDef compile (the parsed source span),
722    /// and `unfunction` removal. Used by introspection (`whence`, `which`,
723    /// `typeset -f`) instead of reconstructing from a ShellCommand AST. When a
724    /// function is in `functions_compiled` but not here, introspection falls
725    /// back to `text::getpermtext(self.functions[name])`.
726    pub function_source: HashMap<String, String>,
727    /// `first_body_line - 1` per compiled function — matches inner
728    /// `ZshCompiler::lineno_offset` / zsh `funcstack->flineno` combined with
729    /// relative `$LINENO` for Src/prompt.c:909 `%I`.
730    pub function_line_base: HashMap<String, i64>,
731    /// `scriptfilename` when `BUILTIN_REGISTER_COMPILED_FN` ran — `%x` inside
732    /// a function (prompt.c:931-934) reads `funcstack->filename`.
733    pub function_def_file: HashMap<String, Option<String>>,
734    /// Innermost-last stack of active compiled-call frames for prompt `%I` / `%x`.
735    pub prompt_funcstack: Vec<(String, i64, Option<String>)>,
736    /// Scalar→(array, sep) tie table set up by `typeset -T VAR var [SEP]`.
737    /// Used by BUILTIN_SET_VAR to split the assigned scalar on `sep` and
738    /// mirror it into `array`.
739    pub tied_scalar_to_array: HashMap<String, (String, String)>,
740    /// Array→(scalar, sep) reverse-tie table. Used by BUILTIN_SET_ARRAY to
741    /// join the array elements with `sep` and mirror to the scalar side.
742    pub tied_array_to_scalar: HashMap<String, (String, String)>,
743    /// ZLE buffer stack — port of `bufstack` (zsh/Src/builtin.c:4567,
744    /// `LinkList bufstack`). `print -z` (builtin.c:5039-5045) pushes
745    /// joined args onto it; `read -z` and `getln` (builtin.c:6769-6770)
746    /// pop the top entry as the input source. zsh treats this as a stack
747    /// shared between the buffer/zle subsystem and the read path.
748    pub buffer_stack: Vec<String>,
749}
750
751impl ShellExecutor {
752    /// Set a scalar parameter via the canonical `paramtab`
753    /// (`Src/params.c:3350 setsparam`). The single store.
754    pub fn set_scalar(&mut self, name: String, value: String) {
755        crate::ported::params::setsparam(&name, &value);                     // c:params.c:3350
756    }
757
758    /// Read positional parameters from canonical `PPARAMS`
759    /// `Mutex<Vec<String>>` (Src/init.c:pparams). The single store.
760    pub fn pparams(&self) -> Vec<String> {
761        crate::ported::builtin::PPARAMS
762            .lock()
763            .map(|p| p.clone())
764            .unwrap_or_default()
765    }
766
767    /// Write positional parameters to canonical `PPARAMS`.
768    pub fn set_pparams(&mut self, params: Vec<String>) {
769        if let Ok(mut p) = crate::ported::builtin::PPARAMS.lock() {
770            *p = params;
771        }
772    }
773
774    /// Read PM_* type flags from the paramtab Param entry. Used by
775    /// SET_VAR / `+=` arms (case-fold, integer-add, readonly guard)
776    /// instead of the legacy `exec.var_attrs` HashMap. Returns 0 when
777    /// the name isn't in paramtab. Mirrors the C source's direct
778    /// `pm->node.flags & PM_INTEGER` checks.
779    pub fn param_flags(&self, name: &str) -> i32 {
780        crate::ported::params::paramtab().read()
781            .ok()
782            .and_then(|t| t.get(name).map(|p| p.node.flags))
783            .unwrap_or(0)
784    }
785
786    /// `typeset -i name` — Param has PM_INTEGER. Reads via
787    /// `param_flags`.
788    pub fn is_integer_param(&self, name: &str) -> bool {
789        (self.param_flags(name) as u32 & crate::ported::zsh_h::PM_INTEGER) != 0
790    }
791
792    /// `typeset -F` / `-E` — float.
793    pub fn is_float_param(&self, name: &str) -> bool {
794        let f = self.param_flags(name) as u32;
795        (f & (crate::ported::zsh_h::PM_EFLOAT | crate::ported::zsh_h::PM_FFLOAT)) != 0
796    }
797
798    /// `typeset -l` — Param has PM_LOWER.
799    pub fn is_lowercase_param(&self, name: &str) -> bool {
800        (self.param_flags(name) as u32 & crate::ported::zsh_h::PM_LOWER) != 0
801    }
802
803    /// `typeset -u` — Param has PM_UPPER.
804    pub fn is_uppercase_param(&self, name: &str) -> bool {
805        (self.param_flags(name) as u32 & crate::ported::zsh_h::PM_UPPER) != 0
806    }
807
808    /// `readonly` / `typeset -r` — Param has PM_READONLY.
809    pub fn is_readonly_param(&self, name: &str) -> bool {
810        (self.param_flags(name) as u32 & crate::ported::zsh_h::PM_READONLY) != 0
811    }
812
813    /// Most-recent-command exit status. Reads canonical
814    /// `builtin::LASTVAL` AtomicI32 (`Src/builtin.c:6443`).
815    pub fn last_status(&self) -> i32 {
816        crate::ported::builtin::LASTVAL
817            .load(std::sync::atomic::Ordering::Relaxed)
818    }
819
820    /// Write the most-recent-command exit status. The canonical
821    /// store is `builtin::LASTVAL`; this is the single setter.
822    /// Used everywhere `$?` / `%?` / errexit / ZERR trap read.
823    pub fn set_last_status(&mut self, status: i32) {
824        crate::ported::builtin::LASTVAL
825            .store(status, std::sync::atomic::Ordering::Relaxed);
826    }
827
828    /// Set an indexed array parameter via canonical paramtab
829    /// (`setaparam`, `Src/params.c:3595`). The single store.
830    pub fn set_array(&mut self, name: String, value: Vec<String>) {
831        crate::ported::params::setaparam(&name, value);                      // c:params.c:3595
832    }
833
834    /// Set an associative array parameter via canonical
835    /// `sethparam` (`Src/params.c:3602`). The single store.
836    pub fn set_assoc(&mut self, name: String, value: indexmap::IndexMap<String, String>) {
837        let mut flat: Vec<String> = Vec::with_capacity(value.len() * 2);
838        for (k, v) in &value {
839            flat.push(k.clone());
840            flat.push(v.clone());
841        }
842        crate::ported::params::sethparam(&name, flat);                       // c:params.c:3602
843    }
844
845    /// Read a scalar parameter. Mirrors C `getsparam` at
846    /// `Src/params.c:3076` — reads through paramtab, falls back to
847    /// special-var hooks and env.
848    pub fn scalar(&self, name: &str) -> Option<String> {
849        crate::ported::params::getsparam(name)
850    }
851
852    /// Read an array parameter from canonical paramtab. Mirrors C
853    /// `getaparam` at `Src/params.c:3100` — `paramtab->getnode(s)`
854    /// then `pm->u.arr.clone()`. Returns an owned `Vec<String>`.
855    pub fn array(&self, name: &str) -> Option<Vec<String>> {
856        crate::ported::params::paramtab().read()
857            .ok()
858            .and_then(|t| t.get(name).and_then(|pm| pm.u_arr.clone()))
859    }
860
861    /// Read an associative array parameter from canonical
862    /// `paramtab_hashed_storage`. Mirrors C `gethparam` at
863    /// `Src/params.c:3115` — returns the typed `IndexMap`.
864    pub fn assoc(&self, name: &str) -> Option<indexmap::IndexMap<String, String>> {
865        crate::ported::params::paramtab_hashed_storage()
866            .lock().ok()
867            .and_then(|m| m.get(name).cloned())
868    }
869
870    /// Test whether a scalar parameter exists in paramtab.
871    /// Mirrors the C `paramtab->getnode(name) != NULL` check.
872    pub fn has_scalar(&self, name: &str) -> bool {
873        crate::ported::params::getsparam(name).is_some()
874    }
875
876    /// Test whether an array parameter exists in paramtab.
877    pub fn has_array(&self, name: &str) -> bool {
878        crate::ported::params::paramtab().read()
879            .ok()
880            .and_then(|t| t.get(name).map(|pm| pm.u_arr.is_some()))
881            .unwrap_or(false)
882    }
883
884    /// Test whether an associative array parameter exists. Reads
885    /// canonical `paramtab_hashed_storage` (Src/params.c hashed
886    /// PM_HASHED slot).
887    pub fn has_assoc(&self, name: &str) -> bool {
888        crate::ported::params::paramtab_hashed_storage()
889            .lock()
890            .ok()
891            .map(|m| m.contains_key(name))
892            .unwrap_or(false)
893    }
894
895    /// Unset an associative array parameter from canonical paramtab
896    /// + paramtab_hashed_storage. Direct port of `unsetparam_pm`
897    /// for a PM_HASHED Param.
898    pub fn unset_assoc(&mut self, name: &str) {
899        if let Some(tab) = crate::ported::params::paramtab().write().ok().as_deref_mut() {
900            tab.remove(name);
901        }
902        let _ = crate::ported::params::paramtab_hashed_storage()
903            .lock().ok().as_deref_mut()
904            .map(|m| m.remove(name));
905    }
906
907    /// Read a regular (non-global) alias value. Reads canonical
908    /// `aliastab` (Src/hashtable.c:1186). Filters out aliases that
909    /// have the ALIAS_GLOBAL flag set so the regular-alias slot is
910    /// distinct from the global-alias slot, mirroring C's two
911    /// separate dispatch paths via `aliasflags` checks.
912    pub fn alias(&self, name: &str) -> Option<String> {
913        let tab = crate::ported::hashtable::aliastab_lock().read().ok()?;
914        let a = tab.get(name)?;
915        if (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0 {
916            None
917        } else {
918            Some(a.text.clone())
919        }
920    }
921
922    /// Read a global alias value (`alias -g`). Reads canonical
923    /// `aliastab` and filters to entries with the ALIAS_GLOBAL flag.
924    pub fn global_alias(&self, name: &str) -> Option<String> {
925        let tab = crate::ported::hashtable::aliastab_lock().read().ok()?;
926        let a = tab.get(name)?;
927        if (a.node.flags & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0 {
928            Some(a.text.clone())
929        } else {
930            None
931        }
932    }
933
934    /// Read a suffix alias value (`alias -s`). Reads canonical
935    /// `sufaliastab` (Src/hashtable.c:1187).
936    pub fn suffix_alias(&self, name: &str) -> Option<String> {
937        let tab = crate::ported::hashtable::sufaliastab_lock().read().ok()?;
938        Some(tab.get(name)?.text.clone())
939    }
940
941    /// Set a regular alias. Writes canonical aliastab with
942    /// ALIAS_GLOBAL bit cleared.
943    pub fn set_alias(&mut self, name: String, value: String) {
944        if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
945            tab.add(crate::ported::hashtable::createaliasnode(&name, &value, 0));
946        }
947    }
948
949    /// Set a global alias (`alias -g`). Writes canonical aliastab
950    /// with ALIAS_GLOBAL bit set.
951    pub fn set_global_alias(&mut self, name: String, value: String) {
952        if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
953            tab.add(crate::ported::hashtable::createaliasnode(
954                &name, &value, crate::ported::zsh_h::ALIAS_GLOBAL as u32,
955            ));
956        }
957    }
958
959    /// Set a suffix alias (`alias -s ext=cmd`). Writes canonical
960    /// sufaliastab.
961    pub fn set_suffix_alias(&mut self, name: String, value: String) {
962        if let Ok(mut tab) = crate::ported::hashtable::sufaliastab_lock().write() {
963            tab.add(crate::ported::hashtable::createaliasnode(&name, &value, 0));
964        }
965    }
966
967    /// Unset an alias from canonical aliastab (any flag). Mirrors
968    /// C's `unalias` lookup.
969    pub fn unset_alias(&mut self, name: &str) {
970        if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
971            tab.remove(name);
972        }
973    }
974
975    /// Unset a suffix alias.
976    pub fn unset_suffix_alias(&mut self, name: &str) {
977        if let Ok(mut tab) = crate::ported::hashtable::sufaliastab_lock().write() {
978            tab.remove(name);
979        }
980    }
981
982    /// Snapshot the alias map as a sorted `Vec<(name, value)>`,
983    /// only entries WITHOUT the ALIAS_GLOBAL flag (regular aliases).
984    pub fn alias_entries(&self) -> Vec<(String, String)> {
985        if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
986            tab.iter_sorted()
987                .into_iter()
988                .filter(|(_, a)| (a.node.flags
989                    & crate::ported::zsh_h::ALIAS_GLOBAL as i32) == 0)
990                .map(|(k, a)| (k.clone(), a.text.clone()))
991                .collect()
992        } else {
993            Vec::new()
994        }
995    }
996
997    /// Snapshot the global-alias entries (ALIAS_GLOBAL flag set).
998    pub fn global_alias_entries(&self) -> Vec<(String, String)> {
999        if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
1000            tab.iter_sorted()
1001                .into_iter()
1002                .filter(|(_, a)| (a.node.flags
1003                    & crate::ported::zsh_h::ALIAS_GLOBAL as i32) != 0)
1004                .map(|(k, a)| (k.clone(), a.text.clone()))
1005                .collect()
1006        } else {
1007            Vec::new()
1008        }
1009    }
1010
1011    /// Snapshot the suffix-alias entries.
1012    pub fn suffix_alias_entries(&self) -> Vec<(String, String)> {
1013        if let Ok(tab) = crate::ported::hashtable::sufaliastab_lock().read() {
1014            tab.iter_sorted()
1015                .into_iter()
1016                .map(|(k, a)| (k.clone(), a.text.clone()))
1017                .collect()
1018        } else {
1019            Vec::new()
1020        }
1021    }
1022
1023    /// Unset an array parameter. Direct port of `unsetparam_pm` for
1024    /// a PM_ARRAY Param. Mirrors are kept for now while the field
1025    /// transitions.
1026    pub fn unset_array(&mut self, name: &str) {
1027        if let Some(tab) = crate::ported::params::paramtab().write().ok().as_deref_mut() {
1028            tab.remove(name);
1029        }
1030    }
1031
1032    /// Unset a scalar parameter from canonical paramtab. Narrower
1033    /// than `unset_var` which clears arrays + assocs too. Direct
1034    /// port of `Src/params.c:unsetparam_pm` for a scalar PM_TYPE.
1035    pub fn unset_scalar(&mut self, name: &str) {
1036        if let Some(tab) = crate::ported::params::paramtab().write().ok().as_deref_mut() {
1037            tab.remove(name);
1038        }
1039    }
1040
1041    /// Unset a parameter from every store. Mirrors the C
1042    /// `unsetparam_pm` semantics at `Src/params.c:3905`: clear the
1043    /// paramtab entry + the legacy HashMap caches + (for exported
1044    /// vars) the env entry. Callers that need to scope the unset to
1045    /// just one type pass through this single entry so paramtab and
1046    /// the HashMaps don't drift apart.
1047    pub(crate) fn unset_var(&mut self, name: &str) {
1048        if let Some(tab) = crate::ported::params::paramtab().write().ok().as_deref_mut() {
1049            tab.remove(name);                                                // c:params.c:3900 paramtab removenode
1050        }
1051        let _ = crate::ported::params::paramtab_hashed_storage()
1052            .lock().ok().as_deref_mut()
1053            .map(|m| m.remove(name));
1054    }
1055
1056    /// Single-string substitution via the canonical pipeline. Snapshots
1057    /// the executor state into a `SubstState`, runs `singsub` from
1058    /// `Src/subst.c:514`, commits any side-effects (assigns inside
1059    /// `${var:=default}`, etc.) back to the executor.
1060    ///
1061    /// Replaces the bot-invented `expand_string` method that was deleted
1062    /// in the citation purge (180463e1e7). All call sites that previously
1063    /// did `exec.singsub(s)` now do `exec.singsub(s)` and route
1064
1065
1066    pub fn new() -> Self {
1067        tracing::debug!("ShellExecutor::new() initializing");
1068
1069        // Validate the inherited $PWD against the real cwd before any
1070        // builtin reads it as a logical-path base. Direct port of zsh's
1071        // ispwd() at src/zsh/Src/utils.c:809-829: $PWD is honored only
1072        // when it (a) is absolute, (b) stat's to the same dev+inode as
1073        // ".", and (c) contains no `.`/`..` components. Otherwise zsh
1074        // resets it to getcwd() (init.c:1247-1253).
1075        //
1076        // Without this check, a child process that inherits $PWD from
1077        // a parent run in a different directory (cargo test setting
1078        // current_dir(/tmp) but leaking PWD=/project/root) sees the
1079        // stale PWD and `cd .` later snaps the real cwd to wherever
1080        // PWD points, escaping the parent's sandbox. ztst harnesses
1081        // hit this and polluted the project root with test artifacts.
1082        if let Ok(pwd_env) = env::var("PWD") {
1083            let valid = ispwd(&pwd_env);
1084            if !valid {
1085                if let Ok(real) = env::current_dir() {
1086                    env::set_var("PWD", &real);
1087                }
1088            }
1089        } else if let Ok(real) = env::current_dir() {
1090            env::set_var("PWD", &real);
1091        }
1092
1093        // Initialize fpath from FPATH env var or use defaults
1094        let fpath = env::var("FPATH")
1095            .unwrap_or_default()
1096            .split(':')
1097            .filter(|s| !s.is_empty())
1098            .map(PathBuf::from)
1099            .collect();
1100
1101        let history = HistoryEngine::new().ok();
1102
1103        // Initialize standard zsh variables.
1104        //
1105        // `ZSH_VERSION` / `ZSH_PATCHLEVEL` come from the vendored zsh
1106        // source — build.rs parses `src/zsh/Config/version.mk` and
1107        // emits the constants below. Previously hardcoded `"5.9"` /
1108        // `"zsh-5.9-0-g73d3173"`; the latter was an invented git-hash
1109        // literal that didn't correspond to any real commit. The C
1110        // source sets these at `Src/params.c:972-973` via
1111        // `setsparam("ZSH_VERSION", ztrdup_metafy(ZSH_VERSION))`.
1112        let mut variables = HashMap::new();
1113        variables.insert("ZSH_VERSION".to_string(), zsh_version::ZSH_VERSION.to_string()); // c:params.c:972
1114        variables.insert("ZSH_PATCHLEVEL".to_string(),
1115            zsh_version::ZSH_PATCHLEVEL.to_string());                                       // c:params.c:973
1116        variables.insert("ZSH_NAME".to_string(), "zsh".to_string());
1117        // $ZSH_ARGZERO mirrors `posixzero` from Src/init.c:271
1118        // (`argv0 = argzero = posixzero = *argv++`). Src/params.c:971
1119        // does the actual `setsparam("ZSH_ARGZERO", ztrdup(posixzero))`
1120        // at the same setup phase Rust handles here. For -c / runscript
1121        // invocations the bin entrypoint overrides this with the
1122        // script path (Src/init.c:297).
1123        variables.insert(
1124            "ZSH_ARGZERO".to_string(),
1125            std::env::args().next().unwrap_or_else(|| "zsh".to_string()),
1126        );
1127        // ZLE word boundary chars — matches mainline zsh's default.
1128        variables.insert(
1129            "WORDCHARS".to_string(),
1130            "*?_-.[]~=/&;!#$%^(){}<>".to_string(),
1131        );
1132        variables.insert(
1133            "SHLVL".to_string(),
1134            env::var("SHLVL")
1135                .map(|v| {
1136                    v.parse::<i32>()
1137                        .map(|n| (n + 1).to_string())
1138                        .unwrap_or_else(|_| "1".to_string())
1139                })
1140                .unwrap_or_else(|_| "1".to_string()),
1141        );
1142        // POSIX/zsh default IFS is space, tab, newline, NUL. Splitters
1143        // throughout the codebase fall back to ` \t\n` when IFS is
1144        // missing; expose the actual default value so user code that
1145        // inspects $IFS sees what zsh exposes.
1146        variables.insert("IFS".to_string(), " \t\n\0".to_string());
1147
1148        // POSIX `getopts` initial state: OPTIND starts at 1, OPTERR
1149        // at 1 (errors enabled). Without these, scripts that read
1150        // `$OPTIND` before the first `getopts` call see empty strings
1151        // (zsh: `1`).
1152        variables.insert("OPTIND".to_string(), "1".to_string());
1153        variables.insert("OPTERR".to_string(), "1".to_string());
1154
1155        // zsh starts with `$_` empty (unlike bash which inherits the
1156        // OS-env value). The parent process sets `_=/path/to/binary`
1157        // before exec; zsh wipes that. Initialize to empty so script
1158        // reads of `$_` before any command runs return empty.
1159        variables.insert("_".to_string(), String::new());
1160        // `$histchars` — `Src/params.c:5064 histcharsgetfn` composes
1161        // bangchar+hatchar+hashchar (defaults `!`, `^`, `#` per
1162        // `Src/init.c:1100-1102`). Route through the C-port `histcharsgetfn`
1163        // so the value follows any runtime updates to the trio.
1164        variables.insert("histchars".to_string(),
1165            crate::ported::params::histcharsgetfn());                                       // c:params.c:5064
1166
1167        // c:Src/params.c:858-860 standard non-special param defaults.
1168        // The full createparamtable() body installs special_paramdef
1169        // entries (LINENO/PPID/EUID/etc) as PM_READONLY which would
1170        // block subsequent BUILTIN_SET_LINENO writes; the readonly-
1171        // special bypass at setsparam isn't ported yet. Inline these
1172        // three setiparam-equivalent values in the meantime.
1173        variables.insert("MAILCHECK".to_string(), "60".to_string());                    // c:858
1174        variables.insert("KEYTIMEOUT".to_string(), "40".to_string());                   // c:859
1175        variables.insert("LISTMAX".to_string(), "100".to_string());                     // c:860
1176        // `$WATCHFMT` — `Src/Modules/watch.c:137 DEFAULT_WATCHFMT`.
1177        // zsh's watch boot_ seeds WATCHFMT to the default when the
1178        // module loads. zshrs's modules are statically linked but
1179        // boot_ isn't wired into require_module yet, so seed the
1180        // default here. `print "$WATCHFMT"` prints the default
1181        // (diverges from `/bin/zsh -fc` which leaves it unset until
1182        // an explicit `zmodload zsh/watch`, but matches the
1183        // post-zmodload state that most plugin code expects).
1184        variables.insert("WATCHFMT".to_string(),
1185            crate::ported::modules::watch::DEFAULT_WATCHFMT.to_string());
1186
1187        // `$FUNCNEST` default. Real zsh defaults to 500 (Src/zsh.h
1188        // MAXNEST), but zshrs's bytecode-VM recursion eats ~40KB of
1189        // Rust stack per frame and tops out around 150 on the
1190        // default 8MB stack. We seed `100` here so plugin probes
1191        // (`${FUNCNEST:-default}`) get a realistic cap that
1192        // matches what `call_function` actually enforces. Users
1193        // who need deeper need to raise FUNCNEST explicitly AND
1194        // run with a larger stack (RUST_MIN_STACK).
1195        variables.insert("FUNCNEST".to_string(), "100".to_string());
1196
1197        // Run setlocale(LC_ALL, "") so nl_langinfo() (used by the
1198        // `langinfo` module) returns the host's actual locale instead
1199        // of the C/POSIX default ("US-ASCII"). Direct port of zsh's
1200        // Src/init.c:1208 setlocale call. unsafe { } around libc is
1201        // standard for this exact use-case — setlocale is process-
1202        // global and must run once at startup.
1203        unsafe {
1204            libc::setlocale(libc::LC_ALL, c"".as_ptr());
1205        }
1206
1207        // c:hashtable.c:1206 createaliastables() — seeds aliastab with
1208        // the `run-help` / `which-command` defaults. Run once at shell
1209        // init so the canonical port owns the default-alias set; the
1210        // Executor's `aliases` HashMap then mirrors aliastab.
1211        crate::ported::hashtable::createaliastables();
1212        // Build the initial $path tied array as a local — fans out
1213        // to paramtab below; no ShellExecutor mirror anymore.
1214        let mut arrays: HashMap<String, Vec<String>> = HashMap::new();
1215        let path_dirs: Vec<String> = env::var("PATH")
1216            .unwrap_or_default()
1217            .split(':')
1218            .map(|s| s.to_string())
1219            .collect();
1220        arrays.insert("path".to_string(), path_dirs);
1221        // Seed canonical OPTS_LIVE with defaults if not already
1222        // populated. `default_options` builds the same name→bool map
1223        // we previously cloned into `exec.options`.
1224        if crate::ported::options::opt_state_len() == 0 {
1225            for (k, v) in Self::default_options() {
1226                crate::ported::options::opt_state_set(&k, v);
1227            }
1228        }
1229        let mut exec = Self {
1230            scriptname: None,
1231            scriptfilename: None,
1232            loop_signal: None,
1233            subshell_snapshots: Vec::new(),
1234            inline_env_stack: Vec::new(),
1235            current_command_glob_failed: std::cell::Cell::new(false),
1236            jobs: JobTable::new(),
1237            fpath,
1238            zwc_cache: HashMap::new(),
1239            history,
1240            session_histnum: 0,
1241            completions: HashMap::new(),
1242            process_sub_counter: 0,
1243            traps: HashMap::new(),
1244            // zsh completion system
1245            comp_matches: Vec::new(),
1246            comp_groups: Vec::new(),
1247            comp_state: CompState::default(),
1248            zstyles: Vec::new(),
1249            comp_words: Vec::new(),
1250            comp_current: 0,
1251            comp_prefix: String::new(),
1252            comp_suffix: String::new(),
1253            comp_iprefix: String::new(),
1254            comp_isuffix: String::new(),
1255            local_scope_depth: 0,
1256            pending_underscore: None,
1257            in_dq_context: 0,
1258            in_scalar_assign: 0,
1259            session_history_ids: Vec::new(),
1260            open_fds: HashMap::new(),
1261            next_fd: 10,
1262            profiling_enabled: false,
1263            compsys_cache: {
1264                let cache_path = compsys::cache::default_cache_path();
1265                if cache_path.exists() {
1266                    let db_size = std::fs::metadata(&cache_path).map(|m| m.len()).unwrap_or(0);
1267                    match CompsysCache::open(&cache_path) {
1268                        Ok(c) => {
1269                            tracing::info!(
1270                                db_bytes = db_size,
1271                                path = %cache_path.display(),
1272                                "compsys: sqlite cache opened"
1273                            );
1274                            Some(c)
1275                        }
1276                        Err(e) => {
1277                            tracing::warn!(error = %e, "compsys: failed to open cache");
1278                            None
1279                        }
1280                    }
1281                } else {
1282                    tracing::debug!("compsys: no cache at {}", cache_path.display());
1283                    None
1284                }
1285            },
1286            compinit_pending: None, // (receiver, start_time)
1287            plugin_cache: {
1288                let pc_path = crate::plugin_cache::default_cache_path();
1289                if let Some(parent) = pc_path.parent() {
1290                    let _ = std::fs::create_dir_all(parent);
1291                }
1292                match crate::plugin_cache::PluginCache::open(&pc_path) {
1293                    Ok(pc) => {
1294                        let (plugins, functions) = pc.stats();
1295                        tracing::info!(
1296                            plugins,
1297                            cached_functions = functions,
1298                            path = %pc_path.display(),
1299                            "plugin_cache: sqlite opened"
1300                        );
1301                        Some(pc)
1302                    }
1303                    Err(e) => {
1304                        tracing::warn!(error = %e, "plugin_cache: failed to open");
1305                        None
1306                    }
1307                }
1308            },
1309            deferred_compdefs: Vec::new(),
1310            returning: None,
1311            breaking: 0,
1312            continuing: 0,
1313            zsh_compat: false,
1314            bash_compat: false,
1315            posix_mode: false,
1316            worker_pool: {
1317                let config = crate::config::load();
1318                let pool_size = crate::config::resolve_pool_size(&config.worker_pool);
1319                std::sync::Arc::new(crate::worker::WorkerPool::new(pool_size))
1320            },
1321            intercepts: Vec::new(),
1322            async_jobs: HashMap::new(),
1323            next_async_id: 1,
1324            defer_stack: Vec::new(),
1325            redirect_scope_stack: Vec::new(),
1326            redirect_failed: false,
1327            pending_stdin: None,
1328            functions_compiled: HashMap::new(),
1329            function_source: HashMap::new(),
1330            function_line_base: HashMap::new(),
1331            function_def_file: HashMap::new(),
1332            prompt_funcstack: Vec::new(),
1333            tied_scalar_to_array: HashMap::new(),
1334            tied_array_to_scalar: HashMap::new(),
1335            buffer_stack: Vec::new(),
1336        };
1337        // Mirror env-derived path arrays into the `arrays` table so
1338        // user-level `fpath` / `path` array reads see the inherited
1339        // entries. zsh: `fpath+=…` should append to the inherited
1340        // 43-entry array, not replace it. Same for `path` (PATH).
1341        let fpath_arr: Vec<String> = exec
1342            .fpath
1343            .iter()
1344            .map(|p| p.to_string_lossy().to_string())
1345            .collect();
1346        if !fpath_arr.is_empty() {
1347            exec.set_array("fpath".to_string(), fpath_arr);
1348        }
1349        if let Ok(path) = env::var("PATH") {
1350            let path_arr: Vec<String> = path
1351                .split(':')
1352                .filter(|s| !s.is_empty())
1353                .map(String::from)
1354                .collect();
1355            if !path_arr.is_empty() {
1356                exec.set_array("path".to_string(), path_arr);
1357            }
1358        }
1359        // Register the standard tied path-family pairs so `path+=` /
1360        // `fpath+=` / etc. mirror through the array→scalar sync hook
1361        // in BUILTIN_APPEND_ARRAY (and the SET_ARRAY tied path).
1362        // Direct port of the implicit ties that zsh wires up at
1363        // startup for PATH/path, FPATH/fpath, etc. Source-of-truth
1364        // for the pairs is Src/init.c's `setupvals()` PM_TIED entries.
1365        for (scalar, arr) in [
1366            ("PATH", "path"),
1367            ("FPATH", "fpath"),
1368            ("MANPATH", "manpath"),
1369            ("CDPATH", "cdpath"),
1370            ("MODULE_PATH", "module_path"),
1371        ] {
1372            exec.tied_array_to_scalar
1373                .insert(arr.to_string(), (scalar.to_string(), ":".to_string()));
1374            exec.tied_scalar_to_array
1375                .insert(scalar.to_string(), (arr.to_string(), ":".to_string()));
1376        }
1377
1378        // Mirror every constructor-time `variables` / `arrays` /
1379        // `assoc_arrays` seed into paramtab so the C-port readers see
1380        // the same initial state. C does this implicitly because its
1381        // single `paramtab` is populated by `setupvals()` /
1382        // `createparam()` calls at init (Src/init.c:1014-1300). The
1383        // Rust port builds local HashMaps first and then constructs
1384        // self; this loop fans the contents out to paramtab in one
1385        // pass at the end of new().
1386        for (k, v) in &variables {
1387            crate::ported::params::setsparam(k, v);                          // c:params.c:3350
1388        }
1389        for (k, v) in &arrays {
1390            crate::ported::params::setaparam(k, v.clone());                  // c:params.c:3595
1391        }
1392        // Assocs: there are no pre-seeded entries (terminfo / termcap
1393        // resolve lazily via magic_assoc_lookup) so no mirror loop.
1394        exec
1395    }
1396
1397    // enter_posix_mode / enter_ksh_mode moved to src/ported/options.rs
1398    // (canonical C source: Src/options.c:533 emulate()).
1399
1400    // host_apply_redirect / host_redirect_scope_begin / host_redirect_scope_end /
1401    // host_set_pending_stdin / host_exec_external moved to src/fusevm_bridge.rs
1402    // (extension; not a port of Src/exec.c).
1403
1404    /// Add a directory to fpath
1405    pub fn add_fpath(&mut self, path: PathBuf) {
1406        if !self.fpath.contains(&path) {
1407            self.fpath.insert(0, path);
1408        }
1409    }
1410
1411    /// Tab expansion — direct port of `zexpandtabs(const char *s, int len, int width, int startpos, FILE *fout, int all)` in zsh/Src/utils.c:5973.
1412    /// Moved to `crate::ported::utils::zexpandtabs`; re-exported below.
1413
1414    /// Execute a script file with bytecode caching — skips lex+parse+compile on cache hit.
1415    /// Bytecode is stored in rkyv keyed by (path, mtime).
1416    pub fn execute_script_file(&mut self, file_path: &str) -> Result<i32, String> {
1417
1418        let path = Path::new(file_path);
1419        let abs_path = path
1420            .canonicalize()
1421            .unwrap_or_else(|_| path.to_path_buf())
1422            .to_string_lossy()
1423            .to_string();
1424
1425        // Try bytecode cache first — rkyv shard at ~/.zshrs/scripts.rkyv.
1426        // The cache validates path + mtime + zshrs binary mtime; on any miss
1427        // we fall through to lex/parse/compile.
1428        if let Some(bc_blob) = crate::script_cache::try_load_bytes(path) {
1429            if let Ok(chunk) = bincode::deserialize::<fusevm::Chunk>(&bc_blob) {
1430                if !chunk.ops.is_empty() {
1431                    tracing::trace!(
1432                        path = %abs_path,
1433                        ops = chunk.ops.len(),
1434                        "execute_script_file: bytecode cache hit"
1435                    );
1436                    crate::fusevm_disasm::maybe_print_stdout(
1437                        &format!("execute_script_file:cache:{abs_path}"),
1438                        &chunk,
1439                    );
1440                    let mut vm = fusevm::VM::new(chunk);
1441                    register_builtins(&mut vm);
1442                    let _ctx = ExecutorContext::enter(self);
1443                    match vm.run() {
1444                        fusevm::VMResult::Ok(_) | fusevm::VMResult::Halted => {
1445                            self.set_last_status(vm.last_status);
1446                        }
1447                        fusevm::VMResult::Error(e) => {
1448                            return Err(format!("VM error: {}", e));
1449                        }
1450                    }
1451                    return Ok(self.last_status());
1452                }
1453            }
1454        }
1455
1456        // Cache miss — read, parse, compile, execute, then cache.
1457        // No history expansion: zsh fires `!` history sub only on
1458        // interactive input (the REPL line). Sourced files are
1459        // verbatim — `(( !${#ARR} ))` (logical-not) must NOT
1460        // become `(( <last-arg-of-prev-cmd>{#ARR} ))`. Direct port
1461        // of Src/init.c source() which calls `lex_init_buf` /
1462        // `loop()` without engaging the history layer.
1463        let content =
1464            std::fs::read_to_string(file_path).map_err(|e| format!("{}: {}", file_path, e))?;
1465        // Save & clear errflag around the parse so we can detect a
1466        // fresh syntax error vs an inherited one. Direct port of
1467        // Src/init.c source()'s `errflag &= ~ERRFLAG_ERROR;` before
1468        // `parse_event(ENDINPUT)` and the post-parse errflag check.
1469        let saved_errflag = errflag.load(Ordering::Relaxed);
1470        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
1471        crate::ported::parse::parse_init(&content);
1472        let program = crate::ported::parse::parse();
1473        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
1474        errflag.store(saved_errflag, Ordering::Relaxed);
1475        if parse_failed {
1476            return Err("parse error".to_string());
1477        }
1478
1479        let compiler = crate::compile_zsh::ZshCompiler::new();
1480        let chunk = compiler.compile(&program);
1481
1482        // Cache the bytecode for next time. Best-effort — failures don't
1483        // block execution since the chunk is already in hand.
1484        if let Ok(blob) = bincode::serialize(&chunk) {
1485            let _ = crate::script_cache::try_save_bytes(path, &blob);
1486            tracing::trace!(
1487                path = %abs_path,
1488                bytes = blob.len(),
1489                "execute_script_file: bytecode cached"
1490            );
1491        }
1492
1493        // Execute
1494        if !chunk.ops.is_empty() {
1495            crate::fusevm_disasm::maybe_print_stdout(
1496                &format!("execute_script_file:compile:{abs_path}"),
1497                &chunk,
1498            );
1499            let mut vm = fusevm::VM::new(chunk);
1500            register_builtins(&mut vm);
1501            let _ctx = ExecutorContext::enter(self);
1502            match vm.run() {
1503                fusevm::VMResult::Ok(_) | fusevm::VMResult::Halted => {
1504                    self.set_last_status(vm.last_status);
1505                }
1506                fusevm::VMResult::Error(e) => {
1507                    return Err(format!("VM error: {}", e));
1508                }
1509            }
1510        }
1511
1512        Ok(self.last_status())
1513    }
1514
1515    /// P9d: wordcode-consumer entry. Direct port of zsh's `execlist`
1516    /// from `Src/exec.c:1551-1671` — walks the wordcode buffer that
1517    /// P9c's `par_event_wordcode` emitted into `ECBUF`, dispatching on
1518    /// `WC_KIND` (wc_code) for each entry.
1519    ///
1520    /// Minimal implementation: walks ECBUF, dispatches WC_END to a
1521    /// no-op return-0 path. The full WC_LIST/WC_SUBLIST/WC_PIPE/WC_CMD/
1522    /// WC_REDIR/WC_SIMPLE/... dispatch tree (Src/exec.c ~30k lines
1523    /// total) is the multi-week rewrite called out in PORT_PLAN.md.
1524    /// This stub establishes the entry point and proves the consumer
1525    /// can walk a buffer P9c emitted into.
1526    pub fn exec_wordcode(&mut self) -> i32 {
1527        let buf = ECBUF.with_borrow(|b| b.clone());
1528        let (status, _next) = self.exec_list_wordcode(&buf, 0);
1529        self.set_last_status(status);
1530        status
1531    }
1532
1533    /// P9d stub: direct port of `execlist(Estate state, int dont_change_job,
1534    /// int exiting)` from `Src/exec.c:1551-1671`. Walks WC_LIST entries,
1535    /// dispatches each sublist payload to exec_pline_wordcode. Real
1536    /// implementation handles fork/wait + signal-trap dispatch.
1537    /// Returns (last_status, pc_after_walk).
1538    pub fn exec_list_wordcode(&mut self, buf: &[u32], mut pc: usize) -> (i32, usize) {
1539        let mut last_status: i32 = 0;
1540        while pc < buf.len() {
1541            let code = wc_code(buf[pc]);
1542            if code == WC_END {
1543                pc += 1;
1544                break;
1545            }
1546            if code != WC_LIST {
1547                pc += 1;
1548                continue;
1549            }
1550            let header = buf[pc];
1551            let skip = (wc_data(header)
1552                >> crate::ported::zsh_h::WC_LIST_FREE) as usize;
1553            pc += 1;
1554            let (s, _) = self.exec_sublist_wordcode(buf, pc);
1555            last_status = s;
1556            pc += skip;
1557        }
1558        (last_status, pc)
1559    }
1560
1561    /// P9d stub: direct port of `execsublist` from
1562    /// `Src/exec.c:1672-1810`. Walks WC_SUBLIST + pipeline payload.
1563    pub fn exec_sublist_wordcode(&mut self, buf: &[u32], mut pc: usize) -> (i32, usize) {
1564        let mut last_status: i32 = 0;
1565        if pc < buf.len() && wc_code(buf[pc]) == WC_SUBLIST {
1566            let header = buf[pc];
1567            let skip = (wc_data(header) >> 7) as usize;
1568            pc += 1;
1569            let (s, _) = self.exec_pline_wordcode(buf, pc);
1570            last_status = s;
1571            pc += skip;
1572        }
1573        (last_status, pc)
1574    }
1575
1576    /// P9d stub: direct port of `execpline` from
1577    /// `Src/exec.c:1812-1980`. Walks WC_PIPE chain + cmd payloads.
1578    pub fn exec_pline_wordcode(&mut self, buf: &[u32], mut pc: usize) -> (i32, usize) {
1579        let mut last_status: i32 = 0;
1580        if pc < buf.len() && wc_code(buf[pc]) == WC_PIPE {
1581            let header = buf[pc];
1582            let skip = ((wc_data(header) >> 1) & 0xffff) as usize;
1583            pc += 1;
1584            let (s, _) = self.exec_cmd_wordcode(buf, pc);
1585            last_status = s;
1586            pc += skip;
1587        }
1588        (last_status, pc)
1589    }
1590
1591    /// P9d: direct port of `execcmd_exec` / `execcmd_analyze` from
1592    /// `Src/exec.c:2700-3700`. Reads the cmd header (WC_SIMPLE /
1593    /// WC_SUBSH / WC_FOR / WC_CASE / ...) and dispatches accordingly.
1594    pub fn exec_cmd_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1595        if pc >= buf.len() {
1596            return (0, pc);
1597        }
1598        match wc_code(buf[pc]) {
1599            WC_SIMPLE => self.exec_simple_wordcode(buf, pc),
1600            WC_SUBSH => self.exec_subsh_wordcode(buf, pc),
1601            WC_CURSH => self.exec_cursh_wordcode(buf, pc),
1602            WC_FOR => self.exec_for_wordcode(buf, pc),
1603            WC_SELECT => self.exec_select_wordcode(buf, pc),
1604            WC_CASE => self.exec_case_wordcode(buf, pc),
1605            WC_IF => self.exec_if_wordcode(buf, pc),
1606            WC_WHILE => self.exec_while_wordcode(buf, pc),
1607            WC_REPEAT => self.exec_repeat_wordcode(buf, pc),
1608            WC_FUNCDEF => self.exec_funcdef_wordcode(buf, pc),
1609            WC_TIMED => self.exec_timed_wordcode(buf, pc),
1610            WC_COND => self.exec_cond_wordcode(buf, pc),
1611            WC_ARITH => self.exec_arith_wordcode(buf, pc),
1612            WC_TRY => self.exec_try_wordcode(buf, pc),
1613            _ => (0, pc + 1),
1614        }
1615    }
1616
1617    /// P9d: direct port of `execfor(Estate state, int do_exec)` from `Src/exec.c:1232-1350`.
1618    /// Reads WC_FOR header via WC_FOR_TYPE/WC_FOR_SKIP, dispatches on
1619    /// type (PPARAM / LIST / COND), iterates body via recursive
1620    /// exec_list_wordcode calls.
1621    pub fn exec_for_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1622        if pc >= buf.len() {
1623            return (0, pc);
1624        }
1625        let header = buf[pc];
1626        let _type_bits = WC_FOR_TYPE(header);
1627        let skip = WC_FOR_SKIP(header) as usize;
1628        let _ = WC_FOR_LIST;
1629        // exec.c:1245+ — read var name via ecgetstr, iterate words,
1630        // exec body. Full implementation needs the var-binding +
1631        // iteration loop; this stub advances past the form.
1632        let mut last_status: i32 = 0;
1633        let end_pc = pc + 1 + skip;
1634        // Walk inner body (after header + var-name slot) once as a
1635        // shape-correct placeholder.
1636        let body_pc = pc + 2;
1637        if body_pc < end_pc {
1638            let (s, _) = self.exec_list_wordcode(buf, body_pc);
1639            last_status = s;
1640        }
1641        (last_status, end_pc)
1642    }
1643    /// P9d: `execselect` shape — same as exec_for but with `select`
1644    /// REPL prompt at each iteration. Src/exec.c:1352-1490.
1645    pub fn exec_select_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1646        self.exec_for_wordcode(buf, pc)
1647    }
1648    /// P9d: direct port of `execcase(Estate state, int do_exec)` from `Src/exec.c:1492-1550`.
1649    /// Reads WC_CASE_TYPE + WC_CASE_SKIP, walks pattern arms.
1650    pub fn exec_case_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1651        if pc >= buf.len() {
1652            return (0, pc);
1653        }
1654        let header = buf[pc];
1655        let _type_bits = WC_CASE_TYPE(header);
1656        let skip = WC_CASE_SKIP(header) as usize;
1657        // Full implementation: pattern-match word against each arm's
1658        // patterns, exec the first matching arm's body. Stub walks the
1659        // body once as a placeholder.
1660        let mut last_status: i32 = 0;
1661        let end_pc = pc + 1 + skip;
1662        let body_pc = pc + 1;
1663        if body_pc < end_pc {
1664            let (s, _) = self.exec_list_wordcode(buf, body_pc);
1665            last_status = s;
1666        }
1667        (last_status, end_pc)
1668    }
1669    /// P9d: full port of `execif(Estate state, int do_exec)` from `Src/loop.c:299-340`.
1670    ///
1671    /// C body walks the if/elif/else chain. Each cond is an inner
1672    /// WC_IF header with WC_IF_TYPE distinguishing IF / ELIF / ELSE.
1673    /// Returns lastval = status of the run branch, or 0 if no branch
1674    /// matched.
1675    pub fn exec_if_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1676        if pc >= buf.len() {
1677            return (0, pc);
1678        }
1679        let header = buf[pc];
1680        let skip = WC_IF_SKIP(header) as usize;
1681        let end_pc = pc + 1 + skip;
1682        let mut cur = pc + 1;
1683        let mut run: i32 = 0; // 0=no branch, 1=if/elif body, 2=else body
1684        let mut s = 0; // 0=in if/elif chain, 1=elif seen at least once
1685        let mut last_status: i32 = 0;
1686        // loop.c:307-326 — walk the chain.
1687        while cur < end_pc {
1688            if cur >= buf.len() {
1689                break;
1690            }
1691            let code = buf[cur];
1692            cur += 1;
1693            if wc_code(code) != WC_IF {
1694                // Past the IF header chain — must be the body of a
1695                // previously-selected branch we should run.
1696                run = 1;
1697                cur -= 1;
1698                break;
1699            }
1700            // WC_IF_TYPE == ELSE (2) — unconditional else body.
1701            if WC_IF_TYPE(code) == 2 {
1702                run = 2;
1703                break;
1704            }
1705            let next = cur + WC_IF_SKIP(code) as usize;
1706            let (cond_status, after_cond) = self.exec_list_wordcode(buf, cur);
1707            last_status = cond_status;
1708            if cond_status == 0 {
1709                run = 1;
1710                cur = after_cond;
1711                break;
1712            }
1713            if RETFLAG.load(Ordering::SeqCst) != 0 {
1714                break;
1715            }
1716            s = 1;
1717            cur = next;
1718        }
1719        let _ = s;
1720        // loop.c:328-336 — run the selected branch body.
1721        if run != 0 && cur < end_pc {
1722            let (body_status, _) = self.exec_list_wordcode(buf, cur);
1723            last_status = body_status;
1724        } else if RETFLAG.load(Ordering::SeqCst) == 0
1725            && (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) == 0
1726        {
1727            last_status = 0;
1728        }
1729        (last_status, end_pc)
1730    }
1731    /// P9d: full port of `execwhile(Estate state, UNUSED(int do_exec))` from `Src/loop.c:432-498`.
1732    ///
1733    /// Loops {exec cond; check status XOR isuntil; exec body; check
1734    /// breaks/contflag/retflag/errflag} until termination.
1735    pub fn exec_while_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1736        if pc >= buf.len() {
1737            return (0, pc);
1738        }
1739        let header = buf[pc];
1740        // loop.c:438 — `isuntil = (WC_WHILE_TYPE(code) == WC_WHILE_UNTIL)`.
1741        // WC_WHILE_UNTIL = 2 per zsh.h:1015.
1742        let isuntil = WC_WHILE_TYPE(header) == 2;
1743        let skip = WC_WHILE_SKIP(header) as usize;
1744        let end_pc = pc + 1 + skip;
1745        let loop_pc = pc + 1;
1746        // loop.c:443-446 — pushheap; cmdpush; loops++.
1747        LOOPS.fetch_add(1, Ordering::SeqCst);
1748        let mut last_status: i32 = 0;
1749        let mut oldval: i32 = 0;
1750        // Safety cap to prevent runaway infinite loops in stubs — real
1751        // C loops forever if conditions hold.
1752        let mut iters = 0u64;
1753        const ITER_CAP: u64 = 1_000_000;
1754        loop {
1755            iters += 1;
1756            if iters > ITER_CAP {
1757                break;
1758            }
1759            // loop.c:467 — exec cond (first inner list).
1760            let (cond_status, after_cond) = self.exec_list_wordcode(buf, loop_pc);
1761            last_status = cond_status;
1762            // loop.c:473 — `if (!((lastval == 0) ^ isuntil)) break;`
1763            let cond_passed = (cond_status == 0) ^ isuntil;
1764            if !cond_passed {
1765                if BREAKS.load(Ordering::SeqCst) > 0 {
1766                    BREAKS.fetch_sub(1, Ordering::SeqCst);
1767                }
1768                if RETFLAG.load(Ordering::SeqCst) == 0 {
1769                    last_status = oldval;
1770                }
1771                break;
1772            }
1773            // loop.c:481 — retflag bail.
1774            if RETFLAG.load(Ordering::SeqCst) != 0 {
1775                if BREAKS.load(Ordering::SeqCst) > 0 {
1776                    BREAKS.fetch_sub(1, Ordering::SeqCst);
1777                }
1778                break;
1779            }
1780            // loop.c:489 — exec body.
1781            let (body_status, _) = self.exec_list_wordcode(buf, after_cond);
1782            last_status = body_status;
1783            // loop.c:493-497 — breaks/continue handling.
1784            if BREAKS.load(Ordering::SeqCst) > 0 {
1785                let prev = BREAKS.fetch_sub(1, Ordering::SeqCst);
1786                if prev - 1 > 0 || CONTFLAG.load(Ordering::SeqCst) == 0 {
1787                    break;
1788                }
1789                CONTFLAG.store(0, Ordering::SeqCst);
1790            }
1791            // loop.c:498-501 — errflag bail.
1792            if (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0 {
1793                last_status = 1;
1794                break;
1795            }
1796            // loop.c:502 — retflag bail.
1797            if RETFLAG.load(Ordering::SeqCst) != 0 {
1798                break;
1799            }
1800            oldval = last_status;
1801        }
1802        LOOPS.fetch_sub(1, Ordering::SeqCst);
1803        (last_status, end_pc)
1804    }
1805    /// P9d: full port of `execrepeat(Estate state, UNUSED(int do_exec))` from `Src/loop.c:499-552`.
1806    ///
1807    /// C body:
1808    ///   end = state->pc + WC_REPEAT_SKIP(code);
1809    ///   tmp = ecgetstr(state, EC_DUPTOK, &htok);
1810    ///   if (htok) { singsub(&tmp); untokenize(tmp); }
1811    ///   count = mathevali(tmp);
1812    ///   loops++;
1813    ///   loop = state->pc;
1814    ///   while (count-- > 0) {
1815    ///     state->pc = loop;
1816    ///     execlist(state, 1, 0);
1817    ///     if (breaks) { breaks--; if (breaks || !contflag) break; contflag = 0; }
1818    ///     if (errflag) { lastval = 1; break; }
1819    ///     if (retflag) break;
1820    ///   }
1821    ///   loops--;
1822    pub fn exec_repeat_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1823        if pc >= buf.len() {
1824            return (0, pc);
1825        }
1826        let header = buf[pc];
1827        let skip = WC_REPEAT_SKIP(header) as usize;
1828        let end_pc = pc + 1 + skip;
1829        // loop.c:511 — `tmp = ecgetstr(state, EC_DUPTOK, &htok);`
1830        let (count_expr_raw, after_count) = ecgetstr_wordcode(buf, pc + 1);
1831        // loop.c:512-515 — singsub + untokenize on tokenized count.
1832        let count_expr_sub = singsub(&count_expr_raw);
1833        let count_expr = crate::ported::lex::untokenize(&count_expr_sub);
1834        // loop.c:516 — `count = mathevali(tmp);`
1835        let count_val = mathevali(&count_expr).unwrap_or(0);
1836        if (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0 {
1837            return (1, end_pc);
1838        }
1839        let mut last_status: i32 = 0; // loop.c:519 — `lastval = 0` for zero count.
1840        // loop.c:520-522 — `pushheap(); cmdpush(CS_REPEAT); loops++;`
1841        LOOPS.fetch_add(1, Ordering::SeqCst);
1842        let loop_body_pc = after_count;
1843        // loop.c:523-545 — main iteration.
1844        let mut remaining = count_val;
1845        while remaining > 0 {
1846            remaining -= 1;
1847            let (s, _) = self.exec_list_wordcode(buf, loop_body_pc);
1848            last_status = s;
1849            // loop.c:528-533 — breaks/continue handling.
1850            if BREAKS.load(Ordering::SeqCst) > 0 {
1851                let prev = BREAKS.fetch_sub(1, Ordering::SeqCst);
1852                if prev - 1 > 0 || CONTFLAG.load(Ordering::SeqCst) == 0 {
1853                    break;
1854                }
1855                CONTFLAG.store(0, Ordering::SeqCst);
1856            }
1857            // loop.c:534-537 — errflag bail.
1858            if (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0 {
1859                last_status = 1;
1860                break;
1861            }
1862            // loop.c:538 — retflag bail (function return).
1863            if RETFLAG.load(Ordering::SeqCst) != 0 {
1864                break;
1865            }
1866        }
1867        // loop.c:546-549 — `cmdpop(); popheap(); loops--;`
1868        LOOPS.fetch_sub(1, Ordering::SeqCst);
1869        (last_status, end_pc)
1870    }
1871    /// P9d stub: `execfuncdef`.
1872    pub fn exec_funcdef_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1873        self.skip_form(buf, pc)
1874    }
1875    /// P9d stub: `execsubsh` for `(...)` subshell.
1876    pub fn exec_subsh_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1877        self.skip_form(buf, pc)
1878    }
1879    /// P9d stub: `execcursh` for `{...}` brace group.
1880    pub fn exec_cursh_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1881        self.skip_form(buf, pc)
1882    }
1883    /// P9d stub: `exectimed` for `time pipeline`.
1884    pub fn exec_timed_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1885        self.skip_form(buf, pc)
1886    }
1887    /// P9d stub: `execcond` for `[[ ... ]]`.
1888    pub fn exec_cond_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1889        self.skip_form(buf, pc)
1890    }
1891    /// P9d stub: `execarith` for `(( ... ))`.
1892    pub fn exec_arith_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1893        self.skip_form(buf, pc)
1894    }
1895    /// P9d stub: `exectry` for `{ try } always { finally }`.
1896    pub fn exec_try_wordcode(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1897        self.skip_form(buf, pc)
1898    }
1899
1900    /// Shared helper for WC_* form dispatch stubs: read the header's
1901    /// `skip` field (data >> WC_CODEBITS) and advance pc past the
1902    /// payload. Each real production-specific exec_* will replace its
1903    /// call to this with the form-specific logic.
1904    fn skip_form(&mut self, buf: &[u32], pc: usize) -> (i32, usize) {
1905        if pc >= buf.len() {
1906            return (0, pc);
1907        }
1908        let skip = wc_data(buf[pc]) as usize;
1909        (0, pc + 1 + skip)
1910    }
1911
1912    /// P9d: direct port of `execsimple(Estate state)` from `Src/exec.c:3702-4100`.
1913    /// Walks WC_SIMPLE header + word slots, decodes the interned
1914    /// strings via `ecgetstr`, builds argv, invokes the command.
1915    /// Real implementation handles assignments + redirections inline
1916    /// from the same wordcode; this minimal version pulls just words.
1917    pub fn exec_simple_wordcode(&mut self, buf: &[u32], mut pc: usize) -> (i32, usize) {
1918        let mut last_status: i32 = 0;
1919        if pc < buf.len() && wc_code(buf[pc]) == WC_SIMPLE {
1920            let header = buf[pc];
1921            let nwords = wc_data(header) as usize;
1922            pc += 1;
1923            // Decode the interned strings into an argv vector.
1924            let mut argv: Vec<String> = Vec::with_capacity(nwords);
1925            for _ in 0..nwords {
1926                let (word, next) = ecgetstr(buf, pc);
1927                argv.push(word);
1928                pc = next;
1929            }
1930            // Invoke via the existing command-execution path. argv[0]
1931            // is the command name; remainder are arguments. Real exec
1932            // (Src/exec.c:3850 execcmd_analyze) would resolve builtin /
1933            // function / external + fork/exec; we delegate to the
1934            // existing AST-based simple-cmd executor's argv hook.
1935            if !argv.is_empty() {
1936                last_status = self.invoke_argv_wordcode(&argv);
1937            }
1938        }
1939        (last_status, pc)
1940    }
1941
1942    /// Minimal command invoker for wordcode-driven simple commands.
1943    /// Bridges the wordcode-side argv into the existing AST-side
1944    /// simple-cmd dispatch by constructing a single-Simple ZshProgram
1945    /// and running it through `execute_script_zsh_pipeline`. Real exec
1946    /// (P9d full) bypasses the AST and dispatches builtin/function/
1947    /// external directly from the wordcode — but the AST path
1948    /// already does this correctly today, so until the full
1949    /// builtin/function/external dispatch is ported into the wordcode
1950    /// consumer, this bridge keeps actual execution working.
1951    fn invoke_argv_wordcode(&mut self, argv: &[String]) -> i32 {
1952        let script = argv
1953            .iter()
1954            .map(|s| {
1955                // Minimal shell-escape: wrap in single quotes if
1956                // the arg contains whitespace or special chars.
1957                if s.chars().any(|c| c.is_whitespace() || "\"'`$\\|;&<>(){}[]*?~".contains(c)) {
1958                    format!("'{}'", s.replace('\'', "'\\''"))
1959                } else {
1960                    s.clone()
1961                }
1962            })
1963            .collect::<Vec<_>>()
1964            .join(" ");
1965        self.execute_script_zsh_pipeline(&script).unwrap_or(1)
1966    }
1967
1968    /// Execute via the lex+parse free fns + ZshCompiler pipeline.
1969    /// This is the only execution path; `execute_script` delegates here.
1970    pub fn execute_script_zsh_pipeline(&mut self, script: &str) -> Result<i32, String> {
1971        // Skip history expansion for non-interactive script execution
1972        // (`zsh -c '…'`, internal eval, sourced files). zsh's `!`
1973        // history sub only fires on the REPL command line, never on
1974        // a pre-parsed script body. The interactive REPL has its
1975        // own dedicated path that calls expand_history before
1976        // dispatching here.
1977        // Save & clear errflag around the parse so a fresh syntax
1978        // error is distinguishable from one already in flight. Mirrors
1979        // Src/init.c loop()'s pre-parse `errflag &= ~ERRFLAG_ERROR;`.
1980        let saved_errflag = errflag.load(Ordering::Relaxed);
1981        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
1982        crate::ported::parse::parse_init(script);
1983        let program = crate::ported::parse::parse();
1984        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
1985        errflag.store(saved_errflag, Ordering::Relaxed);
1986        if parse_failed {
1987            return Err("parse error".to_string());
1988        }
1989
1990        let compiler = crate::compile_zsh::ZshCompiler::new();
1991        let chunk = compiler.compile(&program);
1992
1993        if chunk.ops.is_empty() {
1994            return Ok(self.last_status());
1995        }
1996
1997        crate::fusevm_disasm::maybe_print_stdout("execute_script_zsh_pipeline", &chunk);
1998        let mut vm = fusevm::VM::new(chunk);
1999        register_builtins(&mut vm);
2000        {
2001            let _ctx = ExecutorContext::enter(self);
2002            match vm.run() {
2003                fusevm::VMResult::Ok(_) | fusevm::VMResult::Halted => {
2004                    self.set_last_status(vm.last_status);
2005                }
2006                fusevm::VMResult::Error(e) => return Err(format!("VM error: {}", e)),
2007            }
2008        }
2009
2010        // Fire EXIT trap if set. Same logic as execute_script's old path:
2011        // remove first to prevent infinite recursion, then run.
2012        if let Some(action) = self.traps.remove("EXIT") {
2013            tracing::debug!("firing EXIT trap (new pipeline)");
2014            let _ = self.execute_script_zsh_pipeline(&action);
2015        }
2016
2017        Ok(self.last_status())
2018    }
2019
2020    #[tracing::instrument(skip(self, script), fields(len = script.len()))]
2021    pub fn execute_script(&mut self, script: &str) -> Result<i32, String> {
2022        // lex+parse free fns + ZshCompiler is the only execution path.
2023        self.execute_script_zsh_pipeline(script)
2024    }
2025
2026    /// Whether `name` is a known function. Checks the compiled-functions
2027    /// table and the autoload-pending registry — `autoload foo` should
2028    /// make `whence foo`/`type foo`/`functions foo` recognize `foo` as
2029    /// a function before it's actually loaded. Doesn't trigger autoload
2030    /// itself; use `maybe_autoload` first if you need to load before
2031    /// introspecting.
2032    pub fn function_exists(&self, name: &str) -> bool {
2033        // Either compiled (already loaded) or shfunctab has an
2034        // autoload stub with PM_UNDEFINED set (pending). Matches C's
2035        // `lookupshfunc(name)` semantics at `Src/exec.c:5215`.
2036        if self.functions_compiled.contains_key(name) {
2037            return true;
2038        }
2039        crate::ported::hashtable::shfunctab_lock().read().ok()
2040            .map(|t| t.get(name).is_some())
2041            .unwrap_or(false)
2042    }
2043
2044    /// Canonical source text for a function. Returns from `function_source`
2045    /// (populated by autoload paths and runtime FuncDef registration via
2046    /// BUILTIN_REGISTER_COMPILED_FN with body_source). Returns `None` if
2047    /// no canonical source is on file.
2048    pub fn function_definition_text(&self, name: &str) -> Option<String> {
2049        self.function_source.get(name).cloned()
2050    }
2051
2052    /// Remove a function from both tables (compiled chunk + canonical
2053    /// source). Returns true iff at least one table held it.
2054    pub fn remove_function(&mut self, name: &str) -> bool {
2055        let a = self.functions_compiled.remove(name).is_some();
2056        let c = self.function_source.remove(name).is_some();
2057        let _ = self.function_line_base.remove(name);
2058        let _ = self.function_def_file.remove(name);
2059        a || c
2060    }
2061
2062    /// Sorted list of every known function name (union of compiled + source).
2063    pub fn function_names(&self) -> Vec<String> {
2064        let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
2065        for k in self.functions_compiled.keys() {
2066            set.insert(k.clone());
2067        }
2068        for k in self.function_source.keys() {
2069            set.insert(k.clone());
2070        }
2071        set.into_iter().collect()
2072    }
2073
2074    /// Dispatch a function by name through the new (compiled) pipeline.
2075    /// Mirrors `ZshrsHost::call_function`'s resolution order — checks
2076    /// `functions_compiled` first, triggers autoload if needed, then falls
2077    /// back to the legacy AST recompile path. Returns `None` if the name
2078    /// isn't a function (caller falls back to external dispatch).
2079    ///
2080    /// This is the synchronous-side replacement for the legacy
2081    /// `call_function(&ShellCommand, args)`. It avoids the AST detour when
2082    /// the new pipeline already has a Chunk for the function.
2083    pub fn dispatch_function_call(&mut self, name: &str, args: &[String]) -> Option<i32> {
2084        // maybe_autoload / autoload_function were deleted with the
2085        // old exec.c stubs. Until canonical autoload is wired,
2086        // resolve from functions_compiled directly.
2087        let chunk = self.functions_compiled.get(name).cloned()?;
2088
2089        // FUNCNEST guard — see `call_function` for the lower-than-
2090        // zsh ceiling rationale. Cap at 100 by default (matches
2091        // call_function's ceiling).
2092        let funcnest_limit: usize = self
2093            .scalar("FUNCNEST")
2094            .and_then(|s| s.parse().ok())
2095            .unwrap_or(100);
2096        if self.local_scope_depth >= funcnest_limit {
2097            eprintln!(
2098                "{}: maximum nested function level reached; increase FUNCNEST?",
2099                name
2100            );
2101            return Some(1);
2102        }
2103        // Save and replace positional params + local-scope save/restore,
2104        // mirroring the legacy `call_function(&ShellCommand, args)` and
2105        // ZshrsHost::call_function.
2106        let saved_params = self.pparams();
2107        self.set_pparams(args.to_vec());
2108        // FUNCTION_ARGZERO: zsh sets `\$0` inside a function to the
2109        // function name (default-on option). The bytecode-level
2110        // call_function path already does this; the dispatch path
2111        // used by dynamic-command-name dispatch (`f=hook; \$f`)
2112        // didn't, so plugin code reading `\$0` saw the binary path
2113        // instead. Save and install the function name; restore on
2114        // exit. Anonymous functions get the cosmetic `(anon)` per
2115        // call_function above.
2116        let display_name = if name.starts_with("_zshrs_anon_") {
2117            "(anon)".to_string()
2118        } else {
2119            name.to_string()
2120        };
2121        let saved_zero = crate::ported::params::getsparam("0");
2122        self.set_scalar("0".to_string(), display_name);
2123        self.local_scope_depth += 1;
2124        // c:Src/exec.c doshfunc startparamscope(): bump canonical
2125        // `locallevel` so any `local`/`typeset` inside the body
2126        // installs Params at the correct scope. endparamscope at
2127        // exit decrements + restores Param.old chain.
2128        crate::ported::params::locallevel.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2129        let line_base = self
2130            .function_line_base
2131            .get(name)
2132            .copied()
2133            .unwrap_or(0);
2134        let def_file = self.function_def_file.get(name).cloned().flatten();
2135        self.prompt_funcstack
2136            .push((name.to_string(), line_base, def_file));
2137
2138        crate::fusevm_disasm::maybe_print_stdout(&format!("function:{name}"), &chunk);
2139        let mut vm = fusevm::VM::new(chunk);
2140        register_builtins(&mut vm);
2141        let _ctx = ExecutorContext::enter(self);
2142        let _ = vm.run();
2143        let status = vm.last_status;
2144        drop(_ctx);
2145
2146        self.set_pparams(saved_params);
2147        self.prompt_funcstack.pop();
2148        // c:Src/exec.c doshfunc → endparamscope(). Decrements
2149        // canonical locallevel and walks paramtab restoring the
2150        // Param.old chain for every entry installed at this depth.
2151        crate::ported::params::endparamscope();
2152        self.local_scope_depth -= 1;
2153        match saved_zero {
2154            Some(v) => {
2155                self.set_scalar("0".to_string(), v);
2156            }
2157            None => {
2158                self.unset_scalar("0");
2159            }
2160        }
2161
2162        // Honor explicit `return N` from inside the function body.
2163        if let Some(ret) = self.returning.take() {
2164            self.set_last_status(ret);
2165            Some(ret)
2166        } else {
2167            self.set_last_status(status);
2168            Some(status)
2169        }
2170    }
2171
2172    pub(crate) fn execute_external(
2173        &mut self,
2174        cmd: &str,
2175        args: &[String],
2176        redirects: &[Redirect],
2177    ) -> Result<i32, String> {
2178        self.execute_external_bg(cmd, args, redirects, false)
2179    }
2180
2181    fn execute_external_bg(
2182        &mut self,
2183        cmd: &str,
2184        args: &[String],
2185        _redirects: &[Redirect],
2186        background: bool,
2187    ) -> Result<i32, String> {
2188        tracing::trace!(cmd, bg = background, "exec external");
2189        let mut command = Command::new(cmd);
2190        command.args(args);
2191
2192        // Redirect handling moved entirely to fusevm's WithRedirectsBegin/End
2193        // ops at compile time; the `_redirects` slice arrives empty in every
2194        // production code path. The legacy `for redir in redirects { ... }`
2195        // block (~120 LOC of file/pipe/heredoc/herestring/fd_var handling)
2196        // is gone.
2197
2198        if background {
2199            match command.spawn() {
2200                Ok(child) => {
2201                    let pid = child.id();
2202                    let cmd_str = format!("{} {}", cmd, args.join(" "));
2203                    let job_id = self.jobs.add_job(child, cmd_str, JobState::Running);
2204                    println!("[{}] {}", job_id, pid);
2205                    Ok(0)
2206                }
2207                Err(e) => {
2208                    if e.kind() == io::ErrorKind::NotFound {
2209                        // zsh: absolute paths emit "no such file or
2210                        // directory" (the OS error, since the path was
2211                        // tried directly), not "command not found"
2212                        // (which implies PATH search).
2213                        if cmd.starts_with('/') {
2214                            eprintln!("zshrs:1: no such file or directory: {}", cmd);
2215                        } else {
2216                            eprintln!("zshrs:1: command not found: {}", cmd);
2217                        }
2218                        Ok(127)
2219                    } else {
2220                        Err(format!("zshrs: {}: {}", cmd, e))
2221                    }
2222                }
2223            }
2224        } else {
2225            match command.status() {
2226                Ok(status) => Ok(status.code().unwrap_or(1)),
2227                Err(e) => {
2228                    if e.kind() == io::ErrorKind::NotFound {
2229                        // zsh: absolute paths emit "no such file or
2230                        // directory" (the OS error, since the path was
2231                        // tried directly), not "command not found"
2232                        // (which implies PATH search).
2233                        if cmd.starts_with('/') {
2234                            eprintln!("zshrs:1: no such file or directory: {}", cmd);
2235                        } else {
2236                            eprintln!("zshrs:1: command not found: {}", cmd);
2237                        }
2238                        Ok(127)
2239                    } else if e.kind() == io::ErrorKind::PermissionDenied {
2240                        // zsh: non-executable file → "permission denied"
2241                        // on stderr and exit 126 (POSIX convention for
2242                        // "command found but not executable"). zshrs
2243                        // previously bubbled the IO error up via Err
2244                        // and the surrounding code converted to 127
2245                        // with no diagnostic.
2246                        eprintln!("zshrs:1: permission denied: {}", cmd);
2247                        Ok(126)
2248                    } else {
2249                        Err(format!("zshrs: {}: {}", cmd, e))
2250                    }
2251                }
2252            }
2253        }
2254    }
2255
2256    pub(crate) fn collect_until_paren(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
2257        let mut result = String::new();
2258        let mut depth = 1;
2259
2260        for c in chars.by_ref() {
2261            if c == '(' {
2262                depth += 1;
2263                result.push(c);
2264            } else if c == ')' {
2265                depth -= 1;
2266                if depth == 0 {
2267                    break;
2268                }
2269                result.push(c);
2270            } else {
2271                result.push(c);
2272            }
2273        }
2274
2275        result
2276    }
2277
2278    pub(crate) fn collect_until_double_paren(
2279        chars: &mut std::iter::Peekable<std::str::Chars>,
2280    ) -> String {
2281        let mut result = String::new();
2282        let mut arith_depth = 1; // Tracks $(( ... )) nesting
2283        let mut paren_depth = 0; // Tracks ( ... ) nesting within expression
2284
2285        while let Some(c) = chars.next() {
2286            if c == '(' {
2287                if paren_depth == 0 && chars.peek() == Some(&'(') {
2288                    // Nested $(( - but we need to see if it's really another arithmetic
2289                    // For simplicity, track inner parens
2290                    paren_depth += 1;
2291                    result.push(c);
2292                } else {
2293                    paren_depth += 1;
2294                    result.push(c);
2295                }
2296            } else if c == ')' {
2297                if paren_depth > 0 {
2298                    // Inside nested parens, just close one level
2299                    paren_depth -= 1;
2300                    result.push(c);
2301                } else if chars.peek() == Some(&')') {
2302                    // At top level and seeing )) - this closes our arithmetic
2303                    chars.next();
2304                    arith_depth -= 1;
2305                    if arith_depth == 0 {
2306                        break;
2307                    }
2308                    result.push_str("))");
2309                } else {
2310                    // Single ) at top level - shouldn't happen in valid expression
2311                    result.push(c);
2312                }
2313            } else {
2314                result.push(c);
2315            }
2316        }
2317
2318        result
2319    }
2320
2321    /// Parse `cmd_str` via parse_init+parse and pull out the first Simple
2322    /// command's words, untokenized + variable-expanded, ready to spawn
2323    /// as argv. Used by process-substitution where we need raw argv to
2324    /// hand to `Command::new`. Returns empty vec if the cmd isn't a
2325    /// simple shape — pipelines / compound forms aren't process-sub
2326    /// friendly anyway.
2327    fn simple_cmd_words(&mut self, cmd_str: &str) -> Vec<String> {
2328        // Mirror Src/init.c-style errflag save/clear/check around the
2329        // parse. Process-sub argv extraction silently bails on syntax
2330        // errors (matches zsh's behavior when the inner command can't
2331        // be parsed).
2332        let saved_errflag = errflag.load(Ordering::Relaxed);
2333        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
2334        crate::ported::parse::parse_init(cmd_str);
2335        let prog = crate::ported::parse::parse();
2336        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
2337        errflag.store(saved_errflag, Ordering::Relaxed);
2338        if parse_failed {
2339            return Vec::new();
2340        }
2341        let first = match prog.lists.first() {
2342            Some(l) => l,
2343            None => return Vec::new(),
2344        };
2345        let pipe = &first.sublist.pipe;
2346        if let crate::parse::ZshCommand::Simple(simple) = &pipe.cmd {
2347            simple
2348                .words
2349                .iter()
2350                .map(|w| {
2351                    // Untokenize then variable-expand — text-based
2352                    // word expansion for the spawned argv.
2353                    let untoked = crate::lex::untokenize(w);
2354                    crate::ported::subst::singsub(&untoked)
2355                })
2356                .collect()
2357        } else {
2358            Vec::new()
2359        }
2360    }
2361
2362    pub(crate) fn run_process_sub_in(&mut self, cmd_str: &str) -> String {
2363
2364        // Phase 2: parse via parse_init+parse. Extract the first Simple cmd's
2365        // words (untokenized), pre-expand to argv strings, spawn.
2366        let words = self.simple_cmd_words(cmd_str);
2367
2368        // Create a unique FIFO in temp directory
2369        let fifo_path = format!("/tmp/zshrs_psub_{}", std::process::id());
2370        let fifo_counter = self.process_sub_counter;
2371        self.process_sub_counter += 1;
2372        let fifo_path = format!("{}_{}", fifo_path, fifo_counter);
2373
2374        // Remove if exists, then create FIFO
2375        let _ = fs::remove_file(&fifo_path);
2376        if nix::unistd::mkfifo(fifo_path.as_str(), nix::sys::stat::Mode::S_IRWXU).is_err() {
2377            return String::new();
2378        }
2379
2380        // Spawn command that writes to the FIFO
2381        let fifo_clone = fifo_path.clone();
2382        if !words.is_empty() {
2383            let cmd_name = words[0].clone();
2384            let args: Vec<String> = words[1..].to_vec();
2385
2386            self.worker_pool.submit(move || {
2387                // Open FIFO for writing (will block until reader connects)
2388                if let Ok(fifo) = fs::OpenOptions::new().write(true).open(&fifo_clone) {
2389                    let _ = Command::new(&cmd_name)
2390                        .args(&args)
2391                        .stdout(fifo)
2392                        .stderr(Stdio::inherit())
2393                        .status();
2394                }
2395                // Clean up FIFO after command completes
2396                let _ = fs::remove_file(&fifo_clone);
2397            });
2398        }
2399
2400        fifo_path
2401    }
2402
2403    pub(crate) fn run_process_sub_out(&mut self, cmd_str: &str) -> String {
2404
2405        let words = self.simple_cmd_words(cmd_str);
2406
2407        // Create a unique FIFO in temp directory
2408        let fifo_path = format!("/tmp/zshrs_psub_{}", std::process::id());
2409        let fifo_counter = self.process_sub_counter;
2410        self.process_sub_counter += 1;
2411        let fifo_path = format!("{}_{}", fifo_path, fifo_counter);
2412
2413        // Remove if exists, then create FIFO
2414        let _ = fs::remove_file(&fifo_path);
2415        if nix::unistd::mkfifo(fifo_path.as_str(), nix::sys::stat::Mode::S_IRWXU).is_err() {
2416            return String::new();
2417        }
2418
2419        // Spawn command that reads from the FIFO
2420        let fifo_clone = fifo_path.clone();
2421        if !words.is_empty() {
2422            let cmd_name = words[0].clone();
2423            let args: Vec<String> = words[1..].to_vec();
2424
2425            self.worker_pool.submit(move || {
2426                // Open FIFO for reading (will block until writer connects)
2427                if let Ok(fifo) = fs::File::open(&fifo_clone) {
2428                    let _ = Command::new(&cmd_name)
2429                        .args(&args)
2430                        .stdin(fifo)
2431                        .stdout(Stdio::inherit())
2432                        .stderr(Stdio::inherit())
2433                        .status();
2434                }
2435                // Clean up FIFO after command completes
2436                let _ = fs::remove_file(&fifo_clone);
2437            });
2438        }
2439
2440        fifo_path
2441    }
2442
2443    pub fn run_command_substitution(&mut self, cmd_str: &str) -> String {
2444        // `$(< FILE)` — zsh shorthand for "read FILE contents". Faster
2445        // than spawning `cat`. The leading `<` (after stripping
2446        // whitespace) means "read this file". Trailing newline is
2447        // stripped (same as command-substitution).
2448        let trimmed = cmd_str.trim_start();
2449        // Only treat as `$(<file)` shorthand when the SINGLE leading `<`
2450        // is followed by a filename, not another `<`. `$(<<<"hi" cat)`
2451        // starts with `<<<` (here-string) and must go through the full
2452        // parse path, not the read-file shortcut.
2453        if let Some(rest) = trimmed.strip_prefix('<').filter(|s| !s.starts_with('<')) {
2454            let filename = rest.trim();
2455            // Expand any leading $ / tilde in the filename so
2456            // `$(< $f)` and `$(< ~/x)` work.
2457            let resolved = if filename.contains('$') || filename.starts_with('~') {
2458                crate::ported::subst::singsub(filename)
2459            } else {
2460                filename.to_string()
2461            };
2462            let resolved = resolved.to_string();
2463            match std::fs::read_to_string(&resolved) {
2464                Ok(contents) => {
2465                    return contents.trim_end_matches('\n').to_string();
2466                }
2467                Err(_) => {
2468                    eprintln!("zshrs:1: no such file or directory: {}", resolved);
2469                    return String::new();
2470                }
2471            }
2472        }
2473
2474        // Port of getoutput(char *cmd, int qt) from Src/exec.c. Parse and compile via
2475        // the lex+parse free fns + ZshCompiler pipeline, run on a
2476        // sub-VM with the host wired up. Stdout is captured through
2477        // an in-process pipe via dup2 — no fork.
2478        //
2479        // This single path replaces the prior "internal vs external"
2480        // fast-path split: the sub-VM emits Op::Exec for unknown
2481        // command names, which forks/execs through the host.
2482
2483        // Set up the stdout-capture pipe. We dup the original stdout
2484        // so post-run we can restore it; the write end is dup2'd onto
2485        // STDOUT_FILENO so all output the sub-VM emits (including from
2486        // forked children, which inherit fd 1) lands in the pipe.
2487        let (read_fd, write_fd) = {
2488            let mut fds = [0i32; 2];
2489            if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
2490                return String::new();
2491            }
2492            (fds[0], fds[1])
2493        };
2494        let saved_stdout = unsafe { libc::dup(libc::STDOUT_FILENO) };
2495        if saved_stdout < 0 {
2496            unsafe {
2497                libc::close(read_fd);
2498                libc::close(write_fd);
2499            }
2500            return String::new();
2501        }
2502        unsafe {
2503            libc::dup2(write_fd, libc::STDOUT_FILENO);
2504            libc::close(write_fd);
2505        }
2506
2507        // Parse + compile + run.
2508        // Push CS_CMDSUBST for `%_` xtrace prefix — direct port of
2509        // Src/exec.c:4783 `cmdpush(CS_CMDSUBST);` around execode().
2510        // Trace lines emitted by the inner program inherit this token
2511        // so their PS4 prefix shows "cmdsubst" matching zsh -x.
2512        crate::ported::prompt::cmdpush(crate::ported::zsh_h::CS_CMDSUBST as u8); // c:zsh.h:2799
2513        // Save LINENO so the inner cmdsubst's line counter doesn't
2514        // leak into the outer trace — direct port of Src/exec.c:1407
2515        // `oldlineno = lineno;` followed by `lineno = oldlineno;`
2516        // restore at line 1640. Inner program parses fresh as line 1
2517        // and increments from there; once it returns, the outer
2518        // line at the `$(…)` site must read the original outer
2519        // lineno (so xtrace renders `+:5:> echo …` not `+:1:> …`).
2520        let saved_lineno = crate::ported::params::getsparam("LINENO");
2521        // Anchor the inner program's lineno to the outer's current
2522        // $LINENO so xtrace inside the cmdsubst renders the outer
2523        // line. zsh's execlist preserves lineno across the inner
2524        // exec — for our sub-VM (fresh compile) we use lineno_addend
2525        // to shift inner's line N → outer_lineno + (N - 1).
2526        let outer_lineno: u64 = self
2527            .scalar("LINENO")
2528            .and_then(|s| s.parse::<u64>().ok())
2529            .unwrap_or(0);
2530        // Mirror Src/init.c errflag save/clear/check pattern around
2531        // the nested parse so an inner syntax error doesn't bleed into
2532        // the outer execution.
2533        let saved_errflag = errflag.load(Ordering::Relaxed);
2534        errflag.fetch_and(!ERRFLAG_ERROR, Ordering::Relaxed);
2535        crate::ported::parse::parse_init(cmd_str);
2536        let parsed = crate::ported::parse::parse();
2537        let parse_failed = (errflag.load(Ordering::Relaxed) & ERRFLAG_ERROR) != 0;
2538        errflag.store(saved_errflag, Ordering::Relaxed);
2539        let prog = if parse_failed { None } else { Some(parsed) };
2540        let mut cmd_status: Option<i32> = None;
2541        if let Some(prog) = prog {
2542            let mut compiler = crate::compile_zsh::ZshCompiler::new();
2543            compiler.lineno_addend = outer_lineno.saturating_sub(1);
2544            let chunk = compiler.compile(&prog);
2545            if !chunk.ops.is_empty() {
2546                crate::fusevm_disasm::maybe_print_stdout("run_command_substitution", &chunk);
2547                let mut vm = fusevm::VM::new(chunk);
2548                register_builtins(&mut vm);
2549                vm.set_shell_host(Box::new(ZshrsHost));
2550                // Seed inner $? with the outer's last_status so the
2551                // sub-shell inherits the parent's exit code. Direct
2552                // port of Src/exec.c:4783 around execcmd_exec — the
2553                // child inherits `lastval` at fork time, so `false;
2554                // echo $(echo $?)` reads 1, not the freshly-zeroed
2555                // sub-VM default. Without this, every cmd-subst
2556                // started with $?==0 regardless of the parent's
2557                // last command.
2558                vm.last_status = self.last_status();
2559                let _ctx = ExecutorContext::enter(self);
2560                let _ = vm.run();
2561                cmd_status = Some(vm.last_status);
2562            }
2563        }
2564        // Restore LINENO so outer xtrace sees the outer line.
2565        if let Some(ln) = saved_lineno {
2566            self.set_scalar("LINENO".to_string(), ln);
2567        }
2568        crate::ported::prompt::cmdpop();
2569        // Propagate the inner cmd's status to the parent shell. zsh:
2570        // `a=$(false); echo $?` → 1 because cmd-subst status leaks to
2571        // $?. Set last_status on the executor so $? reads the right
2572        // value for callers that don't have a SetStatus(0) overwrite
2573        // (echo, test, etc.). Bare assignment paths still get the
2574        // SetStatus(0) from compile_simple — that's a separate gap.
2575        // Empty cmd-subst (`\`\``, `$()`) resets status to 0 per
2576        // Src/exec.c — the inner ran no command so the "last
2577        // command's exit" is the implicit success of "did nothing".
2578        // Without this branch, a prior command's non-zero status
2579        // leaked through the empty cmd-subst.
2580        if let Some(status) = cmd_status {
2581            self.set_last_status(status);
2582        } else {
2583            self.set_last_status(0);
2584        }
2585
2586        // Flush any buffered Rust-side stdout so it reaches the pipe
2587        // before we restore.
2588        let _ = io::stdout().flush();
2589
2590        // Restore stdout and read what was captured.
2591        unsafe {
2592            libc::dup2(saved_stdout, libc::STDOUT_FILENO);
2593            libc::close(saved_stdout);
2594        }
2595        let read_file = unsafe { std::fs::File::from_raw_fd(read_fd) };
2596        let mut output = String::new();
2597        let _ = std::io::BufReader::new(read_file).read_to_string(&mut output);
2598
2599        // POSIX: trailing newlines stripped from cmd-sub result.
2600        while output.ends_with('\n') {
2601            output.pop();
2602        }
2603        output
2604    }
2605
2606    // ksh_autoload_body moved to src/ported/builtin.rs
2607}
2608
2609impl Default for ShellExecutor {
2610    fn default() -> Self {
2611        Self::new()
2612    }
2613}
2614
2615#[cfg(test)]
2616mod tests {
2617    use super::*;
2618
2619    #[test]
2620    fn test_simple_echo() {
2621        let mut exec = ShellExecutor::new();
2622        let status = exec.execute_script("true").unwrap();
2623        assert_eq!(status, 0);
2624    }
2625
2626    #[test]
2627    fn test_if_true() {
2628        let mut exec = ShellExecutor::new();
2629        let status = exec.execute_script("if true; then true; fi").unwrap();
2630        assert_eq!(status, 0);
2631    }
2632
2633    #[test]
2634    fn test_if_false() {
2635        let mut exec = ShellExecutor::new();
2636        let status = exec
2637            .execute_script("if false; then true; else false; fi")
2638            .unwrap();
2639        assert_eq!(status, 1);
2640    }
2641
2642    #[test]
2643    fn test_for_loop() {
2644        let mut exec = ShellExecutor::new();
2645        exec.execute_script("for i in a b c; do true; done")
2646            .unwrap();
2647        assert_eq!(exec.last_status(), 0);
2648    }
2649
2650    #[test]
2651    fn test_and_list() {
2652        let mut exec = ShellExecutor::new();
2653        let status = exec.execute_script("true && true").unwrap();
2654        assert_eq!(status, 0);
2655
2656        let status = exec.execute_script("true && false").unwrap();
2657        assert_eq!(status, 1);
2658    }
2659
2660    #[test]
2661    fn test_or_list() {
2662        let mut exec = ShellExecutor::new();
2663        let status = exec.execute_script("false || true").unwrap();
2664        assert_eq!(status, 0);
2665    }
2666
2667    /// Pin: `forklevel` matches the C global declared at
2668    /// `Src/exec.c:1052` (`int forklevel;`). Like `int` in C, the
2669    /// Rust port is an AtomicI32 starting at 0 (no fork has occurred
2670    /// at process start). Per `Src/exec.c:1221` (`forklevel =
2671    /// locallevel;`), every subshell entry copies `locallevel` into
2672    /// the global; the SIGPIPE handler at `Src/signals.c:808` reads
2673    /// it back to distinguish the top-level shell from a subshell.
2674    #[test]
2675    fn test_forklevel_default_zero_and_roundtrip() {
2676        use std::sync::atomic::Ordering;
2677        let prev = FORKLEVEL.load(Ordering::Relaxed);
2678        // Default state at process start: zero (matches C's BSS init
2679        // of `int forklevel;` to 0).
2680        FORKLEVEL.store(0, Ordering::Relaxed);
2681        assert_eq!(FORKLEVEL.load(Ordering::Relaxed), 0);
2682        // Simulate the c:1221 store: `forklevel = locallevel;`.
2683        FORKLEVEL.store(3, Ordering::Relaxed);
2684        assert_eq!(FORKLEVEL.load(Ordering::Relaxed), 3);
2685        FORKLEVEL.store(prev, Ordering::Relaxed);
2686    }
2687}
2688
2689// Plugin-Framework-Agnostic State-Modification Recorder hook helpers.
2690/// Recorder helper: emit one record for an array/scalar mutation
2691/// targeting a path-family parameter (path/fpath/manpath/module_path/
2692/// cdpath, lower- or upper-cased), or one `assign` record for any
2693/// other name. Centralises the path-family list so `BUILTIN_SET_ARRAY`,
2694/// `BUILTIN_APPEND_ARRAY`, and `BUILTIN_APPEND_SCALAR_OR_PUSH` share
2695/// the same routing.
2696///
2697/// `is_append` distinguishes `arr=(...)` from `arr+=(...)` so the
2698/// emitted event carries the APPEND attr bit and replay can choose
2699/// between fresh-set and extend semantics.
2700///
2701/// `attrs` carries any pre-existing type info from
2702/// `recorder_attrs_for(name)` (readonly/export/global) — array shape
2703/// and APPEND get OR'd in by emit_array_assign.
2704#[cfg(feature = "recorder")]
2705pub(crate) fn emit_path_or_assign(
2706    name: &str,
2707    values: &[String],
2708    attrs: crate::recorder::ParamAttrs,
2709    is_append: bool,
2710    ctx: &crate::recorder::RecordCtx,
2711) {
2712    let lower = name.to_ascii_lowercase();
2713    let kind_name: Option<&'static str> = match lower.as_str() {
2714        "path" => Some("path"),
2715        "fpath" => Some("fpath"),
2716        "manpath" => Some("manpath"),
2717        "module_path" => Some("module_path"),
2718        "cdpath" => Some("cdpath"),
2719        _ => None,
2720    };
2721    match kind_name {
2722        Some(k) => {
2723            for v in values {
2724                crate::recorder::emit_path_mod(v, k, ctx.clone());
2725                // Each fpath addition also surfaces every `_completion`
2726                // file inside the directory — matches zinit-report's
2727                // per-plugin "Completions:" listing. Only fpath dirs
2728                // get this treatment; PATH dirs hold executables, not
2729                // completion functions.
2730                if k == "fpath" {
2731                    crate::recorder::discover_completions_in_fpath_dir(v, ctx);
2732                }
2733            }
2734        }
2735        None => {
2736            // Non-path arrays: emit ONE `assign` event with the
2737            // ordered element list preserved in value_array. Replay
2738            // reconstructs `name=(elem1 elem2 ...)` exactly without
2739            // having to re-split a joined string.
2740            crate::recorder::emit_array_assign(
2741                name,
2742                values.to_vec(),
2743                attrs,
2744                is_append,
2745                ctx.clone(),
2746            );
2747        }
2748    }
2749}
2750
2751// Whole impl block is `#[cfg(feature = "recorder")]` so the default
2752// build sees no recorder symbols on `ShellExecutor`.
2753#[cfg(feature = "recorder")]
2754impl ShellExecutor {}
2755
2756#[cfg(feature = "recorder")]
2757impl ShellExecutor {}
2758
2759impl ShellExecutor {
2760    /// `add-zsh-hook` registration stub. The hooks-machinery file was
2761    /// removed from extensions/; these no-ops preserve the call sites
2762    /// in `ext_builtins.rs::builtin_add_zsh_hook` and `plugin_cache.rs`.
2763    pub(crate) fn add_hook(&mut self, _hook: &str, _func: &str) {}
2764
2765    /// `add-zsh-hook -d` removal — same stub rationale as `add_hook`.
2766    pub(crate) fn delete_hook(&mut self, _hook: &str, _func: &str) {}
2767
2768    // ═══════════════════════════════════════════════════════════════════
2769    // AOP INTERCEPT — the killer builtin
2770    // ═══════════════════════════════════════════════════════════════════
2771
2772    // ═══════════════════════════════════════════════════════════════════
2773    // CONCURRENT PRIMITIVES — ship work to the worker pool from shell
2774    // No stryke dependency. Pure zshrs. Thin binary gets full parallelism.
2775    // ═══════════════════════════════════════════════════════════════════
2776}
2777
2778
2779impl ShellExecutor {
2780    // ═══════════════════════════════════════════════════════════════════════════
2781    // Additional zsh builtins
2782    // ═══════════════════════════════════════════════════════════════════════════
2783
2784    /// Helper to check if name is a builtin. Consults the canonical
2785    /// `BUILTINS` table (`src/ported/builtin.rs:122`, the 1:1 port of
2786    /// `static struct builtin builtins[]` at `Src/builtin.c:40-137`).
2787    /// Earlier implementation hardcoded a separate `BUILTIN_SET`
2788    /// HashSet of 130+ names — duplicated state that drifts when new
2789    /// builtins land in the canonical table. The cached lookup set
2790    /// below is built once from `BUILTINS` so the O(1) cost stays
2791    /// without a separate authoritative list.
2792    pub(crate) fn is_builtin(&self, name: &str) -> bool {
2793        BUILTIN_NAMES.contains(name) || name.starts_with('_')
2794    }
2795
2796    /// Helper to find command in PATH. The fast path consults the
2797    /// `command_hash` table (rebuilt by `rehash` per `Src/Modules/
2798    /// hashed.c`); the slow path delegates to the canonical port of
2799    /// `findcmd()` (`Src/exec.c:5260`, ported at
2800    /// `src/ported/builtin.rs:4047`). Earlier inline PATH walk
2801    /// duplicated findcmd's logic without honoring `name.contains('/')`
2802    /// (the C source returns the literal path for slashed names
2803    /// without walking $PATH).
2804    pub(crate) fn find_in_path(&self, name: &str) -> Option<String> {
2805        // Canonical command-hash lives in `cmdnamtab`. `get_full_path`
2806        // returns the resolved path for HASHED entries.
2807        if let Some(p) = crate::ported::hashtable::cmdnamtab_lock()
2808            .read().ok()
2809            .and_then(|t| t.get_full_path(name))
2810        {
2811            return Some(p.display().to_string());
2812        }
2813        crate::ported::builtin::findcmd(name, 0, 0)                          // c:exec.c:5260
2814    }
2815
2816    // ═══════════════════════════════════════════════════════════════════════════
2817    // zsh module builtins
2818    // ═══════════════════════════════════════════════════════════════════════════
2819
2820    // =========================================================================
2821    // Process control functions - Port from exec.c
2822    // =========================================================================
2823
2824    /// Fork a new process
2825    /// Port of zfork(struct timespec *ts) from exec.c
2826    pub fn zfork(&mut self, flags: ForkFlags) -> std::io::Result<ForkResult> {
2827        // Check for job control
2828        let can_background = crate::ported::options::opt_state_get("monitor").unwrap_or(false);
2829
2830        unsafe {
2831            match libc::fork() {
2832                -1 => Err(std::io::Error::last_os_error()),
2833                0 => {
2834                    // Child process
2835                    if !flags.contains(ForkFlags::NOJOB) && can_background {
2836                        // Set up job control
2837                        let pid = libc::getpid();
2838                        if flags.contains(ForkFlags::NEWGRP) {
2839                            libc::setpgid(0, 0);
2840                        }
2841                        if flags.contains(ForkFlags::FGTTY) {
2842                            libc::tcsetpgrp(0, pid);
2843                        }
2844                    }
2845
2846                    // Reset signal handlers
2847                    if !flags.contains(ForkFlags::KEEPSIGS) {
2848                        self.reset_signals();
2849                    }
2850
2851                    Ok(ForkResult::Child)
2852                }
2853                pid => {
2854                    // Parent process
2855                    if !flags.contains(ForkFlags::NOJOB) {
2856                        // Add to job table
2857                        self.add_child_process(pid);
2858                    }
2859                    Ok(ForkResult::Parent(pid))
2860                }
2861            }
2862        }
2863    }
2864
2865    /// Add a child process to tracking
2866    fn add_child_process(&mut self, pid: i32) {
2867        // Would track in job table
2868        self.set_scalar("!".to_string(), pid.to_string());
2869    }
2870
2871    /// Reset signal handlers to defaults
2872    fn reset_signals(&self) {
2873        unsafe {
2874            libc::signal(libc::SIGINT, libc::SIG_DFL);
2875            libc::signal(libc::SIGQUIT, libc::SIG_DFL);
2876            libc::signal(libc::SIGTERM, libc::SIG_DFL);
2877            libc::signal(libc::SIGTSTP, libc::SIG_DFL);
2878            libc::signal(libc::SIGTTIN, libc::SIG_DFL);
2879            libc::signal(libc::SIGTTOU, libc::SIG_DFL);
2880            libc::signal(libc::SIGCHLD, libc::SIG_DFL);
2881        }
2882    }
2883
2884    /// Execute a command in the current process (exec family)
2885    /// Port of zexecve(char *pth, char **argv, char **newenvp) from exec.c
2886    pub fn zexecve(&self, cmd: &str, args: &[String]) -> ! {
2887
2888        let c_cmd = CString::new(cmd).expect("CString::new failed");
2889
2890        // Build argv
2891        let c_args: Vec<CString> = std::iter::once(c_cmd.clone())
2892            .chain(args.iter().map(|s| CString::new(s.as_str()).unwrap()))
2893            .collect();
2894
2895        let c_argv: Vec<*const libc::c_char> = c_args
2896            .iter()
2897            .map(|s| s.as_ptr())
2898            .chain(std::iter::once(std::ptr::null()))
2899            .collect();
2900
2901        // Build envp from current environment
2902        let env_vars: Vec<CString> = std::env::vars()
2903            .map(|(k, v)| CString::new(format!("{}={}", k, v)).unwrap())
2904            .collect();
2905
2906        let c_envp: Vec<*const libc::c_char> = env_vars
2907            .iter()
2908            .map(|s| s.as_ptr())
2909            .chain(std::iter::once(std::ptr::null()))
2910            .collect();
2911
2912        unsafe {
2913            libc::execve(c_cmd.as_ptr(), c_argv.as_ptr(), c_envp.as_ptr());
2914            // If we get here, exec failed
2915            eprintln!(
2916                "zshrs: exec failed: {}: {}",
2917                cmd,
2918                std::io::Error::last_os_error()
2919            );
2920            std::process::exit(127);
2921        }
2922    }
2923
2924    /// Enter a subshell
2925    /// Port of entersubsh(int flags, struct entersubsh_ret *retp) from exec.c
2926    pub fn entersubsh(&mut self, flags: SubshellFlags) {
2927        // Increment subshell level
2928        let level = self
2929            .get_variable("ZSH_SUBSHELL")
2930            .parse::<i32>()
2931            .unwrap_or(0);
2932        self.set_scalar("ZSH_SUBSHELL".to_string(), (level + 1).to_string());
2933
2934        // Handle job control
2935        if flags.contains(SubshellFlags::NOMONITOR) {
2936            crate::ported::options::opt_state_set("monitor", false);
2937        }
2938
2939        // Close unneeded fds
2940        if !flags.contains(SubshellFlags::KEEPFDS) {
2941            self.close_extra_fds();
2942        }
2943
2944        // Reset traps
2945        if !flags.contains(SubshellFlags::KEEPTRAPS) {
2946            self.reset_traps();
2947        }
2948
2949        // c:1221 — `forklevel = locallevel;` at the tail of entersubsh.
2950        // Records the locallevel depth at this subshell entry so the
2951        // SIGPIPE handler (signals.c:808) and the WARNCREATEGLOBAL
2952        // nest-depth check (params.c:3724) can distinguish parent vs
2953        // child shell contexts.
2954        let cur_local = crate::ported::params::locallevel
2955            .load(std::sync::atomic::Ordering::Relaxed);
2956        FORKLEVEL.store(cur_local, std::sync::atomic::Ordering::Relaxed);     // c:1221 (Src/exec.c)
2957    }
2958
2959    /// Close extra file descriptors
2960    fn close_extra_fds(&self) {
2961        // Close fds > 10 (common shell convention)
2962        for fd in 10..256 {
2963            unsafe {
2964                libc::close(fd);
2965            }
2966        }
2967    }
2968
2969    fn reset_traps(&mut self) {
2970        self.traps.clear();
2971    }
2972
2973
2974    // ═══════════════════════════════════════════════════════════════════════
2975    // Coreutils builtins (anti-fork) — only active when !posix_mode
2976    // ═══════════════════════════════════════════════════════════════════════
2977
2978    // nproc-equivalent already exists via builtin_nproc.
2979}
2980
2981use std::os::unix::fs::MetadataExt;
2982
2983bitflags::bitflags! {
2984    /// Flags for zfork()
2985    #[derive(Debug, Clone, Copy, Default)]
2986    pub struct ForkFlags: u32 {
2987        const NOJOB = 1 << 0;    // Don't add to job table
2988        const NEWGRP = 1 << 1;   // Create new process group
2989        const FGTTY = 1 << 2;    // Take foreground terminal
2990        const KEEPSIGS = 1 << 3; // Keep signal handlers
2991    }
2992}
2993
2994bitflags::bitflags! {
2995    /// Flags for entersubsh()
2996    #[derive(Debug, Clone, Copy, Default)]
2997    pub struct SubshellFlags: u32 {
2998        const NOMONITOR = 1 << 0; // Disable job control
2999        const KEEPFDS = 1 << 1;   // Keep file descriptors
3000        const KEEPTRAPS = 1 << 2; // Keep trap handlers
3001    }
3002}
3003
3004/// Result of fork operation
3005#[derive(Debug)]
3006/// `fork()` outcome (parent / child / error).
3007/// Mirrors the integer return of `zfork()` from Src/exec.c:349.
3008pub enum ForkResult {
3009    Parent(i32), // Contains child PID
3010    Child,
3011}
3012
3013/// Redirection mode
3014#[derive(Debug, Clone, Copy)]
3015/// File-redirection mode (`>` / `>>` / `<` / etc.).
3016/// Mirrors the `REDIR_*` enum from Src/zsh.h.
3017pub enum RedirMode {
3018    Dup,
3019    Close,
3020}
3021
3022/// Builtin command type
3023#[derive(Debug, Clone, Copy)]
3024/// Builtin classification.
3025/// Mirrors the `BINF_*` flag set Src/builtin.c uses to
3026/// classify special vs regular builtins.
3027pub enum BuiltinType {
3028    Normal,
3029    Disabled,
3030}
3031
3032// =====================================================================
3033// Builtin dispatch stubs.
3034//
3035// These methods used to live in `src/ported/builtin.rs` inside
3036// `impl ShellExecutor` blocks. Per user feedback ("each of those
3037// bin_* is fake anyways"), the impl blocks were deleted from the
3038// port tree. The methods are recreated here as stubs so existing
3039// callers (fusevm_bridge, ext_builtins, exec.rs's own dispatch loop)
3040// keep compiling. Each stub delegates to the canonical free-fn port
3041// at `crate::ported::builtin::bin_X` when one exists, or returns 0.
3042//
3043// The recorder hooks the original methods carried are preserved as
3044// commented snippets at the bottom of `src/ported/builtin.rs` —
3045// they will be re-wired here once the canonical bin_* ports are
3046// true to C.
3047// =====================================================================
3048#[allow(unused_variables, dead_code)]
3049impl ShellExecutor {
3050    fn _empty_ops() -> crate::ported::zsh_h::options {
3051        options { ind: [0u8; MAX_OPS], args: Vec::new(),
3052                  argscount: 0, argsalloc: 0 }
3053    }
3054
3055    pub(crate) fn dispatch_pending_traps(&mut self) {}
3056    // Note: `recorder_ctx()` lives in src/extensions/recorder.rs
3057    // (gated behind --features recorder). Do not stub it here or
3058    // you'll get a duplicate-definition error when the recorder
3059    // feature is enabled.
3060
3061    pub(crate) fn builtin_pwd_with_args(&mut self, args: &[String]) -> i32 {
3062        let ops = Self::_empty_ops();
3063        crate::ported::builtin::bin_pwd("pwd", args, &ops, 0)
3064    }
3065    pub(crate) fn bin_zcompile(&mut self, args: &[String]) -> i32 {
3066        // C-faithful dispatch via the canonical port in
3067        // `src/ported/parse.rs::bin_zcompile` (port of `Src/parse.c:3180`).
3068        // The caller's argv is the full builtin command line, e.g.
3069        // `["zcompile", "-t", "/path/file.zwc"]`. Parse option chars
3070        // into an `options` ind[] bitmap and pass positionals.
3071        let mut ops = options {
3072            ind: [0u8; MAX_OPS],
3073            args: Vec::new(),
3074            argscount: 0,
3075            argsalloc: 0,
3076        };
3077        let mut positional: Vec<String> = Vec::new();
3078        let mut consume_opts = true;
3079        // `args` from pop_args holds positional + option words only —
3080        // no leading builtin name (matches what `bin_*` entry points
3081        // get from C's `bin_X(char *nam, char **args, ...)` arg vector).
3082        for a in args.iter() {
3083            if consume_opts && a == "--" { consume_opts = false; continue; }
3084            if consume_opts && a.starts_with('-') && a.len() > 1 {
3085                for c in a.bytes().skip(1) {
3086                    let idx = c as usize;
3087                    if idx < MAX_OPS {
3088                        ops.ind[idx] |= 1; // bit 1 = OPT_MINUS (set as `-X`)
3089                    }
3090                }
3091                continue;
3092            }
3093            consume_opts = false;
3094            positional.push(a.clone());
3095        }
3096        crate::ported::parse::bin_zcompile("zcompile", &positional, &ops, 0)
3097    }
3098
3099    // zsh implements echo/printf via funcid dispatch into shared
3100    // `bin_print` (Src/builtin.c:4587 — BIN_ECHO and BIN_PRINTF
3101    // funcids route through the same handler). Route through
3102    // `execbuiltin` (Src/builtin.c:250) so flag parsing matches C
3103    // — earlier impl was `println!("{}", args.join(" "))` which
3104    // ignored `-n` / `-e` flags and didn't interpret escape
3105    // sequences.
3106    pub(crate) fn builtin_echo(&mut self, args: &[String], _redir: &[crate::parse::Redirect]) -> i32 {
3107        let bn_idx = crate::ported::builtin::BUILTINS.iter()
3108            .position(|b| b.node.nam == "echo");
3109        match bn_idx {
3110            Some(idx) => {
3111                let bn_static: &'static crate::ported::zsh_h::builtin =
3112                    &crate::ported::builtin::BUILTINS[idx];
3113                let bn_ptr = bn_static as *const _ as *mut _;
3114                crate::ported::builtin::execbuiltin(args.to_vec(), Vec::new(), bn_ptr)
3115            }
3116            None => {
3117                let ops = Self::_empty_ops();
3118                crate::ported::builtin::bin_print("echo", args, &ops,
3119                    crate::ported::builtin::BIN_ECHO)
3120            }
3121        }
3122    }
3123    pub(crate) fn builtin_printf(&mut self, args: &[String]) -> i32 {
3124        let bn_idx = crate::ported::builtin::BUILTINS.iter()
3125            .position(|b| b.node.nam == "printf");
3126        match bn_idx {
3127            Some(idx) => {
3128                let bn_static: &'static crate::ported::zsh_h::builtin =
3129                    &crate::ported::builtin::BUILTINS[idx];
3130                let bn_ptr = bn_static as *const _ as *mut _;
3131                crate::ported::builtin::execbuiltin(args.to_vec(), Vec::new(), bn_ptr)
3132            }
3133            None => {
3134                let ops = Self::_empty_ops();
3135                crate::ported::builtin::bin_print("printf", args, &ops,
3136                    crate::ported::builtin::BIN_PRINTF)
3137            }
3138        }
3139    }
3140    pub(crate) fn builtin_local(&mut self, args: &[String]) -> i32 {
3141        self.dispatch_typeset_as("local", args)
3142    }
3143    pub(crate) fn builtin_export(&mut self, args: &[String]) -> i32 {
3144        self.dispatch_typeset_as("export", args)
3145    }
3146    pub(crate) fn builtin_readonly(&mut self, args: &[String]) -> i32 {
3147        self.dispatch_typeset_as("readonly", args)
3148    }
3149    pub(crate) fn builtin_declare(&mut self, args: &[String]) -> i32 {
3150        self.dispatch_typeset_as("declare", args)
3151    }
3152    pub(crate) fn builtin_typeset_named(&mut self, name: &str, args: &[String]) -> i32 {
3153        self.dispatch_typeset_as(name, args)
3154    }
3155    pub(crate) fn builtin_integer(&mut self, args: &[String]) -> i32 {
3156        self.dispatch_typeset_as("integer", args)
3157    }
3158    pub(crate) fn builtin_float(&mut self, args: &[String]) -> i32 {
3159        self.dispatch_typeset_as("float", args)
3160    }
3161    /// Route `local`/`export`/`readonly`/`declare`/`integer`/`float`
3162    /// through canonical `bin_typeset` via `execbuiltin` so the
3163    /// BUILTIN("…") entry's optstr is parsed and the `nm0` char in
3164    /// `bin_typeset` picks up the PM_LOCAL implicit-scope path
3165    /// (`l` → local, `r`+func → PM_READONLY etc.). The old stub
3166    /// only `env::set_var`-d which gave no local scoping or
3167    /// type-flag effect.
3168    fn dispatch_typeset_as(&mut self, name: &str, args: &[String]) -> i32 {
3169        let bn_idx = crate::ported::builtin::BUILTINS.iter()
3170            .position(|b| b.node.nam == name);
3171        if let Some(idx) = bn_idx {
3172            let bn_static: &'static crate::ported::zsh_h::builtin =
3173                &crate::ported::builtin::BUILTINS[idx];
3174            let bn_ptr = bn_static as *const _ as *mut _;
3175            return crate::ported::builtin::execbuiltin(args.to_vec(), Vec::new(), bn_ptr);
3176        }
3177        // Fallback: best-effort env mirror.
3178        for a in args {
3179            if let Some(eq) = a.find('=') {
3180                std::env::set_var(&a[..eq], &a[eq+1..]);
3181            }
3182        }
3183        0
3184    }
3185    // Stubs deleted — these were leftover from an old zshrs port aligned to
3186    // `Src/exec.c` before fusevm replaced that path. Each was a `{ 0 }` / `{ None }` / `{ false }` no-op
3187    // pretending to implement a builtin while silently succeeding.
3188    // Removed: builtin_pushd, builtin_popd, builtin_history,
3189    // builtin_unalias, builtin_unfunction, builtin_autoload,
3190    // builtin_command, builtin_builtin, builtin_exec, builtin_noglob,
3191    // builtin_type, builtin_which, builtin_where, builtin_r,
3192    // builtin_getln, builtin_pushln, builtin_source_named,
3193    // autoload_function, maybe_autoload, find_function_file,
3194    // ksh_autoload_body, findcmd (shadow), get_builtin_names.
3195    // Callers must route to canonical ports in `src/ported/builtin.rs`
3196    // or fail loudly so the gap is visible.
3197
3198    // zutil.rs orphan stubs — moved here after `impl ShellExecutor`
3199    // was ripped out of src/ported/modules/zutil.rs. The canonical
3200    // bin_zstyle/bin_zparseopts/bin_zformat/bin_zregexparse free-fn
3201    // ports (Src/Modules/zutil.c) will land in zutil.rs at module
3202    // level; until then these stubs unblock callers.
3203}
3204
3205// === DISSOLVED FROM src/exec_shims.rs ===
3206use crate::ported::utils::{zwarn, zwarnnam, zerr, zerrnam};
3207use crate::ported::params::*;
3208use crate::ported::options::*;
3209use crate::ported::hist::*;
3210use crate::ported::pattern::*;
3211use crate::ported::prompt::*;
3212use crate::ported::subst::*;
3213use crate::ported::math::*;
3214use crate::ported::jobs::*;
3215use crate::ported::glob::*;
3216use crate::ported::module::*;
3217use crate::ported::signals::*;
3218use crate::ported::modules::cap::*;
3219use crate::ported::modules::tcp::bin_ztcp;
3220use crate::ported::modules::termcap::bin_echotc;
3221use crate::ported::modules::terminfo::*;
3222use crate::fusevm_bridge::with_executor;
3223use ::regex::{Regex, RegexBuilder, Error as RegexError};
3224
3225// =====================================================================
3226// MOVED FROM: src/ported/pattern.rs
3227// =====================================================================
3228
3229impl crate::ported::exec::ShellExecutor {
3230    /// Check if pattern contains extended glob syntax
3231    pub(crate) fn has_extglob_pattern(&self, pattern: &str) -> bool {
3232        let chars: Vec<char> = pattern.chars().collect();
3233        for i in 0..chars.len().saturating_sub(1) {
3234            if (chars[i] == '?'
3235                || chars[i] == '*'
3236                || chars[i] == '+'
3237                || chars[i] == '@'
3238                || chars[i] == '!')
3239                && chars[i + 1] == '('
3240            {
3241                return true;
3242            }
3243        }
3244        false
3245    }
3246    /// Extract the inner part of an extglob pattern (until closing paren)
3247    pub(crate) fn extract_extglob_inner(&self, chars: &[char], start: usize) -> (String, usize) {
3248        let mut inner = String::new();
3249        let mut depth = 1;
3250        let mut i = start;
3251
3252        while i < chars.len() && depth > 0 {
3253            if chars[i] == '(' {
3254                depth += 1;
3255            } else if chars[i] == ')' {
3256                depth -= 1;
3257                if depth == 0 {
3258                    return (inner, i);
3259                }
3260            }
3261            inner.push(chars[i]);
3262            i += 1;
3263        }
3264
3265        (inner, i)
3266    }
3267    /// Convert the inner part of extglob (handles | for alternation)
3268    pub(crate) fn extglob_inner_to_regex(&self, inner: &str) -> String {
3269        // Split by | and convert each alternative
3270        let alternatives: Vec<String> = inner
3271            .split('|')
3272            .map(|alt| {
3273                let mut result = String::new();
3274                for c in alt.chars() {
3275                    match c {
3276                        '*' => result.push_str(".*"),
3277                        '?' => result.push('.'),
3278                        '.' => result.push_str("\\."),
3279                        '^' | '$' | '(' | ')' | '{' | '}' | '\\' => {
3280                            result.push('\\');
3281                            result.push(c);
3282                        }
3283                        _ => result.push(c),
3284                    }
3285                }
3286                result
3287            })
3288            .collect();
3289
3290        alternatives.join("|")
3291    }
3292}
3293
3294
3295// =====================================================================
3296// MOVED FROM: src/ported/options.rs
3297// =====================================================================
3298
3299impl crate::ported::exec::ShellExecutor {
3300    /// Returns every option name in `ZSH_OPTIONS_SET` (canonical port
3301    /// of `optns[]` at `Src/options.c:79+`). Replaces a 200-line
3302    /// hardcoded `&[...]` duplicate that drifted from upstream.
3303    pub(crate) fn all_zsh_options() -> Vec<&'static str> {
3304        crate::ported::options::ZSH_OPTIONS_SET.iter().copied().collect()
3305    }
3306    pub(crate) fn default_options() -> HashMap<String, bool> {
3307        let mut opts = HashMap::new();
3308        // Initialize all options to false first
3309        for opt in Self::all_zsh_options() {
3310            opts.insert(opt.to_string(), false);
3311        }
3312        // Set zsh defaults (options marked with <D> or <Z> in zshoptions man page)
3313        let defaults_on = [
3314            "aliases",
3315            "alwayslastprompt",
3316            "appendhistory",
3317            "autolist",
3318            "automenu",
3319            "autoparamkeys",
3320            "autoparamslash",
3321            "autoremoveslash",
3322            "badpattern",
3323            "banghist",
3324            "bareglobqual",
3325            "beep",
3326            "bgnice",
3327            "caseglob",
3328            "casematch",
3329            "checkjobs",
3330            "checkrunningjobs",
3331            "clobber",
3332            "debugbeforecmd",
3333            "equals",
3334            "evallineno",
3335            "exec",
3336            "flowcontrol",
3337            "functionargzero",
3338            "glob",
3339            "globalexport",
3340            "globalrcs",
3341            "hashcmds",
3342            "hashdirs",
3343            "hashlistall",
3344            "histbeep",
3345            "histsavebycopy",
3346            "hup",
3347            // INTERACTIVE is NOT default-on in zsh — C init sets it
3348            // from isatty(0). Marking it default-on here makes the
3349            // lexer's `!interact() || unset(SHINSTDIN)` comment gate
3350            // (lex.c:678) false for non-tty script runs, so every
3351            // `#`-line was re-lexed as a command. Drop from defaults;
3352            // tty-driven init can flip it back on for real terminals.
3353            "listambiguous",
3354            "listbeep",
3355            "listtypes",
3356            "monitor",
3357            "multibyte",
3358            "multifuncdef",
3359            "multios",
3360            "nomatch",
3361            "notify",
3362            "promptcr",
3363            "promptpercent",
3364            "promptsp",
3365            "rcs",
3366            // SHINSTDIN — same story. Default off; init sets when
3367            // stdin is the real interactive source.
3368            "shortloops",
3369            "unset",
3370            "zle",
3371        ];
3372        for opt in defaults_on {
3373            opts.insert(opt.to_string(), true);
3374        }
3375        opts
3376    }
3377
3378    pub(crate) fn default_on_options() -> &'static [&'static str] {
3379        &[
3380            "aliases",
3381            "alwayslastprompt",
3382            "appendhistory",
3383            "autolist",
3384            "automenu",
3385            "autoparamkeys",
3386            "autoparamslash",
3387            "autoremoveslash",
3388            "badpattern",
3389            "banghist",
3390            "bareglobqual",
3391            "beep",
3392            "bgnice",
3393            "caseglob",
3394            "casematch",
3395            "checkjobs",
3396            "checkrunningjobs",
3397            "clobber",
3398            "debugbeforecmd",
3399            "equals",
3400            "evallineno",
3401            "exec",
3402            "flowcontrol",
3403            "functionargzero",
3404            "glob",
3405            "globalexport",
3406            "globalrcs",
3407            "hashcmds",
3408            "hashdirs",
3409            "hashlistall",
3410            "histbeep",
3411            "histsavebycopy",
3412            "hup",
3413            // INTERACTIVE / SHINSTDIN intentionally absent — see the
3414            // matching note in `default_options()` above. C init
3415            // computes these from isatty(0), not from the static
3416            // emulation-default table.
3417            "listambiguous",
3418            "listbeep",
3419            "listtypes",
3420            "monitor",
3421            "multibyte",
3422            "multifuncdef",
3423            "multios",
3424            "nomatch",
3425            "notify",
3426            "promptcr",
3427            "promptpercent",
3428            "promptsp",
3429            "rcs",
3430            "shortloops",
3431            "unset",
3432            "zle",
3433        ]
3434    }
3435}
3436
3437// =====================================================================
3438// MOVED FROM: src/ported/options.rs
3439// =====================================================================
3440
3441impl crate::ported::exec::ShellExecutor {
3442
3443}
3444
3445// =====================================================================
3446// MOVED FROM: src/ported/params.rs
3447// =====================================================================
3448
3449impl crate::ported::exec::ShellExecutor {
3450    /// Parse subscript range like "1" or "1,5" or "-1" or "1,-1"
3451    pub(crate) fn parse_subscript_range(&self, s: &str, len: usize) -> Option<(usize, usize)> {
3452        if s.is_empty() || len == 0 {
3453            return None;
3454        }
3455
3456        let parts: Vec<&str> = s.split(',').collect();
3457
3458        let parse_idx = |idx_str: &str| -> Option<usize> {
3459            let idx: i64 = idx_str.trim().parse().ok()?;
3460            if idx < 0 {
3461                // Negative index from end
3462                let abs = (-idx) as usize;
3463                if abs > len {
3464                    None
3465                } else {
3466                    Some(len - abs)
3467                }
3468            } else if idx == 0 {
3469                Some(0)
3470            } else {
3471                // 1-indexed
3472                Some((idx as usize).saturating_sub(1).min(len))
3473            }
3474        };
3475
3476        match parts.len() {
3477            1 => {
3478                // Single element [n]
3479                let idx = parse_idx(parts[0])?;
3480                Some((idx, idx + 1))
3481            }
3482            2 => {
3483                // Range [n,m]
3484                let start = parse_idx(parts[0])?;
3485                let end = parse_idx(parts[1])?.saturating_add(1);
3486                Some((start.min(end), start.max(end)))
3487            }
3488            _ => None,
3489        }
3490    }
3491    /// Get value from zsh/parameter special arrays (options, commands, functions, etc.)
3492    /// Returns Some(value) if this is a special array access, None otherwise
3493    pub fn get_special_array_value(&self, array_name: &str, key: &str) -> Option<String> {
3494        match array_name {
3495            // === ZSH/MAPFILE module ===
3496            // `${mapfile[/path]}` reads the file's contents. Direct
3497            // port of `getpmmapfile(UNUSED(HashTable ht), const char *name)` (Src/Modules/mapfile.c:217)
3498            // which calls `get_contents()` (line 167) on the path.
3499            // Splice (`@`/`*`) returns the CWD entry list per
3500            // `scanpmmapfile()` (line 240).
3501            "mapfile" => {
3502                if key == "@" || key == "*" {
3503                    // Inline readdir loop — direct port of
3504                    // scanpmmapfile (Src/Modules/mapfile.c:241).
3505                    let mut files: Vec<String> = Vec::new();
3506                    if let Ok(rd) = std::fs::read_dir(".") {
3507                        for entry in rd.flatten() {
3508                            let path = entry.path();
3509                            if path.is_file() {
3510                                if let Some(name) =
3511                                    path.file_name().and_then(|n| n.to_str())
3512                                {
3513                                    files.push(name.to_string());
3514                                }
3515                            }
3516                        }
3517                    }
3518                    return Some(files.join(" "));
3519                }
3520                Some(crate::modules::mapfile::get_contents(key).unwrap_or_default())
3521            }
3522            // === ZSH/SYSTEM — errnos / sysparams ===
3523            "errnos" => {
3524                let table = crate::modules::system::ERRNO_NAMES;
3525                if key == "@" || key == "*" {
3526                    return Some(
3527                        table
3528                            .iter()
3529                            .map(|(n, _)| (*n).to_string())
3530                            .collect::<Vec<_>>()
3531                            .join(" "),
3532                    );
3533                }
3534                if let Ok(n) = key.parse::<i64>() {
3535                    let len = table.len() as i64;
3536                    let pos = if n > 0 {
3537                        (n - 1) as usize
3538                    } else if n < 0 {
3539                        let p = len + n;
3540                        if p < 0 {
3541                            return Some(String::new());
3542                        }
3543                        p as usize
3544                    } else {
3545                        return Some(String::new());
3546                    };
3547                    if let Some((name, _)) = table.get(pos) {
3548                        return Some((*name).to_string());
3549                    }
3550                }
3551                Some(String::new())
3552            }
3553            "sysparams" => {
3554                let pid = std::process::id().to_string();
3555                let ppid = unsafe { libc::getppid() }.to_string();
3556                if key == "@" || key == "*" {
3557                    return Some(format!("{} {}", pid, ppid));
3558                }
3559                Some(match key {
3560                    "pid" => pid,
3561                    "ppid" => ppid,
3562                    "procsubstpid" => "0".to_string(),
3563                    _ => String::new(),
3564                })
3565            }
3566            // === SHELL OPTIONS ===
3567            "options" => {
3568                if key == "@" || key == "*" {
3569                    // Return all options as "name=on/off" pairs.
3570                    let opts: Vec<String> = crate::ported::options::opt_state_snapshot()
3571                        .iter()
3572                        .map(|(k, v)| format!("{}={}", k, if *v { "on" } else { "off" }))
3573                        .collect();
3574                    return Some(opts.join(" "));
3575                }
3576                let opt_name = key.to_lowercase().replace('_', "");
3577                let is_on = crate::ported::options::opt_state_get(&opt_name).unwrap_or(false);
3578                Some(if is_on {
3579                    "on".to_string()
3580                } else {
3581                    "off".to_string()
3582                })
3583            }
3584
3585            // === ALIASES ===
3586            // ${aliases[@]} returns values in sorted-name order.
3587            // Iterating HashMap::values() gave random order; tests
3588            // and prompt code that snapshot ${(v)aliases} flickered.
3589            "aliases" => {
3590                // Read from canonical `aliastab` (`Src/hashtable.c:1210`,
3591                // ported at `src/ported/hashtable.rs::aliastab_lock`).
3592                // bin_alias writes through `aliastab.add()` — the local
3593                // `exec.aliases` HashMap is a stale init-time snapshot.
3594                if let Ok(tab) = crate::ported::hashtable::aliastab_lock().read() {
3595                    if key == "@" || key == "*" {
3596                        let mut names: Vec<&String> = tab.iter().map(|(n, _)| n).collect();
3597                        names.sort();
3598                        let vals: Vec<String> = names.iter()
3599                            .filter_map(|n| tab.get(n).map(|a| a.text.clone()))
3600                            .collect();
3601                        return Some(vals.join(" "));
3602                    }
3603                    return Some(tab.get(key).map(|a| a.text.clone()).unwrap_or_default());
3604                }
3605                Some(self.alias(key).unwrap_or_default())
3606            }
3607            "galiases" => {
3608                if key == "@" || key == "*" {
3609                    let entries = self.global_alias_entries();
3610                    let vals: Vec<String> = entries.into_iter().map(|(_, v)| v).collect();
3611                    return Some(vals.join(" "));
3612                }
3613                Some(self.global_alias(key).unwrap_or_default())
3614            }
3615            "saliases" => {
3616                if key == "@" || key == "*" {
3617                    let entries = self.suffix_alias_entries();
3618                    let vals: Vec<String> = entries.into_iter().map(|(_, v)| v).collect();
3619                    return Some(vals.join(" "));
3620                }
3621                Some(self.suffix_alias(key).unwrap_or_default())
3622            }
3623
3624            // === TERMINFO (zsh/terminfo module) ===
3625            // `${terminfo[capname]}` returns the escape sequence for
3626            // capability `capname`. Direct port of zsh/Src/Modules/
3627            // terminfo.c — the C version calls `tigetstr(name)` from
3628            // ncurses; we map the common-subset capability names to
3629            // standard xterm/VT escape sequences inline. Covers the
3630            // function-keys / cursor-motion / clear / color set that
3631            // user keymaps query (`key[F1]=$terminfo[kf1]` etc.).
3632            "terminfo" => {
3633                // Lazy lookup via ncurses tigetstr/tigetnum/tigetflag
3634                // — the pre-populated assoc init seeds the common
3635                // subset, but a script may query any cap by name
3636                // (`$terminfo[acsc]`, `$terminfo[colors]`). Mirror
3637                // zsh's terminfo.c::getterminfo lazy-resolve path.
3638                Some(crate::modules::terminfo::getterminfo(key).unwrap_or_default())
3639            }
3640            // `termcap` is dispatched in the `magic_assoc_lookup`
3641            // function (the primary special-array path) so that
3642            // ${termcap[cl]} resolves before this fallback runs.
3643            // Keeping a no-op arm here avoids a spurious "unknown
3644            // assoc" diagnostic if a caller bypasses
3645            // magic_assoc_lookup.
3646            "termcap" => Some(crate::modules::termcap::gettermcap(key).unwrap_or_default()),
3647
3648            // === FUNCTIONS ===
3649            "functions" => {
3650                if key == "@" || key == "*" {
3651                    return Some(self.function_names().join(" "));
3652                }
3653                // Apply zsh's getfn_functions formatter — leading-tab
3654                // body, no trailing `;`. Direct port of Src/exec.c
3655                // shipped via compile_zsh's fast path; this branch
3656                // is the slow-path/subst_port entry that previously
3657                // returned the raw user-typed source. Keeps
3658                // `${functions[foo]:0:20}` (substring extraction)
3659                // consistent with the fast-path `\$functions[foo]`.
3660                let text = self.function_definition_text(key)?;
3661                let formatted = FuncBodyFmt::render(text.trim());
3662                Some(format!("\t{}", formatted))
3663            }
3664            "functions_source" => {
3665                // ${functions_source[name]} → file path where the
3666                // function was defined. zsh/Src/Modules/parameter.c
3667                // exposes this as an assoc keyed by function name.
3668                // For autoload functions we recover the source path
3669                // via the same fpath walk that loads them; for inline
3670                // functions we don't yet track the defining file, so
3671                // emit empty in that case.
3672                // find_function_file deleted with old exec.c stubs.
3673                if key == "@" || key == "*" {
3674                    let _ = self.function_names();
3675                    return Some(String::new());
3676                }
3677                { let _ = key; Some(String::new()) }
3678            }
3679
3680            // === COMMANDS (command hash table) ===
3681            // ${commands[name]} → full path (or empty), per
3682            // zsh/Modules/parameter.c. The @/* expansion enumerates
3683            // every command on PATH (deduplicated, first-wins).
3684            "commands" => {
3685                if key == "@" || key == "*" {
3686                    let path_var = env::var("PATH").unwrap_or_default();
3687                    let mut seen: std::collections::HashSet<String> =
3688                        std::collections::HashSet::new();
3689                    let mut names: Vec<String> = Vec::new();
3690                    // Hashed entries first (rehash population) — read
3691                    // from canonical `cmdnamtab` (Src/exec.c:5260).
3692                    if let Ok(tab) = crate::ported::hashtable::cmdnamtab_lock().read() {
3693                        for (k, _) in tab.iter() {
3694                            if seen.insert(k.clone()) {
3695                                names.push(k.clone());
3696                            }
3697                        }
3698                    }
3699                    for dir in path_var.split(':') {
3700                        if dir.is_empty() {
3701                            continue;
3702                        }
3703                        if let Ok(entries) = std::fs::read_dir(dir) {
3704                            for entry in entries.flatten() {
3705                                if let Ok(name) = entry.file_name().into_string() {
3706                                    if seen.insert(name.clone()) {
3707                                        names.push(name);
3708                                    }
3709                                }
3710                            }
3711                        }
3712                    }
3713                    names.sort();
3714                    return Some(names.join(" "));
3715                }
3716                if let Some(path) = self.find_in_path(key) {
3717                    Some(path)
3718                } else {
3719                    Some(String::new())
3720                }
3721            }
3722
3723            // === BUILTINS ===
3724            "builtins" => {
3725                let builtins: Vec<&str> = crate::exec::BUILTIN_NAMES
3726                    .iter().map(|s| s.as_str()).collect();
3727                if key == "@" || key == "*" {
3728                    return Some(builtins.join(" "));
3729                }
3730                if builtins.iter().any(|b| *b == key) {
3731                    Some("defined".to_string())
3732                } else {
3733                    Some(String::new())
3734                }
3735            }
3736
3737            // === PARAMETERS ===
3738            // ${parameters[name]} → full attribute string per
3739            // VarAttr::format_zsh (e.g. 'integer-readonly-export').
3740            // @/* enumerates every parameter name, sorted+deduped.
3741            "parameters" => {
3742                if key == "@" || key == "*" {
3743                    let mut names: std::collections::BTreeSet<String> =
3744                        if let Ok(tab) = crate::ported::params::paramtab().read() {
3745                            tab.keys().cloned().collect()
3746                        } else {
3747                            std::collections::BTreeSet::new()
3748                        };
3749                    if let Ok(m) = crate::ported::params::paramtab_hashed_storage().lock() {
3750                        names.extend(m.keys().cloned());
3751                    }
3752                    let v: Vec<String> = names.into_iter().collect();
3753                    return Some(v.join(" "));
3754                }
3755                // Read PM_TYPE from paramtab Param flags first
3756                // (canonical). PM_INTEGER → "integer", PM_FFLOAT|
3757                // PM_EFLOAT → "float", PM_HASHED → "association",
3758                // PM_ARRAY → "array", scalar default. Append PM_LOWER
3759                // / PM_UPPER / PM_READONLY / PM_EXPORTED suffixes.
3760                let flags = self.param_flags(key) as u32;
3761                if flags != 0 || self.array(key).is_some() || self.assoc(key).is_some() {
3762                    let base = if flags & PM_INTEGER != 0 { "integer" }
3763                        else if flags & (PM_EFLOAT | PM_FFLOAT) != 0 { "float" }
3764                        else if flags & PM_HASHED != 0 || self.assoc(key).is_some() { "association" }
3765                        else if flags & PM_ARRAY != 0 || self.array(key).is_some() { "array" }
3766                        else { "scalar" };
3767                    let mut out = String::from(base);
3768                    if flags & PM_LEFT != 0 { out.push_str("-left"); }
3769                    if flags & PM_RIGHT_B != 0 { out.push_str("-right_blanks"); }
3770                    if flags & PM_RIGHT_Z != 0 { out.push_str("-right_zeros"); }
3771                    if flags & PM_LOWER != 0 { out.push_str("-lower"); }
3772                    if flags & PM_UPPER != 0 { out.push_str("-upper"); }
3773                    if flags & PM_READONLY != 0 { out.push_str("-readonly"); }
3774                    if flags & PM_EXPORTED != 0 { out.push_str("-export"); }
3775                    return Some(out);
3776                }
3777                if self.has_assoc(key) {
3778                    Some("association".to_string())
3779                } else if self.has_array(key) {
3780                    Some("array".to_string())
3781                } else if self.has_scalar(key) || std::env::var(key).is_ok() {
3782                    Some("scalar".to_string())
3783                } else {
3784                    Some(String::new())
3785                }
3786            }
3787
3788            // === NAMED DIRECTORIES ===
3789            // ${nameddirs[@]} returns paths in sorted-name order (was
3790            // HashMap::values() with random iteration).
3791            "nameddirs" => {
3792                // Canonical `nameddirtab` lives in
3793                // `src/ported/hashnameddir.rs` (port of `Src/hashnameddir.c`).
3794                let tab = crate::ported::hashnameddir::nameddirtab();
3795                if key == "@" || key == "*" {
3796                    let snapshot: Vec<(String, String)> = tab.lock().ok()
3797                        .map(|g| g.iter().map(|(k, v)| (k.clone(), v.dir.clone())).collect())
3798                        .unwrap_or_default();
3799                    let mut keys: Vec<&(String, String)> = snapshot.iter().collect();
3800                    keys.sort_by(|a, b| a.0.cmp(&b.0));
3801                    let vals: Vec<String> = keys.iter().map(|(_, v)| v.clone()).collect();
3802                    return Some(vals.join(" "));
3803                }
3804                Some(
3805                    tab.lock().ok()
3806                        .and_then(|g| g.get(key).map(|nd| nd.dir.clone()))
3807                        .unwrap_or_default(),
3808                )
3809            }
3810
3811            // === USER DIRECTORIES ===
3812            // ${userdirs[name]} → home directory of user `name` per
3813            // zsh/Modules/parameter.c userdirs_*. With @/* expansion,
3814            // walk getpwent(3) to enumerate every passwd entry's
3815            // home directory.
3816            "userdirs" => {
3817                #[cfg(unix)]
3818                {
3819                    if key == "@" || key == "*" {
3820                        let mut homes: Vec<String> = Vec::new();
3821                        unsafe {
3822                            libc::setpwent();
3823                            loop {
3824                                let pwd = libc::getpwent();
3825                                if pwd.is_null() {
3826                                    break;
3827                                }
3828                                let dir = CStr::from_ptr((*pwd).pw_dir);
3829                                homes.push(dir.to_string_lossy().to_string());
3830                            }
3831                            libc::endpwent();
3832                        }
3833                        homes.sort();
3834                        homes.dedup();
3835                        return Some(homes.join(" "));
3836                    }
3837                    if let Ok(name) = CString::new(key) {
3838                        unsafe {
3839                            let pwd = libc::getpwnam(name.as_ptr());
3840                            if !pwd.is_null() {
3841                                let dir = CStr::from_ptr((*pwd).pw_dir);
3842                                return Some(dir.to_string_lossy().to_string());
3843                            }
3844                        }
3845                    }
3846                }
3847                Some(String::new())
3848            }
3849
3850            // === USER GROUPS ===
3851            // ${usergroups[name]} → GID of group `name`. With @/*
3852            // expansion, walk getgrent(3) to enumerate every group's
3853            // gid.
3854            "usergroups" => {
3855                #[cfg(unix)]
3856                {
3857                    if key == "@" || key == "*" {
3858                        let mut gids: Vec<String> = Vec::new();
3859                        unsafe {
3860                            libc::setgrent();
3861                            loop {
3862                                let grp = libc::getgrent();
3863                                if grp.is_null() {
3864                                    break;
3865                                }
3866                                let name = CStr::from_ptr((*grp).gr_name);
3867                                gids.push(name.to_string_lossy().to_string());
3868                            }
3869                            libc::endgrent();
3870                        }
3871                        gids.sort();
3872                        gids.dedup();
3873                        return Some(gids.join(" "));
3874                    }
3875                    if let Ok(name) = CString::new(key) {
3876                        unsafe {
3877                            let grp = libc::getgrnam(name.as_ptr());
3878                            if !grp.is_null() {
3879                                return Some((*grp).gr_gid.to_string());
3880                            }
3881                        }
3882                    }
3883                }
3884                Some(String::new())
3885            }
3886
3887            // === DIRECTORY STACK ===
3888            // Canonical `dirstack` lives in `modules/parameter.rs::DIRSTACK`
3889            // — mirror of the C `dirstack` global (`Src/builtin.c:1456`).
3890            "dirstack" => {
3891                let dirs = crate::ported::modules::parameter::DIRSTACK
3892                    .lock()
3893                    .map(|g| g.clone())
3894                    .unwrap_or_default();
3895                if key == "@" || key == "*" {
3896                    return Some(dirs.join(" "));
3897                }
3898                if let Ok(idx) = key.parse::<usize>() {
3899                    Some(dirs.get(idx).cloned().unwrap_or_default())
3900                } else {
3901                    Some(String::new())
3902                }
3903            }
3904
3905            // === JOBS ===
3906            "jobstates" => {
3907                if key == "@" || key == "*" {
3908                    let states: Vec<String> = self
3909                        .jobs
3910                        .iter()
3911                        .map(|(id, job)| format!("{}:{:?}", id, job.state))
3912                        .collect();
3913                    return Some(states.join(" "));
3914                }
3915                if let Ok(id) = key.parse::<usize>() {
3916                    if let Some(job) = self.jobs.get(id) {
3917                        return Some(format!("{:?}", job.state));
3918                    }
3919                }
3920                Some(String::new())
3921            }
3922            "jobtexts" => {
3923                if key == "@" || key == "*" {
3924                    let texts: Vec<String> = self
3925                        .jobs
3926                        .iter()
3927                        .map(|(_, job)| job.command.clone())
3928                        .collect();
3929                    return Some(texts.join(" "));
3930                }
3931                if let Ok(id) = key.parse::<usize>() {
3932                    if let Some(job) = self.jobs.get(id) {
3933                        return Some(job.command.clone());
3934                    }
3935                }
3936                Some(String::new())
3937            }
3938            "jobdirs" => {
3939                // ${jobdirs[N]}: cwd at the time job N was launched.
3940                // We don't yet capture per-job cwd at launch (would
3941                // need a JobInfo.cwd field plumbed through add_job),
3942                // so use the current PWD as a best-effort proxy. With
3943                // @/* expansion, return one entry per active job so
3944                // arr-length math (${#jobdirs}) matches ${#jobtexts}.
3945                let pwd = self
3946                    .scalar("PWD")
3947                    .or_else(|| env::var("PWD").ok())
3948                    .unwrap_or_default();
3949                if key == "@" || key == "*" {
3950                    let n = self.jobs.iter().count();
3951                    return Some(vec![pwd; n].join(" "));
3952                }
3953                if let Ok(id) = key.parse::<usize>() {
3954                    if self.jobs.get(id).is_some() {
3955                        return Some(pwd);
3956                    }
3957                }
3958                Some(String::new())
3959            }
3960
3961            // === HISTORY ===
3962            "history" => {
3963                if key == "@" || key == "*" {
3964                    // Return recent history
3965                    if let Some(ref engine) = self.history {
3966                        if let Ok(entries) = engine.recent(100) {
3967                            let cmds: Vec<String> =
3968                                entries.iter().map(|e| e.command.clone()).collect();
3969                            return Some(cmds.join("\n"));
3970                        }
3971                    }
3972                    return Some(String::new());
3973                }
3974                if let Ok(num) = key.parse::<usize>() {
3975                    if let Some(ref engine) = self.history {
3976                        if let Ok(Some(entry)) = engine.get_by_offset(num.saturating_sub(1)) {
3977                            return Some(entry.command);
3978                        }
3979                    }
3980                }
3981                Some(String::new())
3982            }
3983            "historywords" => {
3984                // $historywords: flat list of words from recent history
3985                // entries (zsh/Modules/parameter.c historywords_*).
3986                // Each command is split on whitespace; the words are
3987                // collected newest-first across the recent window.
3988                if let Some(ref engine) = self.history {
3989                    if let Ok(entries) = engine.recent(100) {
3990                        let words: Vec<String> = entries
3991                            .iter()
3992                            .flat_map(|e| {
3993                                e.command
3994                                    .split_whitespace()
3995                                    .map(|s| s.to_string())
3996                                    .collect::<Vec<_>>()
3997                            })
3998                            .collect();
3999                        if key == "@" || key == "*" {
4000                            return Some(words.join(" "));
4001                        }
4002                        if let Ok(idx) = key.parse::<usize>() {
4003                            if idx >= 1 && idx <= words.len() {
4004                                return Some(words[idx - 1].clone());
4005                            }
4006                        }
4007                    }
4008                }
4009                Some(String::new())
4010            }
4011
4012            // === MODULES ===
4013            // ${modules[name]} → "loaded" / "" per
4014            // zsh/Src/Modules/parameter.c modules_*. zshrs tracks
4015            // loaded modules via `_module_<name>` keys in
4016            // self.options (see bin_zmodload). Always-loaded
4017            // built-in modules are surfaced unconditionally so
4018            // compsys's `[[ ${+modules[zsh/zutil]} ]]` gating works.
4019            "modules" => {
4020                const ALWAYS_LOADED: &[&str] = &[
4021                    "zsh/parameter",
4022                    "zsh/zutil",
4023                    "zsh/complete",
4024                    "zsh/complist",
4025                    "zsh/zle",
4026                    "zsh/main",
4027                    "zsh/files",
4028                ];
4029                let user_loaded: Vec<String> = crate::ported::options::opt_state_snapshot()
4030                    .iter()
4031                    .filter_map(|(k, v)| {
4032                        if *v {
4033                            k.strip_prefix("_module_").map(|s| s.to_string())
4034                        } else {
4035                            None
4036                        }
4037                    })
4038                    .collect();
4039                if key == "@" || key == "*" {
4040                    let mut all: Vec<String> = ALWAYS_LOADED
4041                        .iter()
4042                        .map(|s| s.to_string())
4043                        .chain(user_loaded.iter().cloned())
4044                        .collect();
4045                    all.sort();
4046                    all.dedup();
4047                    return Some(all.join(" "));
4048                }
4049                if ALWAYS_LOADED.contains(&key)
4050                    || crate::ported::options::opt_state_get(&format!("_module_{}", key))
4051                        .unwrap_or(false)
4052                {
4053                    Some("loaded".to_string())
4054                } else {
4055                    Some(String::new())
4056                }
4057            }
4058
4059            // === RESERVED WORDS ===
4060            "reswords" => {
4061                let reswords = [
4062                    "do",
4063                    "done",
4064                    "esac",
4065                    "then",
4066                    "elif",
4067                    "else",
4068                    "fi",
4069                    "for",
4070                    "case",
4071                    "if",
4072                    "while",
4073                    "function",
4074                    "repeat",
4075                    "time",
4076                    "until",
4077                    "select",
4078                    "coproc",
4079                    "nocorrect",
4080                    "foreach",
4081                    "end",
4082                    "in",
4083                ];
4084                if key == "@" || key == "*" {
4085                    return Some(reswords.join(" "));
4086                }
4087                if let Ok(idx) = key.parse::<usize>() {
4088                    Some(reswords.get(idx).map(|s| s.to_string()).unwrap_or_default())
4089                } else {
4090                    Some(String::new())
4091                }
4092            }
4093
4094            // === PATCHARS (characters with special meaning in patterns) ===
4095            "patchars" => {
4096                let patchars = ["?", "*", "[", "]", "^", "#", "~", "(", ")", "|"];
4097                if key == "@" || key == "*" {
4098                    return Some(patchars.join(" "));
4099                }
4100                if let Ok(idx) = key.parse::<usize>() {
4101                    Some(patchars.get(idx).map(|s| s.to_string()).unwrap_or_default())
4102                } else {
4103                    Some(String::new())
4104                }
4105            }
4106
4107            // === FUNCTION CALL STACK ===
4108            // $funcstack: array of function names in the current call
4109            // chain (innermost first). Already maintained by the
4110            // function-call code at exec.rs:7828-7835. Surface it here
4111            // so `${funcstack[1]}` / `${funcstack[@]}` reads work.
4112            // funcfiletrace / funcsourcetrace need separate tables (file
4113            // and definition tracking) which we don't yet wire; emit
4114            // empty for those until they're populated.
4115            "funcstack" => {
4116                if let Some(stack) = self.array("funcstack") {
4117                    if key == "@" || key == "*" {
4118                        return Some(stack.join(" "));
4119                    }
4120                    if let Ok(idx) = key.parse::<usize>() {
4121                        // zsh subscripts are 1-based.
4122                        if idx >= 1 && idx <= stack.len() {
4123                            return Some(stack[idx - 1].clone());
4124                        }
4125                    }
4126                }
4127                Some(String::new())
4128            }
4129            "functrace" => {
4130                // $functrace: `caller_name:callsite_lineno` for each
4131                // frame. We don't yet track call-site line numbers, so
4132                // synthesize from funcstack with a `:0` placeholder
4133                // line. This still lets scripts that test
4134                // `[[ -n $functrace[1] ]]` work without false-empty.
4135                if let Some(stack) = self.array("funcstack") {
4136                    let synth: Vec<String> = stack.iter().map(|n| format!("{}:0", n)).collect();
4137                    if key == "@" || key == "*" {
4138                        return Some(synth.join(" "));
4139                    }
4140                    if let Ok(idx) = key.parse::<usize>() {
4141                        if idx >= 1 && idx <= synth.len() {
4142                            return Some(synth[idx - 1].clone());
4143                        }
4144                    }
4145                }
4146                Some(String::new())
4147            }
4148            "funcfiletrace" | "funcsourcetrace" => {
4149                // Would need file:line where each function was called
4150                // from / defined in. Per-frame file tracking is not yet
4151                // wired — return empty.
4152                Some(String::new())
4153            }
4154
4155            // === DISABLED VARIANTS (dis_*) ===
4156            // ${dis_builtins[name]} → "defined" if the builtin was
4157            // disabled via `disable name`. Tracked through
4158            // self.options['_disabled_<name>']. The other dis_*
4159            // variants (aliases/functions/reswords/patchars) lose
4160            // their entries entirely on disable in zshrs's table
4161            // model (see do_enable_disable at exec.rs:31371) so the
4162            // disabled list isn't recoverable post-disable; emit
4163            // empty for those.
4164            "dis_builtins" => {
4165                let disabled: Vec<String> = crate::ported::options::opt_state_snapshot()
4166                    .iter()
4167                    .filter_map(|(k, v)| {
4168                        if *v {
4169                            k.strip_prefix("_disabled_").map(|s| s.to_string())
4170                        } else {
4171                            None
4172                        }
4173                    })
4174                    .collect();
4175                if key == "@" || key == "*" {
4176                    let mut sorted = disabled.clone();
4177                    sorted.sort();
4178                    return Some(sorted.join(" "));
4179                }
4180                if disabled.iter().any(|d| d == key) {
4181                    Some("defined".to_string())
4182                } else {
4183                    Some(String::new())
4184                }
4185            }
4186            "dis_aliases"
4187            | "dis_galiases"
4188            | "dis_saliases"
4189            | "dis_functions"
4190            | "dis_functions_source"
4191            | "dis_reswords"
4192            | "dis_patchars" => Some(String::new()),
4193
4194            // === ZLE WIDGETS ===
4195            // ${widgets[name]} → widget-type prefix per
4196            // zsh/Src/Zle/zleparameter.c widgets_*: "builtin",
4197            // "user:<funcname>", or "completion:<funcname>".
4198            // Distinguishes builtin vs user-defined so
4199            // ${(t)widgets[name]} works.
4200            "widgets" => {
4201                if key == "@" || key == "*" {
4202                    let mut names = listwidgets();
4203                    names.sort();
4204                    return Some(names.join(" "));
4205                }
4206                if let Some(target) = getwidgettarget(key) {
4207                    if target == key {
4208                        Some("builtin".to_string())
4209                    } else {
4210                        Some(format!("user:{}", target))
4211                    }
4212                } else {
4213                    Some(String::new())
4214                }
4215            }
4216
4217            // === ZLE KEYMAPS ===
4218            // ${keymaps[N]} per zleparameter.c keymaps_*: list of
4219            // available keymap names. Single-key lookup returns 1
4220            // ("set") if the keymap exists, "" otherwise.
4221            "keymaps" => {
4222                const KEYMAPS: &[&str] = &[
4223                    "main",
4224                    "emacs",
4225                    "viins",
4226                    "vicmd",
4227                    "isearch",
4228                    "command",
4229                    "menuselect",
4230                ];
4231                if key == "@" || key == "*" {
4232                    return Some(KEYMAPS.join(" "));
4233                }
4234                if KEYMAPS.contains(&key) {
4235                    Some("1".to_string())
4236                } else {
4237                    Some(String::new())
4238                }
4239            }
4240
4241            // === SIGNAL NAMES ===
4242            // $signals: array indexed by signal number (1-based) where
4243            // each slot holds the bare signal name. Direct port of
4244            // zsh/Modules/parameter.c signals_*. zshrs uses libc signal
4245            // constants so the mapping matches the host platform
4246            // (macOS USR1=30, Linux USR1=10).
4247            "signals" => {
4248                let map: &[(i32, &str)] = &[
4249                    (libc::SIGHUP, "HUP"),
4250                    (libc::SIGINT, "INT"),
4251                    (libc::SIGQUIT, "QUIT"),
4252                    (libc::SIGILL, "ILL"),
4253                    (libc::SIGTRAP, "TRAP"),
4254                    (libc::SIGABRT, "ABRT"),
4255                    #[cfg(target_os = "macos")]
4256                    (libc::SIGEMT, "EMT"),
4257                    (libc::SIGFPE, "FPE"),
4258                    (libc::SIGKILL, "KILL"),
4259                    (libc::SIGBUS, "BUS"),
4260                    (libc::SIGSEGV, "SEGV"),
4261                    (libc::SIGSYS, "SYS"),
4262                    (libc::SIGPIPE, "PIPE"),
4263                    (libc::SIGALRM, "ALRM"),
4264                    (libc::SIGTERM, "TERM"),
4265                    (libc::SIGURG, "URG"),
4266                    (libc::SIGSTOP, "STOP"),
4267                    (libc::SIGTSTP, "TSTP"),
4268                    (libc::SIGCONT, "CONT"),
4269                    (libc::SIGCHLD, "CHLD"),
4270                    (libc::SIGTTIN, "TTIN"),
4271                    (libc::SIGTTOU, "TTOU"),
4272                    (libc::SIGIO, "IO"),
4273                    (libc::SIGXCPU, "XCPU"),
4274                    (libc::SIGXFSZ, "XFSZ"),
4275                    (libc::SIGVTALRM, "VTALRM"),
4276                    (libc::SIGPROF, "PROF"),
4277                    (libc::SIGWINCH, "WINCH"),
4278                    #[cfg(target_os = "macos")]
4279                    (libc::SIGINFO, "INFO"),
4280                    (libc::SIGUSR1, "USR1"),
4281                    (libc::SIGUSR2, "USR2"),
4282                ];
4283                if key == "@" || key == "*" {
4284                    // Return one entry per signal in numeric order (1..N).
4285                    let max = map.iter().map(|(n, _)| *n).max().unwrap_or(0) as usize;
4286                    let mut slots: Vec<String> = vec![String::new(); max];
4287                    for (n, name) in map {
4288                        if (*n as usize) >= 1 && (*n as usize) <= max {
4289                            slots[*n as usize - 1] = (*name).to_string();
4290                        }
4291                    }
4292                    return Some(slots.join(" "));
4293                }
4294                // Numeric subscript -> name; name -> empty (zsh's
4295                // $signals is keyed by number).
4296                if let Ok(n) = key.parse::<i32>() {
4297                    for (sig_num, name) in map {
4298                        if *sig_num == n {
4299                            return Some((*name).to_string());
4300                        }
4301                    }
4302                }
4303                Some(String::new())
4304            }
4305
4306            // Not a special array
4307            _ => None,
4308        }
4309    }
4310    pub(crate) fn get_variable(&self, name: &str) -> String {
4311        // Handle special parameters
4312        match name {
4313            "" => String::new(), // Empty name returns empty
4314            "$" => std::process::id().to_string(),
4315            "@" | "*" => {
4316                // $* joins by the first char of $IFS (POSIX). Default
4317                // IFS is " \t\n\0" so the join char is " "; with a
4318                // custom IFS like `:` the joined string uses `:`.
4319                // $@ technically does the same in scalar context but
4320                // is usually quoted-spliced — both fall through here.
4321                let sep = self
4322                    .scalar("IFS")
4323                    .and_then(|s| s.chars().next())
4324                    .unwrap_or(' ');
4325                self.pparams().join(&sep.to_string())
4326            }
4327            "#" | "#@" | "#*" => self.pparams().len().to_string(),
4328            // zsh alias: $ARGC also equals $#.
4329            "ARGC" => self.pparams().len().to_string(),
4330            "?" | "status" => self.last_status().to_string(),
4331            "!" => self
4332                .scalar("!")
4333                .unwrap_or_else(|| "0".to_string()),
4334            // `$-` returns the concatenated single-letter flags of options
4335            // currently set. zsh always emits a baseline "569X" prefix
4336            // (internal-letter options that are on by default in -f mode)
4337            // followed by user-controllable flags. Match the prefix
4338            // verbatim so existing scripts that do `[[ $- == *e* ]]` /
4339            // `case $- in *x*) … esac` see consistent letters.
4340            "-" => {
4341                let mut letters = String::from("569X");
4342                let opt = |n: &str| crate::ported::options::opt_state_get(n).unwrap_or(false);
4343                // `e` comes BEFORE `f` in zsh's letter ordering: `set -e`
4344                // in -f mode produces "569Xef", not "569Xfe".
4345                if opt("errexit") {
4346                    letters.push('e');
4347                }
4348                if !opt("rcs") {
4349                    letters.push('f');
4350                }
4351                if opt("login") {
4352                    letters.push('l');
4353                }
4354                // i/m are present only when *truly* interactive; zsh's `-c`
4355                // path leaves them off, so we mirror that and don't surface
4356                // them just because `options.interactive` happens to be set
4357                // by the executor's default-options init.
4358                if opt("nounset") {
4359                    letters.push('u');
4360                }
4361                if opt("xtrace") {
4362                    letters.push('x');
4363                }
4364                if opt("verbose") {
4365                    letters.push('v');
4366                }
4367                if opt("noexec") {
4368                    letters.push('n');
4369                }
4370                if opt("hashall") {
4371                    letters.push('h');
4372                }
4373                letters
4374            }
4375            "EUID" => unsafe { libc::geteuid() }.to_string(),
4376            "UID" => unsafe { libc::getuid() }.to_string(),
4377            "EGID" => unsafe { libc::getegid() }.to_string(),
4378            "GID" => unsafe { libc::getgid() }.to_string(),
4379            "PPID" => unsafe { libc::getppid() }.to_string(),
4380            "ZSH_SUBSHELL" => self
4381                .scalar("ZSH_SUBSHELL")
4382                .unwrap_or_else(|| "0".to_string()),
4383            "HOST" => {
4384                // libc gethostname → up to 256 bytes.
4385                let mut buf = [0u8; 256];
4386                let r = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len()) };
4387                if r == 0 {
4388                    let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
4389                    String::from_utf8_lossy(&buf[..nul]).into_owned()
4390                } else {
4391                    String::new()
4392                }
4393            }
4394            // OS / machine identity vars. zsh hardcodes these from build-time
4395            // detection; we synthesize at runtime from libc uname(). Without
4396            // these arms `$OSTYPE` returned empty even though zle_params wrote
4397            // them into the params table — the executor's get_variable bypasses
4398            // that table for special names.
4399            "OSTYPE" => {
4400                let mut u: libc::utsname = unsafe { std::mem::zeroed() };
4401                if unsafe { libc::uname(&mut u) } == 0 {
4402                    let sysname = unsafe { std::ffi::CStr::from_ptr(u.sysname.as_ptr()) }
4403                        .to_string_lossy()
4404                        .to_lowercase();
4405                    let release = unsafe { std::ffi::CStr::from_ptr(u.release.as_ptr()) }
4406                        .to_string_lossy()
4407                        .to_string();
4408                    format!("{}{}", sysname, release)
4409                } else {
4410                    std::env::consts::OS.to_string()
4411                }
4412            }
4413            "MACHTYPE" => {
4414                let mut u: libc::utsname = unsafe { std::mem::zeroed() };
4415                if unsafe { libc::uname(&mut u) } == 0 {
4416                    let m = unsafe { std::ffi::CStr::from_ptr(u.machine.as_ptr()) }
4417                        .to_string_lossy()
4418                        .to_string();
4419                    // zsh shortens common machines: aarch64 → arm, x86_64
4420                    // stays x86_64. Mirror that for the common cases.
4421                    if m == "aarch64" || m == "arm64" {
4422                        "arm".to_string()
4423                    } else {
4424                        m
4425                    }
4426                } else {
4427                    std::env::consts::ARCH.to_string()
4428                }
4429            }
4430            "CPUTYPE" => {
4431                let mut u: libc::utsname = unsafe { std::mem::zeroed() };
4432                if unsafe { libc::uname(&mut u) } == 0 {
4433                    unsafe { std::ffi::CStr::from_ptr(u.machine.as_ptr()) }
4434                        .to_string_lossy()
4435                        .to_string()
4436                } else {
4437                    std::env::consts::ARCH.to_string()
4438                }
4439            }
4440            "VENDOR" => {
4441                // No portable libc query for vendor; pick by OS family.
4442                if cfg!(target_os = "macos") {
4443                    "apple".to_string()
4444                } else if cfg!(target_os = "linux") {
4445                    "unknown".to_string()
4446                } else {
4447                    "pc".to_string()
4448                }
4449            }
4450            "HOSTNAME" => {
4451                let mut buf = [0u8; 256];
4452                let r = unsafe { libc::gethostname(buf.as_mut_ptr() as *mut _, buf.len()) };
4453                if r == 0 {
4454                    let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
4455                    String::from_utf8_lossy(&buf[..nul]).into_owned()
4456                } else {
4457                    String::new()
4458                }
4459            }
4460            "RANDOM" => {
4461                // zsh/bash: pseudo-random unsigned 16-bit integer per
4462                // expansion. We use process+nano for a cheap, OS-portable
4463                // source — not cryptographically secure, but matches zsh's
4464                // "noise" semantics.
4465                let nanos = SystemTime::now()
4466                    .duration_since(UNIX_EPOCH)
4467                    .map(|d| d.subsec_nanos() as u64)
4468                    .unwrap_or(0);
4469                let pid = std::process::id() as u64;
4470                let r = (nanos.wrapping_mul(2654435761).wrapping_add(pid)) as u32;
4471                ((r as u16) & 0x7fff).to_string()
4472            }
4473            "SECONDS" => {
4474                // Seconds since shell start. We approximate via the
4475                // tracked `shell_start_time` if present; otherwise 0.
4476                crate::ported::params::getsparam("SECONDS").unwrap_or_else(|| {
4477                    let now = SystemTime::now()
4478                        .duration_since(UNIX_EPOCH)
4479                        .map(|d| d.as_secs())
4480                        .unwrap_or(0);
4481                    let start = self
4482                        .scalar("__zshrs_start_secs")
4483                        .and_then(|s| s.parse::<u64>().ok())
4484                        .unwrap_or(now);
4485                    now.saturating_sub(start).to_string()
4486                })
4487            }
4488            "EPOCHSECONDS" => {
4489                SystemTime::now()
4490                    .duration_since(UNIX_EPOCH)
4491                    .map(|d| d.as_secs().to_string())
4492                    .unwrap_or_else(|_| "0".to_string())
4493            }
4494            "EPOCHREALTIME" => {
4495                // zsh/datetime: fractional seconds since the epoch with
4496                // microsecond resolution. Format: SECS.UUUUUU.
4497                match SystemTime::now().duration_since(UNIX_EPOCH) {
4498                    Ok(d) => format!("{}.{:06}", d.as_secs(), d.subsec_micros()),
4499                    Err(_) => "0.000000".to_string(),
4500                }
4501            }
4502            "argv" => self.pparams().join(" "),
4503            "HISTCMD" => {
4504                // zsh: HISTCMD = current history-event number. With -f
4505                // (no rc loading) and history-tracking off, zsh shows
4506                // 0. We mirror by returning the current session count
4507                // (or 0 when history isn't engaged).
4508                self.session_history_ids.len().to_string()
4509            }
4510            "TTY" => {
4511                // Path to the controlling terminal (`$TTY` in zsh).
4512                // ttyname(0) gives the device path. Returns "" if no tty.
4513                let ptr = unsafe { libc::ttyname(0) };
4514                if ptr.is_null() {
4515                    String::new()
4516                } else {
4517                    unsafe { std::ffi::CStr::from_ptr(ptr) }
4518                        .to_string_lossy()
4519                        .into_owned()
4520                }
4521            }
4522            "TTYIDLE" => {
4523                // Idle time of stdin TTY in seconds — stat the tty, return
4524                // (now - st_atime). Returns "-1" if not a tty per zsh docs.
4525                let ptr = unsafe { libc::ttyname(0) };
4526                if ptr.is_null() {
4527                    return "-1".to_string();
4528                }
4529                let path = unsafe { std::ffi::CStr::from_ptr(ptr) };
4530                let path_str = path.to_string_lossy().into_owned();
4531                match std::fs::metadata(&path_str) {
4532                    Ok(meta) => {
4533                        if let Ok(atime) = meta.accessed() {
4534                            let now = SystemTime::now();
4535                            let idle = now.duration_since(atime).unwrap_or_default();
4536                            return idle.as_secs().to_string();
4537                        }
4538                        "0".to_string()
4539                    }
4540                    Err(_) => "-1".to_string(),
4541                }
4542            }
4543            "TRY_BLOCK_ERROR" => {
4544                // Set by `{ … } always { … }` — last status of the try
4545                // block. Lives in self.variables under the same name when
4546                // the try arm assigns it; default 0.
4547                self.scalar("TRY_BLOCK_ERROR")
4548                    .unwrap_or_else(|| "0".to_string())
4549            }
4550            "patchars" => "*?[]<>(){}|^&;".to_string(),
4551            "RANDOM_FILE" => {
4552                // Path to entropy source. Mainline zsh leaves empty
4553                // unless `zmodload zsh/random` set it; we expose
4554                // /dev/urandom as a useful default — matches the
4555                // platform's actual entropy source.
4556                if std::path::Path::new("/dev/urandom").exists() {
4557                    "/dev/urandom".to_string()
4558                } else {
4559                    String::new()
4560                }
4561            }
4562            "LINENO" => {
4563                // Tracked elsewhere; default to 1 if not populated.
4564                self.scalar("LINENO")
4565                    .unwrap_or_else(|| "1".to_string())
4566            }
4567            "0" => self
4568                .scalar("0")
4569                .unwrap_or_else(|| env::args().next().unwrap_or_default()),
4570            n if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) => {
4571                let idx: usize = n.parse().unwrap_or(0);
4572                if idx == 0 {
4573                    env::args().next().unwrap_or_default()
4574                } else {
4575                    self.pparams()
4576                        .get(idx - 1)
4577                        .cloned()
4578                        .unwrap_or_default()
4579                }
4580            }
4581            _ => {
4582                // Bare-assoc bypass: `declare -A h; h=(a 1 b 2); ${h}`
4583                // expects the joined values. The `declare -A` sets
4584                // variables["h"]="" as a side effect, which would
4585                // satisfy the variables lookup with empty. Skip the
4586                // variables lookup when an assoc with the same name
4587                // exists AND has entries.
4588                let assoc_has_entries = self
4589                    .assoc(name)
4590                    .map(|h| !h.is_empty())
4591                    .unwrap_or(false);
4592                // GSU dispatch first — `$USERNAME` / `$IFS` / `$HOME`
4593                // / etc. route through their getfn callback. Mirrors
4594                // C zsh's `Param.gsu->getfn` lookup. Without this,
4595                // get_variable bypassed the GSU table entirely and
4596                // returned empty for usernamegetfn-backed reads.
4597                let resolved = lookup_special_var(name)
4598                    .or_else(|| {
4599                        if !assoc_has_entries {
4600                            crate::ported::params::getsparam(name)
4601                        } else {
4602                            None
4603                        }
4604                    })
4605                    .or_else(|| self.array(name).map(|a| a.join(" ")))
4606                    .or_else(|| {
4607                        self.assoc(name).map(|h| {
4608                            if h.is_empty() {
4609                                String::new()
4610                            } else {
4611                                h.values().cloned().collect::<Vec<_>>().join(" ")
4612                            }
4613                        })
4614                    })
4615                    .or_else(|| env::var(name).ok());
4616                match resolved {
4617                    Some(v) => v,
4618                    None => {
4619                        // zsh stores the option as "unset" (default ON =
4620                        // silently empty). `set -u` / `setopt nounset` /
4621                        // `set -o nounset` all turn it OFF. Different
4622                        // code paths in zshrs persist either key, so
4623                        // honor either signal.
4624                        let nounset_on = crate::ported::options::opt_state_get("nounset").unwrap_or(false)
4625                            || !crate::ported::options::opt_state_get("unset").unwrap_or(true);
4626                        if nounset_on {
4627                            zerr(&format!("{}: parameter not set", name));
4628                            std::process::exit(1);
4629                        }
4630                        String::new()
4631                    }
4632                }
4633            }
4634        }
4635    }
4636    pub(crate) fn pre_resolve_array_subscripts(&self, expr: &str) -> String {
4637        let bytes: Vec<char> = expr.chars().collect();
4638        let mut out = String::with_capacity(expr.len());
4639        let mut i = 0;
4640        while i < bytes.len() {
4641            let c = bytes[i];
4642            // `$@`, `$*`, `$NAME` followed by `[…]` — zinit's
4643            // `(( $@[(I)-*] ))` and similar arith uses this. Strip
4644            // the leading `$` and route through the same name+[key]
4645            // resolver as bare identifiers. Without this the `$@`
4646            // gets variable-expanded to its joined form before
4647            // arith eval, dropping the subscript flag entirely.
4648            if c == '$' && i + 1 < bytes.len() {
4649                let next = bytes[i + 1];
4650                let is_special_at = next == '@' || next == '*';
4651                let is_ident_start = next.is_ascii_alphabetic() || next == '_';
4652                if (is_special_at || is_ident_start) && i + 2 < bytes.len() {
4653                    // Look-ahead: must be followed by `[` to qualify
4654                    // as a subscript form. Bare `$@` without `[` is
4655                    // left alone (downstream substitution handles it).
4656                    let mut probe = i + 1;
4657                    if is_special_at {
4658                        probe += 1;
4659                    } else {
4660                        while probe < bytes.len()
4661                            && (bytes[probe].is_ascii_alphanumeric() || bytes[probe] == '_')
4662                        {
4663                            probe += 1;
4664                        }
4665                    }
4666                    if probe < bytes.len() && bytes[probe] == '[' {
4667                        // Drop the `$` and re-enter the bare-ident
4668                        // path on the next iteration.
4669                        i += 1;
4670                        continue;
4671                    }
4672                }
4673            }
4674            // Identifier start?
4675            if c.is_ascii_alphabetic() || c == '_' || c == '@' || c == '*' {
4676                let start = i;
4677                i += 1;
4678                if !(bytes[start] == '@' || bytes[start] == '*') {
4679                    while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == '_') {
4680                        i += 1;
4681                    }
4682                }
4683                let name: String = bytes[start..i].iter().collect();
4684                if i < bytes.len() && bytes[i] == '[' {
4685                    // Collect balanced [...]
4686                    i += 1;
4687                    let key_start = i;
4688                    let mut depth = 1;
4689                    while i < bytes.len() && depth > 0 {
4690                        match bytes[i] {
4691                            '[' => depth += 1,
4692                            ']' => {
4693                                depth -= 1;
4694                                if depth == 0 {
4695                                    break;
4696                                }
4697                            }
4698                            _ => {}
4699                        }
4700                        i += 1;
4701                    }
4702                    let key_str: String = bytes[key_start..i].iter().collect();
4703                    if i < bytes.len() {
4704                        i += 1; // skip closing ]
4705                    }
4706                    // Resolve sub-key (it may itself be an arith expr or
4707                    // string literal); strip surrounding quotes and
4708                    // expand $-refs.
4709                    let key_resolved: String = if key_str.starts_with('"') && key_str.ends_with('"')
4710                        || key_str.starts_with('\'') && key_str.ends_with('\'')
4711                    {
4712                        key_str[1..key_str.len() - 1].to_string()
4713                    } else {
4714                        key_str.clone()
4715                    };
4716                    // Subscript-flag form `(I)pat` / `(i)pat` etc. —
4717                    // route through array_subscript_flag so zinit's
4718                    // `(( $@[(I)-*] ))` and `(( OPTS[opt_-h,…] ))`
4719                    // patterns yield an index/key as zsh does.
4720                    let trimmed_key = key_resolved.trim_start();
4721                    let resolved = if trimmed_key.starts_with('(') {
4722                        // getarg with the right storage gives back the
4723                        // matched value or the all-matches join — see
4724                        // params.c:1581-1719 inside getarg.
4725                        let scalar_val = crate::ported::params::getsparam(&name);
4726                        let result = if let Some(assoc) = self.assoc(&name) {
4727                            getarg(trimmed_key, None, Some(&assoc), None)
4728                        } else if name == "@" || name == "*" {
4729                            let pos = self.pparams();
4730                            getarg(trimmed_key, Some(&pos), None, None)
4731                        } else if let Some(arr) = self.array(&name) {
4732                            getarg(trimmed_key, Some(&arr), None, None)
4733                        } else if let Some(ref s) = scalar_val {
4734                            getarg(trimmed_key, None, None, Some(s.as_str()))
4735                        } else {
4736                            None
4737                        };
4738                        match result {
4739                            Some(getarg_out::Value(v)) => v.to_str(),
4740                            _ => "0".to_string(),
4741                        }
4742                    } else if let Some(assoc) = self.assoc(&name) {
4743                        assoc
4744                            .get(&key_resolved)
4745                            .cloned()
4746                            .unwrap_or_else(|| "0".to_string())
4747                    } else if let Some(arr) = self.array(&name) {
4748                        // Numeric subscript — can be a literal or an
4749                        // expression. For simple int literals only here;
4750                        // complex exprs are uncommon in real scripts.
4751                        if let Ok(idx) = key_resolved.trim().parse::<i64>() {
4752                            let len = arr.len() as i64;
4753                            let pos = if idx < 0 { len + idx } else { idx - 1 };
4754                            if pos >= 0 && (pos as usize) < arr.len() {
4755                                arr[pos as usize].clone()
4756                            } else {
4757                                "0".to_string()
4758                            }
4759                        } else {
4760                            "0".to_string()
4761                        }
4762                    } else {
4763                        // Unrecognised — emit the original text so the
4764                        // evaluator can complain naturally.
4765                        format!("{}[{}]", name, key_str)
4766                    };
4767                    out.push_str(&resolved);
4768                } else {
4769                    out.push_str(&name);
4770                }
4771                continue;
4772            }
4773            out.push(c);
4774            i += 1;
4775        }
4776        out
4777    }
4778}
4779
4780// =====================================================================
4781// MOVED FROM: src/ported/hist.rs
4782// =====================================================================
4783
4784impl crate::ported::exec::ShellExecutor {
4785    /// Split a command string into words for word designators, respecting quotes.
4786    pub(crate) fn history_split_words(cmd: &str) -> Vec<String> {
4787        let mut words = Vec::new();
4788        let mut current = String::new();
4789        let mut in_sq = false;
4790        let mut in_dq = false;
4791        let mut escaped = false;
4792
4793        for c in cmd.chars() {
4794            if escaped {
4795                current.push(c);
4796                escaped = false;
4797                continue;
4798            }
4799            if c == '\\' {
4800                current.push(c);
4801                escaped = true;
4802                continue;
4803            }
4804            if c == '\'' && !in_dq {
4805                in_sq = !in_sq;
4806                current.push(c);
4807                continue;
4808            }
4809            if c == '"' && !in_sq {
4810                in_dq = !in_dq;
4811                current.push(c);
4812                continue;
4813            }
4814            if c.is_whitespace() && !in_sq && !in_dq {
4815                if !current.is_empty() {
4816                    words.push(std::mem::take(&mut current));
4817                }
4818                continue;
4819            }
4820            current.push(c);
4821        }
4822        if !current.is_empty() {
4823            words.push(current);
4824        }
4825        words
4826    }
4827    /// Parse a word range like 0, 1, ^, $, *, n-m, n-
4828    pub(crate) fn history_parse_word_range(
4829        &self,
4830        chars: &[char],
4831        mut i: usize,
4832        argc: usize,
4833    ) -> (Option<usize>, Option<usize>, usize) {
4834        if i >= chars.len() {
4835            return (None, None, i);
4836        }
4837
4838        // Check for modifiers that aren't word designators
4839        match chars[i] {
4840            'h' | 't' | 'r' | 'e' | 's' | 'S' | 'g' | 'p' | 'q' | 'Q' | 'l' | 'u' | 'a' | 'A'
4841            | '&' => {
4842                // This is a modifier, not a word designator — back up
4843                return (None, None, i - 1); // -1 to re-read the ':'
4844            }
4845            _ => {}
4846        }
4847
4848        let farg = if chars[i] == '^' {
4849            i += 1;
4850            Some(1usize)
4851        } else if chars[i] == '$' {
4852            i += 1;
4853            return (Some(argc), Some(argc), i);
4854        } else if chars[i] == '*' {
4855            i += 1;
4856            return (Some(1), Some(argc), i);
4857        } else if chars[i].is_ascii_digit() {
4858            let start = i;
4859            while i < chars.len() && chars[i].is_ascii_digit() {
4860                i += 1;
4861            }
4862            let n: usize = chars[start..i]
4863                .iter()
4864                .collect::<String>()
4865                .parse()
4866                .unwrap_or(0);
4867            Some(n)
4868        } else {
4869            None
4870        };
4871
4872        // Check for range: n-m or n-
4873        if i < chars.len() && chars[i] == '-' {
4874            i += 1;
4875            if i < chars.len() && chars[i] == '$' {
4876                i += 1;
4877                return (farg, Some(argc), i);
4878            } else if i < chars.len() && chars[i].is_ascii_digit() {
4879                let start = i;
4880                while i < chars.len() && chars[i].is_ascii_digit() {
4881                    i += 1;
4882                }
4883                let m: usize = chars[start..i]
4884                    .iter()
4885                    .collect::<String>()
4886                    .parse()
4887                    .unwrap_or(0);
4888                return (farg, Some(m), i);
4889            } else {
4890                // n- means n to argc-1
4891                return (farg, Some(argc.saturating_sub(1)), i);
4892            }
4893        }
4894
4895        if farg.is_some() {
4896            (farg, farg, i)
4897        } else {
4898            (None, None, i)
4899        }
4900    }
4901}
4902
4903// =====================================================================
4904// MOVED FROM: src/ported/signals.rs
4905// =====================================================================
4906
4907impl crate::ported::exec::ShellExecutor {
4908    /// Execute trap handlers for a signal
4909    pub fn run_trap(&mut self, signal: &str) {
4910        if let Some(action) = self.traps.get(signal).cloned() {
4911            // Empty action = signal-ignore. Don't try to execute "".
4912            if !action.is_empty() {
4913                let _ = self.execute_script(&action);
4914            }
4915        }
4916    }
4917}
4918
4919// =====================================================================
4920// MOVED FROM: src/ported/prompt.rs
4921// =====================================================================
4922
4923impl crate::ported::exec::ShellExecutor {
4924    /// Expand prompt escape sequences using the full prompt module.
4925    /// `expand_prompt` itself now reads C globals (paramtab / LASTVAL /
4926    /// curhist / JOBTAB / scriptname) so no per-executor sync is
4927    /// needed — the executor's state already mirrors those globals.
4928    pub(crate) fn expand_prompt_string(&self, s: &str) -> String {
4929        expand_prompt(s)
4930    }
4931    pub(crate) fn apply_prompt_theme(&mut self, theme: &str, preview: bool) {
4932        let (ps1, rps1) = match theme {
4933            "minimal" => ("%# ", ""),
4934            "off" => ("$ ", ""),
4935            "adam1" => (
4936                "%B%F{cyan}%n@%m %F{blue}%~%f%b %# ",
4937                "%F{yellow}%D{%H:%M}%f",
4938            ),
4939            "redhat" => ("[%n@%m %~]$ ", ""),
4940            _ => ("%n@%m %~ %# ", ""),
4941        };
4942        if preview {
4943            println!("PS1={:?}", ps1);
4944            println!("RPS1={:?}", rps1);
4945        } else {
4946            self.set_scalar("PS1".to_string(), ps1.to_string());
4947            self.set_scalar("RPS1".to_string(), rps1.to_string());
4948            self.set_scalar("prompt_theme".to_string(), theme.to_string());
4949        }
4950    }
4951}
4952
4953// =====================================================================
4954// MOVED FROM: src/ported/glob.rs
4955// =====================================================================
4956
4957impl crate::ported::exec::ShellExecutor {
4958    /// Expand glob pattern to matching files
4959    pub fn expand_glob(&self, pattern: &str) -> Vec<String> {
4960        // Glob alternation `(a|b|c)` is a primary zsh feature
4961        // (no extendedglob needed, unlike `~` exclusion). Direct
4962        // port of zsh's pattern.c handling of P_BRANCH | inside
4963        // grouping parens — at the path level, `/etc/(passwd|
4964        // hostname)` matches multiple alternative paths. zshrs's
4965        // glob crate (and earlier hand-rolled code) didn't expand
4966        // the `(...|...)` form, so the literal parens reached the
4967        // OS glob and produced no matches.
4968        //
4969        // Pre-expand by splitting top-level `(...|...)` groups
4970        // into separate patterns and recursing — same shape as
4971        // brace expansion at this layer. Skip when extendedglob
4972        // is on AND the pattern is `(#flag)` (inline pattern flag,
4973        // handled by the regex compiler downstream).
4974        if let Some(alternatives) = expand_glob_alternation(pattern) {
4975            // For each alternative, treat as a GLOB pattern: if it
4976            // contains other glob chars, recurse through expand_glob
4977            // (which handles `*`/`?`/`[`/qualifier suffixes); if
4978            // it's a literal path, only include it if the path
4979            // EXISTS — zsh's pattern.c behavior is "alternation
4980            // produces matching paths, not literal alternatives".
4981            // Without the exists-check, `/etc/(passwd|nonexistent)`
4982            // would output both.
4983            let mut out: Vec<String> = Vec::new();
4984            for alt in alternatives {
4985                let has_meta = alt.chars().any(|c| matches!(c, '*' | '?' | '[' | '('));
4986                if has_meta {
4987                    out.extend(self.expand_glob(&alt));
4988                } else if std::path::Path::new(&alt).exists() {
4989                    out.push(alt);
4990                }
4991            }
4992            let mut seen = std::collections::HashSet::new();
4993            out.retain(|p| seen.insert(p.clone()));
4994            // zsh sorts glob results alphabetically by default.
4995            // Without sorting, the alternation order leaks
4996            // through (`/etc/(passwd|group)` would output
4997            // `passwd group` instead of zsh's `group passwd`).
4998            out.sort();
4999            if !out.is_empty() {
5000                return out;
5001            }
5002            // No matches — fall through to NOMATCH semantics
5003            // below (zsh: error if `nomatch` is on, else literal).
5004        }
5005        // extendedglob `~` exclusion: `*.txt~b.txt` matches `*.txt`
5006        // and excludes paths that also match `b.txt`. Detect a
5007        // top-level `~` (not inside brackets/parens) when extendedglob
5008        // is on and split. Recursively expand both halves and remove
5009        // the RHS matches from the LHS list.
5010        let extglob_on = crate::ported::options::opt_state_get("extendedglob").unwrap_or(false);
5011        if extglob_on {
5012            // extendedglob `^pat` (negation): match everything that
5013            // does NOT match `pat`. The lexer leaves `^` as a literal
5014            // char, so we detect a leading `^` here and convert to a
5015            // directory-walk-then-filter. Only applies at the start
5016            // of the LAST path component (zsh: `^pat` only negates
5017            // the basename portion).
5018            let last_seg_start = pattern.rfind('/').map(|i| i + 1).unwrap_or(0);
5019            let last_seg = &pattern[last_seg_start..];
5020            if last_seg.starts_with('^') && last_seg.len() > 1 {
5021                let prefix = &pattern[..last_seg_start];
5022                let neg = &last_seg[1..];
5023                let dir = if prefix.is_empty() {
5024                    ".".to_string()
5025                } else {
5026                    prefix.trim_end_matches('/').to_string()
5027                };
5028                let mut out = Vec::new();
5029                if let Ok(entries) = std::fs::read_dir(&dir) {
5030                    for entry in entries.flatten() {
5031                        let name = entry.file_name().to_string_lossy().to_string();
5032                        if name.starts_with('.') {
5033                            continue;
5034                        }
5035                        if !crate::exec::glob_match_static(&name, neg) {
5036                            let path = if prefix.is_empty() {
5037                                name
5038                            } else {
5039                                format!("{}{}", prefix, name)
5040                            };
5041                            out.push(path);
5042                        }
5043                    }
5044                }
5045                out.sort();
5046                if !out.is_empty() {
5047                    return out;
5048                }
5049                let nullglob = crate::ported::options::opt_state_get("nullglob").unwrap_or(false);
5050                if nullglob {
5051                    return Vec::new();
5052                }
5053                let nomatch = crate::ported::options::opt_state_get("nomatch").unwrap_or(true);
5054                if nomatch {
5055                    zerr(&format!("no matches found: {}", pattern));
5056                    std::process::exit(1);
5057                }
5058                return vec![pattern.to_string()];
5059            }
5060            // Find a top-level `~` outside brackets.
5061            let chars: Vec<char> = pattern.chars().collect();
5062            let mut depth_b = 0i32;
5063            let mut depth_p = 0i32;
5064            let mut split_at: Option<usize> = None;
5065            for (i, &c) in chars.iter().enumerate() {
5066                match c {
5067                    '[' => depth_b += 1,
5068                    ']' => depth_b -= 1,
5069                    '(' => depth_p += 1,
5070                    ')' => depth_p -= 1,
5071                    '~' if depth_b == 0 && depth_p == 0 && i > 0 => {
5072                        // Skip `~` at start (tilde expansion) and `~` adjacent
5073                        // to space (zsh treats those as expansion).
5074                        split_at = Some(i);
5075                        break;
5076                    }
5077                    _ => {}
5078                }
5079            }
5080            if let Some(pos) = split_at {
5081                let lhs: String = chars[..pos].iter().collect();
5082                let rhs: String = chars[pos + 1..].iter().collect();
5083                let lhs_matches = self.expand_glob(&lhs);
5084                // zsh pattern.c: `~` is an exclusion operator that matches
5085                // RHS as a PATTERN against each LHS candidate, not a
5086                // separate glob expansion in CWD. Match RHS against each
5087                // result's basename and full path.
5088                let filtered: Vec<String> = lhs_matches
5089                    .into_iter()
5090                    .filter(|p| {
5091                        let basename = p.rsplit('/').next().unwrap_or(p);
5092                        !crate::exec::glob_match_static(basename, &rhs)
5093                            && !crate::exec::glob_match_static(p, &rhs)
5094                    })
5095                    .collect();
5096                if !filtered.is_empty() {
5097                    return filtered;
5098                }
5099                // Empty after exclusion — fall through so NOMATCH
5100                // semantics fire if no nullglob.
5101                let nullglob = crate::ported::options::opt_state_get("nullglob").unwrap_or(false);
5102                if nullglob {
5103                    return Vec::new();
5104                }
5105                let nomatch = crate::ported::options::opt_state_get("nomatch").unwrap_or(true);
5106                if nomatch && Self::looks_like_glob(pattern) {
5107                    zerr(&format!("no matches found: {}", pattern));
5108                    std::process::exit(1);
5109                }
5110                return vec![pattern.to_string()];
5111            }
5112        }
5113        // Check for zsh glob qualifiers at end: *(.) *(/) *(@) etc.
5114        let (glob_pattern, qualifiers) = self.parse_glob_qualifiers(pattern);
5115        // Pre-process `[^...]` → `[!...]` so the `glob` crate (which
5116        // only accepts `!` for class negation per fnmatch) works for
5117        // zsh's `^` form too. Walk the pattern and only translate
5118        // inside `[...]` regions (so a literal `^` outside brackets
5119        // stays literal — extendedglob handles those separately).
5120        let glob_pattern = if glob_pattern.contains("[^") {
5121            let mut out = String::with_capacity(glob_pattern.len());
5122            let mut chars = glob_pattern.chars().peekable();
5123            while let Some(c) = chars.next() {
5124                if c == '[' {
5125                    out.push('[');
5126                    if chars.peek() == Some(&'^') {
5127                        chars.next();
5128                        out.push('!');
5129                    }
5130                    for cc in chars.by_ref() {
5131                        out.push(cc);
5132                        if cc == ']' {
5133                            break;
5134                        }
5135                    }
5136                } else {
5137                    out.push(c);
5138                }
5139            }
5140            out
5141        } else {
5142            glob_pattern
5143        };
5144
5145        // POSIX character classes: `[[:alpha:]]`, `[[:digit:]]` etc.
5146        // The `glob` crate doesn't recognise the `[:class:]` syntax —
5147        // convert each known class to its enumerated char range so
5148        // the underlying matcher sees a plain char-class. Done here
5149        // (not at the lexer) so the substitution survives all the
5150        // way to glob::glob_with(). Tracks: alnum, alpha, blank,
5151        // cntrl, digit, graph, lower, print, punct, space, upper,
5152        // xdigit. Each translates to ranges like `0-9`/`a-zA-Z`.
5153        let glob_pattern = if glob_pattern.contains("[:") {
5154            // Inline expansion of `[[:alpha:]]` → `[a-zA-Z]` etc.
5155            // Mirrors the inline `[:class:]` switch the C source does
5156            // in pattern.c::patmatchrange. Each known class translates
5157            // to its standard ASCII range; unknown classes pass through.
5158            let s = &glob_pattern;
5159            let mut out = String::with_capacity(s.len());
5160            let chars: Vec<char> = s.chars().collect();
5161            let mut i = 0;
5162            while i < chars.len() {
5163                if chars[i] == '[' && i + 2 < chars.len() && chars[i + 1] == ':' {
5164                    let mut j = i + 2;
5165                    while j + 1 < chars.len() && !(chars[j] == ':' && chars[j + 1] == ']') {
5166                        j += 1;
5167                    }
5168                    if j + 1 < chars.len() && chars[j] == ':' && chars[j + 1] == ']' {
5169                        let name: String = chars[i + 2..j].iter().collect();
5170                        let range = match name.as_str() {
5171                            "alpha" => "a-zA-Z",
5172                            "alnum" => "a-zA-Z0-9",
5173                            "digit" => "0-9",
5174                            "xdigit" => "0-9a-fA-F",
5175                            "lower" => "a-z",
5176                            "upper" => "A-Z",
5177                            "space" => " \\t\\n\\r\\v\\f",
5178                            "blank" => " \\t",
5179                            "cntrl" => "\\x00-\\x1f\\x7f",
5180                            "print" => "\\x20-\\x7e",
5181                            "graph" => "\\x21-\\x7e",
5182                            "punct" => "!-/:-@\\[-`{-~",
5183                            _ => "",
5184                        };
5185                        if !range.is_empty() {
5186                            out.push_str(range);
5187                            i = j + 2;
5188                            continue;
5189                        }
5190                    }
5191                }
5192                out.push(chars[i]);
5193                i += 1;
5194            }
5195            out
5196        } else {
5197            glob_pattern
5198        };
5199
5200        // zsh numeric range glob `<N-M>`, `<N->`, `<-M>`, `<->`.
5201        // The `glob` crate has no equivalent — match by replacing the
5202        // range with `*` and post-filtering by extracting the digit
5203        // sequence at that position and verifying it falls in [N, M].
5204        // Only fires when the pattern actually contains a `<…-…>` shape
5205        // — guard with a fast contains() before the regex.
5206        let numeric_ranges = if glob_pattern.contains('<') {
5207            extract_numeric_ranges(&glob_pattern)
5208        } else {
5209            Vec::new()
5210        };
5211        let glob_pattern = if !numeric_ranges.is_empty() {
5212            numeric_ranges_to_star(&glob_pattern)
5213        } else {
5214            glob_pattern
5215        };
5216
5217        // Check for extended glob patterns: ?(pat), *(pat), +(pat), @(pat), !(pat)
5218        if self.has_extglob_pattern(&glob_pattern) {
5219            let expanded = self.expand_glob(&glob_pattern);
5220            return self.filter_by_qualifiers(expanded, &qualifiers);
5221        }
5222
5223        let nullglob = crate::ported::options::opt_state_get("nullglob").unwrap_or(false);
5224        // `(D)` glob qualifier — per-pattern dotglob. Same effect as
5225        // `setopt dotglob` but scoped to this expansion only.
5226        // Also: when the LAST path component starts with literal `.`,
5227        // treat as if dotglob was on (zsh: `.*` matches dotfiles even
5228        // without setopt dotglob, because the leading `.` is literal).
5229        let last_seg = glob_pattern.rsplit('/').next().unwrap_or(&glob_pattern);
5230        let pattern_starts_with_dot = last_seg.starts_with('.');
5231        // `globdots` is the zsh canonical name; `dotglob` is the bash
5232        // alias. Both end up stored under their own key by setopt — read
5233        // both so either spelling works.
5234        let dotglob = crate::ported::options::opt_state_get("dotglob").unwrap_or(false)
5235            || crate::ported::options::opt_state_get("globdots").unwrap_or(false)
5236            || qualifiers.contains('D')
5237            || pattern_starts_with_dot;
5238        // `setopt nocaseglob` normalizes to `caseglob=false` in the
5239        // options table (the `no` prefix is the negation marker).
5240        // Read both forms so user code that flips either key works:
5241        //   - `caseglob=false` → case-INSENSITIVE
5242        //   - `nocaseglob=true` → case-INSENSITIVE (legacy / direct)
5243        let nocaseglob = !crate::ported::options::opt_state_get("caseglob").unwrap_or(true)
5244            || crate::ported::options::opt_state_get("nocaseglob").unwrap_or(false);
5245
5246        // Parallel recursive glob: when pattern contains **/ we split the
5247        // directory walk across worker pool threads — one thread per top-level
5248        // subdirectory.  zsh does this single-threaded via fork+exec which is
5249        // why `echo **/*.rs` is painfully slow on large trees.
5250        let mut expanded = if !numeric_ranges.is_empty() {
5251            // `<N-M>` numeric range glob — handle via direct directory
5252            // walk so the digit-count semantics survive (the glob crate
5253            // can't express "one or more digits" precisely).
5254            self.expand_glob_with_numeric_range(pattern, &numeric_ranges, dotglob, nocaseglob)
5255        } else if glob_pattern.contains("**/") {
5256            self.expand_glob_parallel(&glob_pattern, dotglob, nocaseglob)
5257        } else {
5258            let options = glob::MatchOptions {
5259                case_sensitive: !nocaseglob,
5260                require_literal_separator: false,
5261                require_literal_leading_dot: !dotglob,
5262            };
5263            match glob::glob_with(&glob_pattern, options) {
5264                Ok(paths) => paths
5265                    .filter_map(|p| p.ok())
5266                    .map(|p| p.to_string_lossy().to_string())
5267                    .collect(),
5268                Err(_) => vec![],
5269            }
5270        };
5271
5272        // zsh always excludes "." and ".." from glob results, even
5273        // with `dotglob` set or when the pattern is `.*`. The Rust
5274        // glob crate includes them. `Path::file_name` returns None
5275        // for these (treats them as cur/parent-dir components), so
5276        // check the trailing path segment textually.
5277        expanded.retain(|p| {
5278            let last = p.rsplit('/').next().unwrap_or(p);
5279            last != "." && last != ".."
5280        });
5281
5282        let expanded = self.filter_by_qualifiers(expanded, &qualifiers);
5283        let mut expanded = expanded;
5284        // zsh: `echo */` outputs each directory with a trailing
5285        // slash. The Rust glob crate strips trailing slashes from
5286        // matches, so re-append when the pattern ended in `/`.
5287        if glob_pattern.ends_with('/') {
5288            for p in expanded.iter_mut() {
5289                if !p.ends_with('/') {
5290                    p.push('/');
5291                }
5292            }
5293        }
5294        // Locale-aware sort: under a Unicode locale, zsh folds case
5295        // (`Aaa bbb Ccc Ddd` not `Aaa Ccc Ddd bbb`). Fallback to byte
5296        // order under C/POSIX. Sort by basename so directory components
5297        // don't dominate the comparison and produce ASCII-style output.
5298        // Skip when the qualifier requested an explicit sort (`o*`/`O*`)
5299        // — those reorder by mtime/size/etc and the alpha sort would
5300        // clobber the result.
5301        let user_sort = qualifiers.contains('o') || qualifiers.contains('O');
5302        if !user_sort {
5303            // For `**/...` recursive globs, sort by the FULL path so
5304            // depth-first / breadth-first walk order is preserved
5305            // (zsh's natural recursive order: `dir/f sub sub/g`, not
5306            // basename-sorted `f g sub`). For plain (non-recursive)
5307            // globs, sort by BASENAME to match zsh's locale-aware
5308            // case-folded output.
5309            // Locale-aware compare via canonical `zstrcmp` (sort.c:191).
5310            // C's `gmatchcmp` (glob.c:936) calls `zstrcmp(b->uname,
5311            // a->uname, gf_numsort ? SORTIT_NUMERICALLY : 0)` for the
5312            // GS_NAME arm — same path here at the callsite.
5313            if glob_pattern.contains("**/") {
5314                expanded.sort_by(|a, b| crate::ported::sort::zstrcmp(a, b, 0));
5315            } else {
5316                expanded.sort_by(|a, b| {
5317                    let an = a.rsplit('/').next().unwrap_or(a);
5318                    let bn = b.rsplit('/').next().unwrap_or(b);
5319                    crate::ported::sort::zstrcmp(an, bn, 0)
5320                });
5321            }
5322        }
5323
5324        if expanded.is_empty() {
5325            // The `(N)` per-pattern qualifier is the local equivalent of
5326            // `setopt nullglob` — when present on this glob, no-match
5327            // collapses to an empty list (silent) instead of the literal
5328            // pattern. Mirrors zsh's `*(N)` semantics.
5329            if nullglob || qualifiers.contains('N') {
5330                return vec![];
5331            }
5332            // zsh's default is `setopt nomatch`: an unmatched glob
5333            // emits "no matches found" on stderr and aborts the command
5334            // (the shell exits in -c mode). bash-style "pass literal
5335            // through" is the opt-out via `unsetopt nomatch`.
5336            let nomatch = crate::ported::options::opt_state_get("nomatch").unwrap_or(true);
5337            if nomatch && Self::looks_like_glob(pattern) {
5338                zerr(&format!("no matches found: {}", pattern));
5339                // zsh: command is aborted (skipped) with status 1,
5340                // script continues. Set the flag the simple-command
5341                // dispatcher checks; it returns early before exec.
5342                self.current_command_glob_failed.set(true);
5343                return Vec::new();
5344            }
5345            vec![pattern.to_string()]
5346        } else {
5347            expanded
5348        }
5349    }
5350    /// True iff the literal `pattern` actually contains a glob metachar
5351    /// in a position that would have triggered globbing. Used to avoid
5352    /// spurious "no matches" errors when expand_glob is called on a
5353    /// plain path that happened to route through this code (e.g. some
5354    /// fast paths bridge unconditionally).
5355    pub(crate) fn looks_like_glob(pattern: &str) -> bool {
5356        // A trailing `(qualifier)` is itself a glob trigger — e.g.
5357        // `path(L+10)` should be treated as a glob even when the
5358        // body has no `*`/`?`/`[...]`.
5359        let has_qual_suffix = if let Some(open) = pattern.rfind('(') {
5360            pattern.ends_with(')') && open + 1 < pattern.len() - 1
5361        } else {
5362            false
5363        };
5364        // Strip trailing `(...)` qualifier so we test the pattern body.
5365        let body = if let Some(open) = pattern.rfind('(') {
5366            if pattern.ends_with(')') {
5367                &pattern[..open]
5368            } else {
5369                pattern
5370            }
5371        } else {
5372            pattern
5373        };
5374        // Walk character-by-character so escaped metachars (`\*`, `\?`,
5375        // `\[`) are NOT counted as glob triggers. zsh: `echo \*` prints
5376        // a literal `*`; without the unescaped check, looks_like_glob
5377        // returned true on the bare `*` and the runtime glob expansion
5378        // aborted with NOMATCH.
5379        let chars: Vec<char> = body.chars().collect();
5380        let mut i = 0;
5381        let mut has_unescaped_star = false;
5382        let mut has_unescaped_question = false;
5383        let mut has_unescaped_bracket_open: Option<usize> = None;
5384        while i < chars.len() {
5385            let c = chars[i];
5386            if c == '\\' && i + 1 < chars.len() {
5387                // Escaped char — skip both.
5388                i += 2;
5389                continue;
5390            }
5391            match c {
5392                '*' => has_unescaped_star = true,
5393                '?' => has_unescaped_question = true,
5394                '[' if has_unescaped_bracket_open.is_none() => {
5395                    has_unescaped_bracket_open = Some(i);
5396                }
5397                _ => {}
5398            }
5399            i += 1;
5400        }
5401        // `[` only counts when there's a matching `]` after it.
5402        let has_bracket_class = has_unescaped_bracket_open
5403            .map(|i| body[i + 1..].contains(']'))
5404            .unwrap_or(false);
5405        // `<N-M>` numeric range glob is also a trigger — match shape
5406        // `<` + optional digits + `-` + optional digits + `>` outside
5407        // any bracket expression.
5408        let has_numeric_range =
5409            body.contains('<') && body.contains('>') && !extract_numeric_ranges(body).is_empty();
5410        has_unescaped_star
5411            || has_unescaped_question
5412            || has_bracket_class
5413            || has_qual_suffix
5414            || has_numeric_range
5415    }
5416    /// Direct directory walk for numeric-range glob `<N-M>`.
5417    ///
5418    /// Split the pattern at the last `/` so the dir component can stay
5419    /// concrete (or be globbed normally) and the basename gets a custom
5420    /// regex match. Numeric range groups capture `(\d+)` and each
5421    /// capture must fall inside its declared `[lo, hi]` range — open
5422    /// ends mean unbounded on that side.
5423    pub(crate) fn expand_glob_with_numeric_range(
5424        &self,
5425        pattern: &str,
5426        ranges: &[(usize, usize, Option<i64>, Option<i64>)],
5427        dotglob: bool,
5428        nocaseglob: bool,
5429    ) -> Vec<String> {
5430        let (dir_part, file_part) = match pattern.rfind('/') {
5431            Some(idx) => (&pattern[..idx], &pattern[idx + 1..]),
5432            None => ("", pattern),
5433        };
5434        // Build the basename regex: glob → regex, with each `<N-M>`
5435        // becoming a numbered capture group `(\d+)`.
5436        let mut rx = String::from("^");
5437        let chars: Vec<char> = file_part.chars().collect();
5438        let mut i = 0;
5439        let mut in_bracket = false;
5440        while i < chars.len() {
5441            let c = chars[i];
5442            if c == '[' && !in_bracket {
5443                in_bracket = true;
5444                rx.push('[');
5445                i += 1;
5446                continue;
5447            }
5448            if c == ']' && in_bracket {
5449                in_bracket = false;
5450                rx.push(']');
5451                i += 1;
5452                continue;
5453            }
5454            if in_bracket {
5455                rx.push(c);
5456                i += 1;
5457                continue;
5458            }
5459            if c == '<' {
5460                let mut j = i + 1;
5461                while j < chars.len() && chars[j].is_ascii_digit() {
5462                    j += 1;
5463                }
5464                if j < chars.len() && chars[j] == '-' {
5465                    j += 1;
5466                    while j < chars.len() && chars[j].is_ascii_digit() {
5467                        j += 1;
5468                    }
5469                    if j < chars.len() && chars[j] == '>' {
5470                        rx.push_str("(\\d+)");
5471                        i = j + 1;
5472                        continue;
5473                    }
5474                }
5475            }
5476            match c {
5477                '*' => rx.push_str(".*"),
5478                '?' => rx.push('.'),
5479                '.' | '+' | '(' | ')' | '|' | '^' | '$' | '\\' | '{' | '}' => {
5480                    rx.push('\\');
5481                    rx.push(c);
5482                }
5483                _ => rx.push(c),
5484            }
5485            i += 1;
5486        }
5487        rx.push('$');
5488        let re = match if nocaseglob {
5489            regex::RegexBuilder::new(&rx).case_insensitive(true).build()
5490        } else {
5491            regex::Regex::new(&rx).map_err(|e| regex::Error::Syntax(e.to_string()))
5492        } {
5493            Ok(r) => r,
5494            Err(_) => return Vec::new(),
5495        };
5496
5497        // Resolve dir_part: it may itself contain glob chars (e.g.
5498        // `**/file<2-4>`). For now require the dir part to be either
5499        // empty (cwd) or a literal path; defer recursive ranges.
5500        let mut dirs: Vec<String> = if dir_part.is_empty() {
5501            vec![".".to_string()]
5502        } else if dir_part.contains('*')
5503            || dir_part.contains('?')
5504            || dir_part.contains('[')
5505            || dir_part.contains('<')
5506        {
5507            // Glob the dir component first, keeping only directories.
5508            let opts = glob::MatchOptions {
5509                case_sensitive: !nocaseglob,
5510                require_literal_separator: false,
5511                require_literal_leading_dot: !dotglob,
5512            };
5513            match glob::glob_with(dir_part, opts) {
5514                Ok(paths) => paths
5515                    .filter_map(|p| p.ok())
5516                    .filter(|p| p.is_dir())
5517                    .map(|p| p.to_string_lossy().to_string())
5518                    .collect(),
5519                Err(_) => return Vec::new(),
5520            }
5521        } else {
5522            vec![dir_part.to_string()]
5523        };
5524        if dirs.is_empty() {
5525            dirs.push(dir_part.to_string());
5526        }
5527
5528        let mut out = Vec::new();
5529        for dir in &dirs {
5530            let read = match std::fs::read_dir(if dir.is_empty() { "." } else { dir }) {
5531                Ok(r) => r,
5532                Err(_) => continue,
5533            };
5534            for entry in read.flatten() {
5535                let name = entry.file_name().to_string_lossy().to_string();
5536                if !dotglob && name.starts_with('.') && !file_part.starts_with('.') {
5537                    continue;
5538                }
5539                let caps = match re.captures(&name) {
5540                    Some(c) => c,
5541                    None => continue,
5542                };
5543                let mut ok = true;
5544                for (idx, range) in ranges.iter().enumerate() {
5545                    let cap = match caps.get(idx + 1) {
5546                        Some(m) => m.as_str(),
5547                        None => {
5548                            ok = false;
5549                            break;
5550                        }
5551                    };
5552                    let val: i64 = match cap.parse() {
5553                        Ok(v) => v,
5554                        Err(_) => {
5555                            ok = false;
5556                            break;
5557                        }
5558                    };
5559                    // Tuple shape: (_start, _end, lo, hi).
5560                    if !numeric_range_contains(range.2, range.3, val) {
5561                        ok = false;
5562                        break;
5563                    }
5564                }
5565                if !ok {
5566                    continue;
5567                }
5568                let full = if dir == "." || dir.is_empty() {
5569                    name
5570                } else if dir.ends_with('/') {
5571                    format!("{}{}", dir, name)
5572                } else {
5573                    format!("{}/{}", dir, name)
5574                };
5575                out.push(full);
5576            }
5577        }
5578        out.sort();
5579        out
5580    }
5581    /// Parallel recursive glob using the worker pool.
5582    ///
5583    /// Splits `base/**/file_pattern` into per-subdirectory walks, each
5584    /// running on a pool thread via walkdir.  Results merge via channel.
5585    /// This is why `echo **/*.rs` will be 5-10x faster than zsh.
5586    pub(crate) fn expand_glob_parallel(&self, pattern: &str, dotglob: bool, nocaseglob: bool) -> Vec<String> {
5587
5588        // Split pattern at the first **/ into (base_dir, file_glob)
5589        // e.g. "src/**/*.rs" → ("src", "*.rs")
5590        //      "**/*.rs"     → (".", "*.rs")
5591        //      "**/"         → (".", "")  with dirs_only=true
5592        //      "**/*"        → (".", "*") with both files+dirs
5593        let (base, file_glob) = if let Some(pos) = pattern.find("**/") {
5594            let base = if pos == 0 {
5595                "."
5596            } else {
5597                &pattern[..pos.saturating_sub(1)]
5598            };
5599            let rest = &pattern[pos + 3..]; // skip "**/", get "*.rs" or "foo/**/*.rs"
5600            (base.to_string(), rest.to_string())
5601        } else {
5602            return vec![];
5603        };
5604
5605        // Trailing-slash form `**/`: zsh enumerates matching directories
5606        // (with the trailing slash preserved). Empty file_glob means
5607        // "match every dir under base, no file mask".
5608        let dirs_only = file_glob.is_empty();
5609
5610        // If file_glob itself contains **/, fall back to single-threaded glob
5611        // (nested recursive patterns are rare, not worth the complexity)
5612        if file_glob.contains("**/") {
5613            let options = glob::MatchOptions {
5614                case_sensitive: !nocaseglob,
5615                require_literal_separator: false,
5616                require_literal_leading_dot: !dotglob,
5617            };
5618            return match glob::glob_with(pattern, options) {
5619                Ok(paths) => paths
5620                    .filter_map(|p| p.ok())
5621                    .map(|p| p.to_string_lossy().to_string())
5622                    .collect(),
5623                Err(_) => vec![],
5624            };
5625        }
5626
5627        // Build the glob::Pattern for matching filenames. For
5628        // `dirs_only` (trailing-slash `**/`) we don't have a file mask
5629        // — every directory matches.
5630        let match_opts = glob::MatchOptions {
5631            case_sensitive: !nocaseglob,
5632            require_literal_separator: false,
5633            require_literal_leading_dot: !dotglob,
5634        };
5635        let file_pat = if dirs_only {
5636            None
5637        } else {
5638            match glob::Pattern::new(&file_glob) {
5639                Ok(p) => Some(p),
5640                Err(_) => return vec![],
5641            }
5642        };
5643        // For `**/*` (file_glob = "*"), zsh matches both files and
5644        // directories. For `**/foo` (specific file pattern), still
5645        // match either type — zsh doesn't restrict to file-type unless
5646        // a `(.)` qualifier is appended.
5647        let match_dirs_too = !dirs_only;
5648
5649        // Enumerate top-level entries in base dir to fan out across workers
5650        let top_entries: Vec<std::path::PathBuf> = match std::fs::read_dir(&base) {
5651            Ok(rd) => rd.filter_map(|e| e.ok()).map(|e| e.path()).collect(),
5652            Err(_) => return vec![],
5653        };
5654
5655        // Also check files (and dirs in dirs_only / match_dirs_too mode)
5656        // directly in base (not in subdirs).
5657        let mut results: Vec<String> = Vec::new();
5658        for entry in &top_entries {
5659            let is_dir = entry.is_dir();
5660            let is_file = entry.is_file() || entry.is_symlink();
5661            let want = if dirs_only {
5662                is_dir
5663            } else {
5664                is_file || (match_dirs_too && is_dir)
5665            };
5666            if want {
5667                if let Some(name) = entry.file_name().and_then(|n| n.to_str()) {
5668                    let matches = match &file_pat {
5669                        None => true,
5670                        Some(p) => p.matches_with(name, match_opts),
5671                    };
5672                    if matches {
5673                        let mut s = entry.to_string_lossy().to_string();
5674                        if dirs_only {
5675                            s.push('/');
5676                        }
5677                        results.push(s);
5678                    }
5679                }
5680            }
5681        }
5682
5683        // Fan out subdirectory walks to worker pool
5684        let subdirs: Vec<std::path::PathBuf> = top_entries
5685            .into_iter()
5686            .filter(|p| p.is_dir())
5687            .filter(|p| {
5688                dotglob
5689                    || !p
5690                        .file_name()
5691                        .and_then(|n| n.to_str())
5692                        .map(|n| n.starts_with('.'))
5693                        .unwrap_or(false)
5694            })
5695            .collect();
5696
5697        if subdirs.is_empty() {
5698            return results;
5699        }
5700
5701        let (tx, rx) = std::sync::mpsc::channel::<Vec<String>>();
5702
5703        for subdir in &subdirs {
5704            let tx = tx.clone();
5705            let subdir = subdir.clone();
5706            let file_pat = file_pat.clone();
5707            let skip_dot = !dotglob;
5708            let dirs_only_w = dirs_only;
5709            let match_dirs_too_w = match_dirs_too;
5710            self.worker_pool.submit(move || {
5711                let mut matches = Vec::new();
5712                let walker = WalkDir::new(&subdir)
5713                    .follow_links(false)
5714                    .into_iter()
5715                    .filter_entry(move |e| {
5716                        // Skip hidden dirs if !dotglob
5717                        if skip_dot {
5718                            if let Some(name) = e.file_name().to_str() {
5719                                if name.starts_with('.') && e.depth() > 0 {
5720                                    return false;
5721                                }
5722                            }
5723                        }
5724                        true
5725                    });
5726                for entry in walker.filter_map(|e| e.ok()) {
5727                    let is_file = entry.file_type().is_file() || entry.file_type().is_symlink();
5728                    let is_dir = entry.file_type().is_dir();
5729                    // Skip the subdir root itself — it was already added
5730                    // by the top-level loop.
5731                    if entry.depth() == 0 {
5732                        continue;
5733                    }
5734                    let want = if dirs_only_w {
5735                        is_dir
5736                    } else {
5737                        is_file || (match_dirs_too_w && is_dir)
5738                    };
5739                    if want {
5740                        if let Some(name) = entry.file_name().to_str() {
5741                            let matches_pat = match &file_pat {
5742                                None => true,
5743                                Some(p) => p.matches_with(name, match_opts),
5744                            };
5745                            if matches_pat {
5746                                let mut s = entry.path().to_string_lossy().to_string();
5747                                if dirs_only_w {
5748                                    s.push('/');
5749                                }
5750                                matches.push(s);
5751                            }
5752                        }
5753                    }
5754                }
5755                let _ = tx.send(matches);
5756            });
5757        }
5758
5759        // Drop our sender so rx knows when all workers are done
5760        drop(tx);
5761
5762        // Collect results from all workers
5763        for batch in rx {
5764            results.extend(batch);
5765        }
5766
5767        // When base was the implicit "." (the user wrote `**/...`,
5768        // not `./**/...`), zsh emits relative paths without the `./`
5769        // prefix. Strip it here for parity.
5770        if base == "." {
5771            results = results
5772                .into_iter()
5773                .map(|s| s.strip_prefix("./").map(|t| t.to_string()).unwrap_or(s))
5774                .collect();
5775        }
5776
5777        // zsh sorts the recursive-glob result lexicographically. Without
5778        // this, the parallel-walker order leaks through and `**/*`
5779        // returns paths in worker-completion order (`f sub/g sub`
5780        // instead of `f sub sub/g`).
5781        results.sort();
5782
5783        results
5784    }
5785    /// Parse zsh glob qualifiers from the end of a pattern
5786    /// Returns (pattern_without_qualifiers, qualifiers_string)
5787    pub(crate) fn parse_glob_qualifiers(&self, pattern: &str) -> (String, String) {
5788        // Check if pattern ends with (...) that looks like qualifiers
5789        // Qualifiers are single chars like . / @ * % or combinations
5790        if !pattern.ends_with(')') {
5791            return (pattern.to_string(), String::new());
5792        }
5793
5794        // Find matching opening paren
5795        let chars: Vec<char> = pattern.chars().collect();
5796        let mut depth = 0;
5797        let mut qual_start = None;
5798
5799        for i in (0..chars.len()).rev() {
5800            match chars[i] {
5801                ')' => depth += 1,
5802                '(' => {
5803                    depth -= 1;
5804                    if depth == 0 {
5805                        qual_start = Some(i);
5806                        break;
5807                    }
5808                }
5809                _ => {}
5810            }
5811        }
5812
5813        if let Some(start) = qual_start {
5814            let qual_content: String = chars[start + 1..chars.len() - 1].iter().collect();
5815
5816            // Check if this looks like glob qualifiers (not extglob)
5817            // Qualifiers are things like: . / @ * % r w x ^ - etc.
5818            // Extglob would have | inside
5819            if !qual_content.contains('|') && self.looks_like_glob_qualifiers(&qual_content) {
5820                let base_pattern: String = chars[..start].iter().collect();
5821                return (base_pattern, qual_content);
5822            }
5823        }
5824
5825        (pattern.to_string(), String::new())
5826    }
5827    /// Check if string looks like glob qualifiers
5828    //WARNING FAKE AND MUST BE DELETED
5829    pub(crate) fn looks_like_glob_qualifiers(&self, s: &str) -> bool {
5830        if s.is_empty() {
5831            return false;
5832        }
5833        // Valid qualifier chars (zsh glob qualifier set):
5834        //   type/perm: . / @ = p * % b r w x s A I E R W X
5835        //   sort:      o O n L l a m c d N
5836        //   time qual: a m c — followed by unit (s h m M d w) and op (+ -)
5837        //   user/grp:  u g
5838        //   nullglob:  N
5839        //   dotglob:   D
5840        //   T (path component)
5841        //   numeric ranges and digits for depth/uid/gid: 0-9 + - , [ ] :
5842        // Previously missing: `h` (hours unit), `g` (group qualifier),
5843        // `H` (non-empty-dir alt), `U` (owned-by-user) — adding them
5844        // unlocks `(mh-N)`, `(g+N)`, `(U)`, etc.
5845        // `O` (reverse-sort prefix, complementing `o`) was missing —
5846        // `*(Om)` was being treated as a literal pattern instead of a
5847        // qualifier set, leaving the trailing `)` unmatched. Added.
5848        let valid_chars = "./@=p*%bghilrwxAIERWXsStfHedDLNnMmcaouUYHTk^-+:0123456789,[]FO";
5849        s.chars()
5850            .all(|c| valid_chars.contains(c) || c.is_whitespace())
5851    }
5852
5853    //WARNING FAKE AND MUST BE DELETED
5854    pub(crate) fn filter_by_qualifiers(&self, files: Vec<String>, qualifiers: &str) -> Vec<String> {
5855        if qualifiers.is_empty() {
5856            return files;
5857        }
5858
5859        // Top-level `,` in the qualifier list is OR (zsh: `*(.,/)`
5860        // = files OR dirs). Direct port of zsh's pattern.c
5861        // qualifier parsing — comma splits at clause boundary,
5862        // each clause runs its own AND filter, the results are
5863        // UNIONed and de-duplicated. Single-clause (no comma)
5864        // path is unchanged.
5865        let has_or = {
5866            let mut depth_b = 0;
5867            let mut depth_p = 0;
5868            let mut found = false;
5869            for c in qualifiers.chars() {
5870                match c {
5871                    '[' => depth_b += 1,
5872                    ']' if depth_b > 0 => depth_b -= 1,
5873                    '(' if depth_b == 0 => depth_p += 1,
5874                    ')' if depth_b == 0 && depth_p > 0 => depth_p -= 1,
5875                    ',' if depth_b == 0 && depth_p == 0 => {
5876                        found = true;
5877                        break;
5878                    }
5879                    _ => {}
5880                }
5881            }
5882            found
5883        };
5884        if has_or {
5885            // Split at top-level commas, recurse for each clause,
5886            // union the results in original-file order. Each
5887            // clause re-runs the full filter so qualifier flags
5888            // (`L+0`, `om`, etc.) inside one clause stay scoped.
5889            let mut clauses: Vec<String> = Vec::new();
5890            let mut current = String::new();
5891            let mut depth_b = 0;
5892            let mut depth_p = 0;
5893            for c in qualifiers.chars() {
5894                match c {
5895                    '[' => {
5896                        depth_b += 1;
5897                        current.push(c);
5898                    }
5899                    ']' if depth_b > 0 => {
5900                        depth_b -= 1;
5901                        current.push(c);
5902                    }
5903                    '(' if depth_b == 0 => {
5904                        depth_p += 1;
5905                        current.push(c);
5906                    }
5907                    ')' if depth_b == 0 && depth_p > 0 => {
5908                        depth_p -= 1;
5909                        current.push(c);
5910                    }
5911                    ',' if depth_b == 0 && depth_p == 0 => {
5912                        clauses.push(std::mem::take(&mut current));
5913                    }
5914                    _ => current.push(c),
5915                }
5916            }
5917            clauses.push(current);
5918            let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
5919            let mut out: Vec<String> = Vec::new();
5920            for clause in &clauses {
5921                let matched = self.filter_by_qualifiers(files.clone(), clause);
5922                for m in matched {
5923                    if seen.insert(m.clone()) {
5924                        out.push(m);
5925                    }
5926                }
5927            }
5928            return out;
5929        }
5930
5931        // Parallel metadata prefetch — all stat syscalls happen on pool threads,
5932        // then filter/sort uses cached metadata with zero syscalls.
5933        let meta_cache = self.prefetch_metadata(&files);
5934
5935        let mut result = files;
5936        let mut negate = false;
5937        // (M) mark-dirs and (T) list-types qualifiers — direct port of
5938        // zsh/Src/glob.c:1557-1566. zsh appends a single char to each
5939        // output (or only to dirs for `M`). We collect the flags during
5940        // the filter loop and apply marking AFTER all filtering is done
5941        // so the suffix sticks on the final result, not midway. `^M`
5942        // disables (toggles negate to clear the flag) — same as zsh.
5943        let mut mark_dirs = false;
5944        let mut list_types = false;
5945        let mut chars = qualifiers.chars().peekable();
5946
5947        while let Some(c) = chars.next() {
5948            match c {
5949                // Negation
5950                '^' => negate = !negate,
5951                // (M) mark dirs with `/`. negate=true (`^M`) clears.
5952                'M' => {
5953                    mark_dirs = !negate;
5954                    negate = false;
5955                }
5956                // (T) list types (ls -F style: /, *, @, |, =, #, %).
5957                'T' => {
5958                    list_types = !negate;
5959                    negate = false;
5960                }
5961
5962                // History modifier `:r` / `:e` / `:t` / `:h` /
5963                // `:s/pat/repl/` etc. applied to each match. Direct
5964                // port of zsh's pattern.c qualifier modifier
5965                // handling — `:NAME` consumes through the next
5966                // qualifier-list-end (next `,` or `)`) and
5967                // dispatches each modifier to apply_history_modifiers
5968                // per element.
5969                ':' => {
5970                    // Collect the modifier chain — consume until
5971                    // we hit another qualifier-flag char or end.
5972                    // For simplicity, consume to end since the
5973                    // qualifier-end already strips the trailing
5974                    // `)`. The apply_history_modifiers helper
5975                    // tolerates a leading `:`.
5976                    let mut mods = String::from(":");
5977                    // Consume to end — qualifier-end already stripped
5978                    // the trailing `)`, so no internal delimiter check
5979                    // is needed (apply_history_modifiers tolerates the
5980                    // leading `:`).
5981                    while chars.peek().is_some() {
5982                        mods.push(chars.next().unwrap());
5983                    }
5984                    let modref = mods.as_str();
5985                    result = result
5986                        .into_iter()
5987                        .map(|p| crate::ported::hist::apply_history_modifiers(&p, modref))
5988                        .collect();
5989                }
5990
5991                // File types — all use prefetched metadata cache
5992                '.' => {
5993                    // zsh: `.` is "plain regular file" — excludes
5994                    // symlinks (use `@` for those). The `-`
5995                    // qualifier modifier (`(-.)`) inverts this:
5996                    // follow the symlink before testing, so a link
5997                    // to a regular file IS included. Direct port of
5998                    // zsh pattern.c QUAL_NULL → stat-not-lstat
5999                    // toggle.
6000                    let follow_links = qualifiers.contains('-');
6001                    result.retain(|f| {
6002                        let is_plain_file = meta_cache
6003                            .get(f)
6004                            .map(|(m, sm)| {
6005                                let is_link = sm
6006                                    .as_ref()
6007                                    .map(|m| m.file_type().is_symlink())
6008                                    .unwrap_or(false);
6009                                let is_reg = m.as_ref().map(|m| m.is_file()).unwrap_or(false);
6010                                if follow_links {
6011                                    is_reg
6012                                } else {
6013                                    is_reg && !is_link
6014                                }
6015                            })
6016                            .unwrap_or(false);
6017                        if negate {
6018                            !is_plain_file
6019                        } else {
6020                            is_plain_file
6021                        }
6022                    });
6023                    negate = false;
6024                }
6025                '/' => {
6026                    result.retain(|f| {
6027                        let is_dir = meta_cache
6028                            .get(f)
6029                            .and_then(|(m, _)| m.as_ref())
6030                            .map(|m| m.is_dir())
6031                            .unwrap_or(false);
6032                        if negate {
6033                            !is_dir
6034                        } else {
6035                            is_dir
6036                        }
6037                    });
6038                    negate = false;
6039                }
6040                '@' => {
6041                    result.retain(|f| {
6042                        let is_link = meta_cache
6043                            .get(f)
6044                            .and_then(|(_, sm)| sm.as_ref())
6045                            .map(|m| m.file_type().is_symlink())
6046                            .unwrap_or(false);
6047                        if negate {
6048                            !is_link
6049                        } else {
6050                            is_link
6051                        }
6052                    });
6053                    negate = false;
6054                }
6055                '=' => {
6056                    // Sockets
6057                    result.retain(|f| {
6058                        let is_socket = meta_cache
6059                            .get(f)
6060                            .and_then(|(_, sm)| sm.as_ref())
6061                            .map(|m| m.file_type().is_socket())
6062                            .unwrap_or(false);
6063                        if negate {
6064                            !is_socket
6065                        } else {
6066                            is_socket
6067                        }
6068                    });
6069                    negate = false;
6070                }
6071                'p' => {
6072                    // Named pipes (FIFOs)
6073                    result.retain(|f| {
6074                        let is_fifo = meta_cache
6075                            .get(f)
6076                            .and_then(|(_, sm)| sm.as_ref())
6077                            .map(|m| m.file_type().is_fifo())
6078                            .unwrap_or(false);
6079                        if negate {
6080                            !is_fifo
6081                        } else {
6082                            is_fifo
6083                        }
6084                    });
6085                    negate = false;
6086                }
6087                '*' => {
6088                    // Executable files
6089                    result.retain(|f| {
6090                        let is_exec = meta_cache
6091                            .get(f)
6092                            .and_then(|(m, _)| m.as_ref())
6093                            .map(|m| m.is_file() && (m.permissions().mode() & 0o111) != 0)
6094                            .unwrap_or(false);
6095                        if negate {
6096                            !is_exec
6097                        } else {
6098                            is_exec
6099                        }
6100                    });
6101                    negate = false;
6102                }
6103                '%' => {
6104                    // Device files
6105                    let next = chars.peek().copied();
6106                    result.retain(|f| {
6107                        let is_device = meta_cache
6108                            .get(f)
6109                            .and_then(|(_, sm)| sm.as_ref())
6110                            .map(|m| match next {
6111                                Some('b') => m.file_type().is_block_device(),
6112                                Some('c') => m.file_type().is_char_device(),
6113                                _ => {
6114                                    m.file_type().is_block_device()
6115                                        || m.file_type().is_char_device()
6116                                }
6117                            })
6118                            .unwrap_or(false);
6119                        if negate {
6120                            !is_device
6121                        } else {
6122                            is_device
6123                        }
6124                    });
6125                    if next == Some('b') || next == Some('c') {
6126                        chars.next();
6127                    }
6128                    negate = false;
6129                }
6130
6131                // L[+-]N[k|m|g|p] — size qualifier. Default unit is 512-byte
6132                // blocks; suffix 'k'/'K' = kilobytes, 'm'/'M' = megabytes,
6133                // 'g'/'G' = gigabytes, 'p'/'P' = bytes (POSIX). +N matches
6134                // larger, -N smaller, N matches exactly. e.g. L0 = exactly
6135                // 0 bytes; L+10k = larger than 10 KB.
6136                'L' => {
6137                    let mut cmp = '=';
6138                    if let Some(&peek) = chars.peek() {
6139                        if peek == '+' || peek == '-' {
6140                            cmp = peek;
6141                            chars.next();
6142                        }
6143                    }
6144                    let mut num_str = String::new();
6145                    while let Some(&peek) = chars.peek() {
6146                        if peek.is_ascii_digit() {
6147                            num_str.push(peek);
6148                            chars.next();
6149                        } else {
6150                            break;
6151                        }
6152                    }
6153                    let n: u64 = num_str.parse().unwrap_or(0);
6154                    let unit_mult: u64 = match chars.peek().copied() {
6155                        Some('k') | Some('K') => {
6156                            chars.next();
6157                            1024
6158                        }
6159                        Some('m') | Some('M') => {
6160                            chars.next();
6161                            1024 * 1024
6162                        }
6163                        Some('g') | Some('G') => {
6164                            chars.next();
6165                            1024 * 1024 * 1024
6166                        }
6167                        Some('p') | Some('P') => {
6168                            chars.next();
6169                            1
6170                        }
6171                        // zsh's default for L is BYTES (not 512-byte
6172                        // blocks). `(L+3)` means "more than 3 bytes".
6173                        _ => 1,
6174                    };
6175                    let target = n * unit_mult;
6176                    result.retain(|f| {
6177                        // zsh's L qualifier uses lstat size —
6178                        // for symlinks, that's the path-string
6179                        // length (NOT the target's size).
6180                        // Direct port: prefer the symlink
6181                        // metadata `sm` when present, fall
6182                        // back to the followed metadata.
6183                        let size = meta_cache
6184                            .get(f)
6185                            .map(|(m, sm)| {
6186                                sm.as_ref()
6187                                    .map(|m| m.len())
6188                                    .unwrap_or_else(|| m.as_ref().map(|m| m.len()).unwrap_or(0))
6189                            })
6190                            .unwrap_or(0);
6191                        let pass = match cmp {
6192                            '+' => size > target,
6193                            '-' => size < target,
6194                            _ => size == target,
6195                        };
6196                        if negate {
6197                            !pass
6198                        } else {
6199                            pass
6200                        }
6201                    });
6202                    negate = false;
6203                }
6204
6205                // l[+-]N — link-count qualifier. zsh: `*(l2)` = files
6206                // with exactly 2 hard links (e.g. one regular + one
6207                // hardlink). `+N` matches more, `-N` matches fewer.
6208                'l' => {
6209                    let mut cmp = '=';
6210                    if let Some(&peek) = chars.peek() {
6211                        if peek == '+' || peek == '-' {
6212                            cmp = peek;
6213                            chars.next();
6214                        }
6215                    }
6216                    let mut num_str = String::new();
6217                    while let Some(&peek) = chars.peek() {
6218                        if peek.is_ascii_digit() {
6219                            num_str.push(peek);
6220                            chars.next();
6221                        } else {
6222                            break;
6223                        }
6224                    }
6225                    let target: u64 = num_str.parse().unwrap_or(0);
6226                    result.retain(|f| {
6227                        let nlink = meta_cache
6228                            .get(f)
6229                            .and_then(|(m, _)| m.as_ref())
6230                            .map(|m| m.nlink())
6231                            .unwrap_or(0);
6232                        let matches = match cmp {
6233                            '+' => nlink > target,
6234                            '-' => nlink < target,
6235                            _ => nlink == target,
6236                        };
6237                        if negate {
6238                            !matches
6239                        } else {
6240                            matches
6241                        }
6242                    });
6243                    negate = false;
6244                }
6245
6246                // Permission qualifiers — all use prefetched metadata cache
6247                'r' => {
6248                    result = self.filter_by_permission(result, 0o400, negate, &meta_cache);
6249                    negate = false;
6250                }
6251                'w' => {
6252                    result = self.filter_by_permission(result, 0o200, negate, &meta_cache);
6253                    negate = false;
6254                }
6255                'x' => {
6256                    result = self.filter_by_permission(result, 0o100, negate, &meta_cache);
6257                    negate = false;
6258                }
6259                'A' => {
6260                    result = self.filter_by_permission(result, 0o040, negate, &meta_cache);
6261                    negate = false;
6262                }
6263                'I' => {
6264                    result = self.filter_by_permission(result, 0o020, negate, &meta_cache);
6265                    negate = false;
6266                }
6267                'E' => {
6268                    result = self.filter_by_permission(result, 0o010, negate, &meta_cache);
6269                    negate = false;
6270                }
6271                'R' => {
6272                    result = self.filter_by_permission(result, 0o004, negate, &meta_cache);
6273                    negate = false;
6274                }
6275                'W' => {
6276                    result = self.filter_by_permission(result, 0o002, negate, &meta_cache);
6277                    negate = false;
6278                }
6279                'X' => {
6280                    result = self.filter_by_permission(result, 0o001, negate, &meta_cache);
6281                    negate = false;
6282                }
6283                's' => {
6284                    result = self.filter_by_permission(result, 0o4000, negate, &meta_cache);
6285                    negate = false;
6286                }
6287                'S' => {
6288                    result = self.filter_by_permission(result, 0o2000, negate, &meta_cache);
6289                    negate = false;
6290                }
6291                't' => {
6292                    result = self.filter_by_permission(result, 0o1000, negate, &meta_cache);
6293                    negate = false;
6294                }
6295
6296                // Full/empty directories
6297                'F' => {
6298                    // Non-empty directories
6299                    result.retain(|f| {
6300                        let path = std::path::Path::new(f);
6301                        let is_nonempty = path.is_dir()
6302                            && std::fs::read_dir(path)
6303                                .map(|mut d| d.next().is_some())
6304                                .unwrap_or(false);
6305                        if negate {
6306                            !is_nonempty
6307                        } else {
6308                            is_nonempty
6309                        }
6310                    });
6311                    negate = false;
6312                }
6313
6314                // Ownership — uses prefetched metadata cache
6315                'U' => {
6316                    // Owned by effective UID
6317                    let euid = unsafe { libc::geteuid() };
6318                    result.retain(|f| {
6319                        let is_owned = meta_cache
6320                            .get(f)
6321                            .and_then(|(m, _)| m.as_ref())
6322                            .map(|m| m.uid() == euid)
6323                            .unwrap_or(false);
6324                        if negate {
6325                            !is_owned
6326                        } else {
6327                            is_owned
6328                        }
6329                    });
6330                    negate = false;
6331                }
6332                'G' => {
6333                    // Owned by effective GID
6334                    let egid = unsafe { libc::getegid() };
6335                    result.retain(|f| {
6336                        let is_owned = meta_cache
6337                            .get(f)
6338                            .and_then(|(m, _)| m.as_ref())
6339                            .map(|m| m.gid() == egid)
6340                            .unwrap_or(false);
6341                        if negate {
6342                            !is_owned
6343                        } else {
6344                            is_owned
6345                        }
6346                    });
6347                    negate = false;
6348                }
6349
6350                // Sorting modifiers
6351                'o' => {
6352                    // Sort by name (ascending) - already default
6353                    if chars.peek() == Some(&'n') {
6354                        chars.next();
6355                        // Sort by name
6356                        result.sort();
6357                    } else if chars.peek() == Some(&'L') {
6358                        chars.next();
6359                        // Sort by size — uses prefetched metadata
6360                        result.sort_by_key(|f| {
6361                            meta_cache
6362                                .get(f)
6363                                .and_then(|(m, _)| m.as_ref())
6364                                .map(|m| m.len())
6365                                .unwrap_or(0)
6366                        });
6367                    } else if chars.peek() == Some(&'m') {
6368                        chars.next();
6369                        // zsh: `om` orders by modification time NEWEST
6370                        // FIRST (the time qualifiers default to
6371                        // descending; `Om` reverses to oldest-first).
6372                        // Was sorting ascending which inverted output.
6373                        result.sort_by_key(|f| {
6374                            meta_cache
6375                                .get(f)
6376                                .and_then(|(m, _)| m.as_ref())
6377                                .and_then(|m| m.modified().ok())
6378                                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
6379                        });
6380                        result.reverse();
6381                    } else if chars.peek() == Some(&'a') {
6382                        chars.next();
6383                        // Same time-default-descending for atime.
6384                        result.sort_by_key(|f| {
6385                            meta_cache
6386                                .get(f)
6387                                .and_then(|(m, _)| m.as_ref())
6388                                .and_then(|m| m.accessed().ok())
6389                                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
6390                        });
6391                        result.reverse();
6392                    } else if chars.peek() == Some(&'c') {
6393                        chars.next();
6394                        // ctime — same default-descending semantics.
6395                        result.sort_by_key(|f| {
6396                            meta_cache
6397                                .get(f)
6398                                .and_then(|(m, _)| m.as_ref())
6399                                .map(|m| {
6400                                    std::time::UNIX_EPOCH
6401                                        + std::time::Duration::from_secs(m.ctime() as u64)
6402                                })
6403                                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
6404                        });
6405                        result.reverse();
6406                    }
6407                }
6408                'O' => {
6409                    // Reverse sort — uses prefetched metadata
6410                    if chars.peek() == Some(&'n') {
6411                        chars.next();
6412                        result.sort();
6413                        result.reverse();
6414                    } else if chars.peek() == Some(&'L') {
6415                        chars.next();
6416                        result.sort_by_key(|f| {
6417                            meta_cache
6418                                .get(f)
6419                                .and_then(|(m, _)| m.as_ref())
6420                                .map(|m| m.len())
6421                                .unwrap_or(0)
6422                        });
6423                        result.reverse();
6424                    } else if chars.peek() == Some(&'m') {
6425                        chars.next();
6426                        // `Om` flips the default time-descending — so
6427                        // `Om` is oldest-first. Just sort ascending.
6428                        result.sort_by_key(|f| {
6429                            meta_cache
6430                                .get(f)
6431                                .and_then(|(m, _)| m.as_ref())
6432                                .and_then(|m| m.modified().ok())
6433                                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
6434                        });
6435                    } else {
6436                        // Just reverse current order
6437                        result.reverse();
6438                    }
6439                }
6440
6441                // Subscript range [n] or [n,m]
6442                '[' => {
6443                    let mut range_str = String::new();
6444                    while let Some(&ch) = chars.peek() {
6445                        if ch == ']' {
6446                            chars.next();
6447                            break;
6448                        }
6449                        range_str.push(chars.next().unwrap());
6450                    }
6451
6452                    if let Some((start, end)) = self.parse_subscript_range(&range_str, result.len())
6453                    {
6454                        result = result.into_iter().skip(start).take(end - start).collect();
6455                    }
6456                }
6457
6458                // Depth limit (for **/)
6459                'D' => {
6460                    // Include dotfiles (handled by dotglob)
6461                }
6462                'N' => {
6463                    // Nullglob for this pattern
6464                }
6465
6466                // Time qualifiers `m` (mtime), `a` (atime), `c` (ctime).
6467                // Format: <qual><unit><op><N> e.g. `mh-100` =
6468                //   mtime within last 100 hours. Units: s (sec), m (min,
6469                //   default), h (hour), d (day, default for none),
6470                //   w (week), M (month, 30d). Ops: `+N` = older than,
6471                //   `-N` = newer than, no op = exactly N (within ±1 unit).
6472                'm' | 'a' | 'c' => {
6473                    let qual_kind = c;
6474                    // Unit (optional, default = days)
6475                    let unit_secs: i64 = match chars.peek().copied() {
6476                        Some('s') => {
6477                            chars.next();
6478                            1
6479                        }
6480                        Some('m') => {
6481                            chars.next();
6482                            60
6483                        }
6484                        Some('h') => {
6485                            chars.next();
6486                            3600
6487                        }
6488                        Some('d') => {
6489                            chars.next();
6490                            86400
6491                        }
6492                        Some('w') => {
6493                            chars.next();
6494                            7 * 86400
6495                        }
6496                        Some('M') => {
6497                            chars.next();
6498                            30 * 86400
6499                        }
6500                        _ => 86400,
6501                    };
6502                    // Op (optional, default = exact)
6503                    let op = match chars.peek().copied() {
6504                        Some('+') => {
6505                            chars.next();
6506                            '+'
6507                        }
6508                        Some('-') => {
6509                            chars.next();
6510                            '-'
6511                        }
6512                        _ => '=',
6513                    };
6514                    // Numeric value
6515                    let mut nstr = String::new();
6516                    while let Some(&nc) = chars.peek() {
6517                        if nc.is_ascii_digit() {
6518                            nstr.push(nc);
6519                            chars.next();
6520                        } else {
6521                            break;
6522                        }
6523                    }
6524                    let n: i64 = nstr.parse().unwrap_or(0);
6525                    let cutoff = n * unit_secs;
6526                    let now = std::time::SystemTime::now()
6527                        .duration_since(std::time::UNIX_EPOCH)
6528                        .map(|d| d.as_secs() as i64)
6529                        .unwrap_or(0);
6530                    result.retain(|f| {
6531                        let m = match meta_cache.get(f).and_then(|(m, _)| m.as_ref()) {
6532                            Some(m) => m,
6533                            None => return false,
6534                        };
6535                        let ts = match qual_kind {
6536                            'm' => m.mtime(),
6537                            'a' => m.atime(),
6538                            'c' => m.ctime(),
6539                            _ => 0,
6540                        };
6541                        let age = now - ts;
6542                        let pass = match op {
6543                            '+' => age > cutoff,
6544                            '-' => age < cutoff,
6545                            _ => age >= cutoff && age < cutoff + unit_secs,
6546                        };
6547                        if negate {
6548                            !pass
6549                        } else {
6550                            pass
6551                        }
6552                    });
6553                    negate = false;
6554                }
6555
6556                // Unknown qualifier - ignore
6557                _ => {}
6558            }
6559        }
6560
6561        // Apply (M) / (T) marking AFTER all filters have run. Direct
6562        // port of zsh/Src/glob.c:355,372 — output emit consults
6563        // gf_markdirs / gf_listtypes set by case 'M' / case 'T'.
6564        if mark_dirs || list_types {
6565            result = result
6566                .into_iter()
6567                .map(|p| {
6568                    let meta = match std::fs::symlink_metadata(&p) {
6569                        Ok(m) => m,
6570                        Err(_) => return p,
6571                    };
6572                    let ch = crate::glob::file_type(meta.permissions().mode());
6573                    if list_types || (mark_dirs && ch == '/') {
6574                        format!("{}{}", p, ch)
6575                    } else {
6576                        p
6577                    }
6578                })
6579                .collect();
6580        }
6581
6582        result
6583    }
6584}
6585
6586// =====================================================================
6587// MOVED FROM: src/ported/glob.rs
6588// =====================================================================
6589
6590impl crate::ported::exec::ShellExecutor {
6591    /// Filter file list by glob qualifiers
6592    /// Prefetch file metadata in parallel across the worker pool.
6593    /// Returns a map from path → (metadata, symlink_metadata).
6594    /// Each batch of files is stat'd on a pool thread.
6595    pub(crate) fn prefetch_metadata(
6596        &self,
6597        files: &[String],
6598    ) -> HashMap<String, (Option<std::fs::Metadata>, Option<std::fs::Metadata>)> {
6599        // After fork(), the worker pool's threads don't survive (POSIX:
6600        // only the calling thread persists). Pipeline children would
6601        // submit work that never gets picked up, blocking forever or
6602        // returning empty. Detect via pid mismatch with the original
6603        // main pid; use serial when forked.
6604        // Worker-pool-fork detection: compare current pid with the
6605        // main shell pid cached at handler-install time. After fork()
6606        // the worker pool's threads don't survive (POSIX leaves only
6607        // the calling thread), so pipeline children must take the
6608        // serial path. This is Rust-runtime concern, not a C
6609        // construct, so the check stays here in `exec_shims.rs`.
6610        let in_forked_child = {
6611            static MAIN_PID: AtomicI32 = AtomicI32::new(0);
6612            let mut main = MAIN_PID.load(Ordering::Relaxed);
6613            if main == 0 {
6614                let cur = nix::unistd::getpid().as_raw();
6615                match MAIN_PID.compare_exchange(0, cur, Ordering::Relaxed, Ordering::Relaxed) {
6616                    Ok(_) => main = cur,
6617                    Err(prev) => main = prev,
6618                }
6619            }
6620            nix::unistd::getpid().as_raw() != main
6621        };
6622        if files.len() < 32 || in_forked_child {
6623            // Small list OR forked child — serial stat is the only
6624            // safe path.
6625            return files
6626                .iter()
6627                .map(|f| {
6628                    let meta = std::fs::metadata(f).ok();
6629                    let symlink_meta = std::fs::symlink_metadata(f).ok();
6630                    (f.clone(), (meta, symlink_meta))
6631                })
6632                .collect();
6633        }
6634
6635        let pool_size = self.worker_pool.size();
6636        let chunk_size = files.len().div_ceil(pool_size);
6637        let (tx, rx) = std::sync::mpsc::channel();
6638
6639        for chunk in files.chunks(chunk_size) {
6640            let tx = tx.clone();
6641            let chunk: Vec<String> = chunk.to_vec();
6642            self.worker_pool.submit(move || {
6643                #[allow(clippy::type_complexity)]
6644                let batch: Vec<(
6645                    String,
6646                    (Option<std::fs::Metadata>, Option<std::fs::Metadata>),
6647                )> = chunk
6648                    .into_iter()
6649                    .map(|f| {
6650                        let meta = std::fs::metadata(&f).ok();
6651                        let symlink_meta = std::fs::symlink_metadata(&f).ok();
6652                        (f, (meta, symlink_meta))
6653                    })
6654                    .collect();
6655                let _ = tx.send(batch);
6656            });
6657        }
6658        drop(tx);
6659
6660        let mut map = HashMap::with_capacity(files.len());
6661        for batch in rx {
6662            for (path, metas) in batch {
6663                map.insert(path, metas);
6664            }
6665        }
6666        map
6667    }
6668    /// Filter files by permission bits — uses prefetched metadata cache
6669    pub(crate) fn filter_by_permission(
6670        &self,
6671        files: Vec<String>,
6672        mode: u32,
6673        negate: bool,
6674        meta_cache: &HashMap<String, (Option<std::fs::Metadata>, Option<std::fs::Metadata>)>,
6675    ) -> Vec<String> {
6676        files
6677            .into_iter()
6678            .filter(|f| {
6679                let has_perm = meta_cache
6680                    .get(f)
6681                    .and_then(|(m, _)| m.as_ref())
6682                    .map(|m| (m.permissions().mode() & mode) != 0)
6683                    .unwrap_or(false);
6684                if negate {
6685                    !has_perm
6686                } else {
6687                    has_perm
6688                }
6689            })
6690            .collect()
6691    }
6692}
6693
6694// =====================================================================
6695// MOVED FROM: src/ported/utils.rs
6696// =====================================================================
6697
6698impl crate::ported::exec::ShellExecutor {
6699    pub(crate) fn copy_dir_recursive(src: &std::path::Path, dest: &std::path::Path) -> std::io::Result<()> {
6700        if !dest.exists() {
6701            std::fs::create_dir_all(dest)?;
6702        }
6703        for entry in std::fs::read_dir(src)? {
6704            let entry = entry?;
6705            let file_type = entry.file_type()?;
6706            let src_path = entry.path();
6707            let dest_path = dest.join(entry.file_name());
6708
6709            if file_type.is_dir() {
6710                Self::copy_dir_recursive(&src_path, &dest_path)?;
6711            } else {
6712                std::fs::copy(&src_path, &dest_path)?;
6713            }
6714        }
6715        Ok(())
6716    }
6717}
6718
6719// =====================================================================
6720// MOVED FROM: src/ported/zle/compcore.rs
6721// =====================================================================
6722
6723impl crate::ported::exec::ShellExecutor {
6724    // zsh compadd - add completion matches
6725    // zsh compset - modify completion prefix/suffix
6726}
6727
6728// =====================================================================
6729// MOVED FROM: src/ported/zle/computil.rs
6730// =====================================================================
6731
6732impl crate::ported::exec::ShellExecutor {
6733}
6734
6735// =====================================================================
6736// MOVED FROM: src/ported/zle/zle_main.rs
6737// =====================================================================
6738
6739impl crate::ported::exec::ShellExecutor {
6740    // `vared` shim — parses the `"AaceghM:m:p:r:i:f:"` BUILTIN spec
6741    // from zle_main.c:2186 into a real `options` struct, then invokes
6742    // the canonical free-fn port at
6743    // crate::ported::zle::zle_main::bin_vared which matches the C
6744    // signature `bin_vared(name, args, ops, func)` exactly.
6745}
6746
6747// =====================================================================
6748// MOVED FROM: src/ported/modules/cap.rs
6749// =====================================================================
6750
6751impl crate::ported::exec::ShellExecutor {
6752    /// `cap` builtin entry. Bridge to `bin_cap()` above.
6753    pub(crate) fn bin_cap(&self, args: &[String]) -> i32 {
6754        let ops = crate::ported::zsh_h::options { ind: [0u8; crate::ported::zsh_h::MAX_OPS], args: Vec::new(), argscount: 0, argsalloc: 0 };
6755        bin_cap("cap", args, &ops, 0)
6756    }
6757
6758    /// `getcap` builtin entry. Bridge to `bin_getcap()` above.
6759    pub(crate) fn bin_getcap(&self, args: &[String]) -> i32 {
6760        let ops = crate::ported::zsh_h::options { ind: [0u8; crate::ported::zsh_h::MAX_OPS], args: Vec::new(), argscount: 0, argsalloc: 0 };
6761        bin_getcap("getcap", args, &ops, 0)
6762    }
6763
6764    /// `setcap` builtin entry. Bridge to `bin_setcap()` above.
6765    pub(crate) fn bin_setcap(&self, args: &[String]) -> i32 {
6766        let ops = crate::ported::zsh_h::options { ind: [0u8; crate::ported::zsh_h::MAX_OPS], args: Vec::new(), argscount: 0, argsalloc: 0 };
6767        bin_setcap("setcap", args, &ops, 0)
6768    }
6769}
6770
6771// =====================================================================
6772// MOVED FROM: src/ported/modules/zpty.rs
6773// =====================================================================
6774
6775impl crate::ported::exec::ShellExecutor {
6776    // `zpty` builtin — delegates to canonical port at
6777    // `src/ported/modules/zpty.rs:367` (`bin_zpty()` from
6778    // `Src/Modules/zpty.c`). The named-pty table lives on
6779    // `ShellExecutor` so `zpty -w NAME ...` and `zpty -r NAME` can
6780    // reach a session started by an earlier `zpty NAME ...` call.
6781}
6782
6783// =====================================================================
6784// MOVED FROM: src/ported/modules/terminfo.rs
6785// =====================================================================
6786
6787impl crate::ported::exec::ShellExecutor {
6788    /// `echoti` shim — delegates to the canonical free-fn port at
6789    /// `crate::ported::modules::terminfo::bin_echoti` (matching C
6790    /// `bin_echoti(nam, args, ops, func)` per terminfo.c:64). The
6791    /// previous shim mapped terminfo→termcap and routed through
6792    /// bin_echotc; the canonical port now calls libcurses tigetstr/
6793    /// tigetnum/tigetflag + tparm directly per the C source.
6794    pub(crate) fn bin_echoti(&mut self, args: &[String]) -> i32 {
6795        let ops = options { ind: [0u8; MAX_OPS], args: Vec::new(),
6796                            argscount: 0, argsalloc: 0 };
6797        crate::ported::modules::terminfo::bin_echoti("echoti", args, &ops, 0)
6798    }
6799}
6800
6801// =====================================================================
6802// MOVED FROM: src/ported/modules/watch.rs
6803// =====================================================================
6804
6805impl crate::ported::exec::ShellExecutor {
6806    // `log` builtin — delegates to canonical port at
6807    // `src/ported/modules/watch.rs` (`bin_log()` from
6808    // `Src/Modules/watch.c`). The watch state lives in
6809    // `thread_local!`s in the canonical port (mirroring C's
6810    // `Src/Modules/watch.c:150-156` file-statics) so login/logout
6811    // edge detection survives across calls without a struct on
6812    // `ShellExecutor`.
6813}
6814
6815// =====================================================================
6816// MOVED FROM: src/ported/modules/pcre.rs
6817// =====================================================================
6818
6819impl crate::ported::exec::ShellExecutor {
6820    // `pcre_compile` builtin — delegates to canonical port at
6821    // `src/ported/modules/pcre.rs` (`bin_pcre_compile()` from
6822    // `Src/Modules/pcre.c:70`). Builds an `options` struct from the
6823    // `-a/-i/-m/-s/-x` flags so the canonical port reads them via
6824    // `OPT_ISSET` exactly like C does.
6825    // `pcre_match` builtin — delegates to canonical port at
6826    // `src/ported/modules/pcre.rs` (`bin_pcre_match()` from
6827    // `Src/Modules/pcre.c:328`). Builds the `options` struct from
6828    // `-v`/`-a` argv (matching C's OPT_ARG reads) and writes the
6829    // returned capture data into the executor's variable/array
6830    // tables — that side-effect cannot live in the canonical port
6831    // because it doesn't own those tables.
6832    // pcre_study - optimize compiled PCRE (no-op in Rust regex)
6833}
6834
6835// =====================================================================
6836// MOVED FROM: src/ported/modules/tcp.rs
6837// =====================================================================
6838
6839impl crate::ported::exec::ShellExecutor {
6840}
6841
6842// =====================================================================
6843// MOVED FROM: src/ported/modules/db_gdbm.rs
6844// =====================================================================
6845
6846impl crate::ported::exec::ShellExecutor {
6847    // Tie a parameter to a GDBM database
6848    // Usage: ztie -d db/gdbm -f /path/to/db.gdbm [-r] PARAM_NAME
6849    // Untie a parameter from its GDBM database
6850    // Usage: zuntie [-u] PARAM_NAME...
6851    // Get the path of a tied GDBM database
6852    // Usage: zgdbmpath PARAM_NAME
6853    // Sets $REPLY to the path
6854}
6855
6856// =====================================================================
6857// MOVED FROM: src/ported/modules/termcap.rs
6858// =====================================================================
6859
6860impl crate::ported::exec::ShellExecutor {
6861    // `echotc` builtin shim — adapts `&[String]` argv to
6862    // `bin_echotc` over a `[bool; 256]` ops bitmask.
6863}
6864
6865// (FAKE `magic_assoc_keys` deleted per user instruction. Callers
6866// in fusevm_bridge.rs route through the canonical scanpm* dispatch
6867// in src/ported/modules/parameter.rs — each magic-assoc table has
6868// its own scanpm* fn that walks the live canonical hashtable
6869// instead of a unified hard-coded switch.)
6870
6871// =====================================================================
6872// Magic-assoc key dispatch — fusevm-bridge aggregator that fans a
6873// magic-assoc table NAME out into the right scanpm* port from
6874// src/ported/modules/parameter.rs.
6875// =====================================================================
6876//
6877// The C source (Src/Modules/parameter.c) doesn't have a single
6878// "scan-by-name" function — each magic-assoc registers its own
6879// per-table getfn/scanfn pointer in the paramdef[] table at
6880// c:825-..., and zsh's paramtab dispatch reaches them through that
6881// table. fusevm_bridge's magic_assoc_lookup needs name → keys
6882// lookup at the call site; that aggregator is THIS Rust-only
6883// convenience, parked outside src/ported/ per the rule that
6884// src/ported/ holds direct C ports only.
6885
6886use std::cell::RefCell;
6887thread_local! {
6888    static SCAN_KEYS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
6889}
6890
6891pub fn scan_magic_assoc_keys(name: &str) -> Option<Vec<String>> {
6892    fn collect<F: FnOnce(Option<crate::ported::zsh_h::ScanFunc>, i32)>(scan: F)
6893        -> Vec<String>
6894    {
6895        SCAN_KEYS.with(|k| k.borrow_mut().clear());
6896        fn cb(node: &crate::ported::zsh_h::HashNode, _flags: i32) {
6897            SCAN_KEYS.with(|k| k.borrow_mut().push(node.nam.clone()));
6898        }
6899        scan(Some(cb), 0);
6900        SCAN_KEYS.with(|k| k.borrow().clone())
6901    }
6902    let null_ht = std::ptr::null_mut();
6903    match name {
6904        "commands"     => Some(collect(|f, fl| scanpmcommands(null_ht, f, fl))),
6905        "options"      => Some(collect(|f, fl| scanpmoptions(null_ht, f, fl))),
6906        "builtins"     => Some(collect(|f, fl| scanpmbuiltins(null_ht, f, fl))),
6907        "dis_builtins" => Some(collect(|f, fl| scanpmdisbuiltins(null_ht, f, fl))),
6908        "functions"    => Some(collect(|f, fl| scanpmfunctions(null_ht, f, fl))),
6909        "dis_functions"=> Some(collect(|f, fl| scanpmdisfunctions(null_ht, f, fl))),
6910        "aliases"      => Some(collect(|f, fl| scanpmraliases(null_ht, f, fl))),
6911        "dis_aliases"  => Some(collect(|f, fl| scanpmdisraliases(null_ht, f, fl))),
6912        "galiases"     => Some(collect(|f, fl| scanpmgaliases(null_ht, f, fl))),
6913        "dis_galiases" => Some(collect(|f, fl| scanpmdisgaliases(null_ht, f, fl))),
6914        "saliases"     => Some(collect(|f, fl| scanpmsaliases(null_ht, f, fl))),
6915        "dis_saliases" => Some(collect(|f, fl| scanpmdissaliases(null_ht, f, fl))),
6916        "reswords" | "dis_reswords" |
6917        "modules" | "history" | "historywords" |
6918        "jobtexts" | "jobstates" | "jobdirs" |
6919        "nameddirs" | "userdirs" | "usergroups" |
6920        "parameters" | "errnos" | "sysparams" | "dirstack"
6921            => Some(Vec::new()),
6922        // `mapfile` — zsh/mapfile module's magic assoc. Keys are
6923        // discoverable via `scanpmmapfile` (Src/Modules/mapfile.c:241)
6924        // which walks `.` but uses an empty value list per the
6925        // comment "grotesquely wasteful to read every file into
6926        // memory." Routing through here lets `${mapfile[$path]}`
6927        // hit get_special_array_value's "mapfile" arm and call
6928        // get_contents() directly.
6929        "mapfile" => Some(
6930            crate::modules::mapfile::scanpmmapfile()
6931                .into_iter()
6932                .map(|(k, _v)| k)
6933                .collect(),
6934        ),
6935        _ => None,
6936    }
6937}
6938
6939// =====================================================================
6940// SubstState bridge — DELETED per user directive ("delete SubstState").
6941//
6942// `subst_state_from_executor` and `subst_state_commit_to_executor`
6943// were Rust-only plumbing that snapshotted executor state into a
6944// `SubstState` struct, then mutated it back out. Both the struct
6945// and the bridge are gone. subst.rs now reads/writes canonical
6946// globals (`utils::errflag`, `hist::hsubl/hsubr/hsubpatopt`,
6947// `options::opt_state_get/set`) and executor state directly via
6948// `fusevm_bridge::try_with_executor`. The single piece of state
6949// the bridge guarded — bumping `exec.last_status` on errflag — now
6950// lives at the per-call site in fusevm_bridge.rs subst_port arms.
6951// =====================================================================
6952
6953impl crate::ported::exec::ShellExecutor {
6954    pub fn enter_posix_mode(&mut self) {
6955        self.posix_mode = true;
6956        self.plugin_cache = None;
6957        self.compsys_cache = None;
6958        self.compinit_pending = None;
6959        self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
6960        let ops = crate::ported::zsh_h::options {
6961            ind: [0u8; crate::ported::zsh_h::MAX_OPS],
6962            args: Vec::new(), argscount: 0, argsalloc: 0,
6963        };
6964        crate::ported::builtin::bin_emulate("emulate",
6965            &["sh".to_string(), "-R".to_string()], &ops, 0);
6966    }
6967    pub fn enter_ksh_mode(&mut self) {
6968        self.plugin_cache = None;
6969        self.compsys_cache = None;
6970        self.compinit_pending = None;
6971        self.worker_pool = std::sync::Arc::new(crate::worker::WorkerPool::new(1));
6972        let ops = crate::ported::zsh_h::options {
6973            ind: [0u8; crate::ported::zsh_h::MAX_OPS],
6974            args: Vec::new(), argscount: 0, argsalloc: 0,
6975        };
6976        crate::ported::builtin::bin_emulate("emulate",
6977            &["ksh".to_string(), "-R".to_string()], &ops, 0);
6978    }
6979}
6980
6981// ─────────────────────────────────────────────────────────
6982// Static glob match — module-level free fn (no executor state).
6983// Extracted from impl ShellExecutor per the FAKE DUP audit.
6984// ─────────────────────────────────────────────────────────
6985/// Static glob match — same logic as glob_match but callable without &self,
6986/// needed for Rayon parallel iterators that can't capture &self.
6987///
6988/// plus extendedglob preprocessing (`^pat` negation, `(a|b)~(x)`
6989/// exclusion). The preprocessing is shell-canonical but lives here
6990/// in exec.rs instead of inside patcompile or a wrapper. To dissolve:
6991/// move the extendedglob `^` / `~` arm into patcompile (or a thin
6992/// canonical pre-walker) so callers use patmatch directly.
6993pub fn glob_match_static(s: &str, pattern: &str) -> bool {
6994    // Extendedglob `^pat` negation: when extendedglob is on AND
6995    // the pattern starts with a literal `^`, strip it and invert
6996    // the match of the remainder. Already done in
6997    // `extendedglob_match` for the param-filter path; do it here
6998    // too so `[[ str = ^pat ]]` works via the cond `=` matcher.
6999    let extendedglob_on =
7000        with_executor(|e| crate::ported::options::opt_state_get("extendedglob").unwrap_or(false));
7001    if extendedglob_on {
7002        if let Some(rest) = pattern.strip_prefix('^') {
7003            return !crate::exec::glob_match_static(s, rest);
7004        }
7005        // Extendedglob `~` exclusion: `pat1~pat2` matches strings
7006        // matching `pat1` AND NOT matching `pat2`. Direct port of
7007        // zsh's pattern.c P_EXCLUDE handling (line 155 onward) for
7008        // the top-level case — the canonical implementation also
7009        // handles nested exclusions (`(a~b)c`) but the top-level
7010        // form is what `*.txt~README*` and similar idioms produce.
7011        // Walk the pattern looking for a `~` that's NOT inside
7012        // `[...]` or `(...)` so nested specials stay literal.
7013        if let Some(idx) = find_top_level_tilde(pattern) {
7014            let lhs = &pattern[..idx];
7015            let rhs = &pattern[idx + 1..];
7016            return crate::exec::glob_match_static(s, lhs)
7017                && !crate::exec::glob_match_static(s, rhs);
7018        }
7019    }
7020
7021    // ksh-style negation `!(p)` (gated on `setopt kshglob`): when
7022    // the entire pattern is `!(<body>)`, match anything that does
7023    // NOT match `<body>`. This handles the standalone case (the
7024    // overwhelmingly common form); embedded `!()` inside a larger
7025    // pattern still falls through and is left literal — full
7026    // zsh-style negation needs lookahead which `regex` lacks.
7027    let kshglob_on = with_executor(|e| crate::ported::options::opt_state_get("kshglob").unwrap_or(false));
7028    if kshglob_on {
7029        if let Some(body) = pattern.strip_prefix("!(").and_then(|r| r.strip_suffix(')')) {
7030            // Don't recurse if body itself contains an unmatched
7031            // `(` that would change the meaning.
7032            let mut depth = 0;
7033            let mut balanced = true;
7034            for c in body.chars() {
7035                match c {
7036                    '(' => depth += 1,
7037                    ')' => {
7038                        if depth == 0 {
7039                            balanced = false;
7040                            break;
7041                        }
7042                        depth -= 1;
7043                    }
7044                    _ => {}
7045                }
7046            }
7047            if balanced && depth == 0 {
7048                return !crate::exec::glob_match_static(s, body);
7049            }
7050        }
7051    }
7052
7053    // Inline pattern flags `(#i)` / `(#I)` / `(#l)` / `(#a<n>)` per
7054    // zshexpn(1) "Globbing Flags". They prefix a pattern and modify
7055    // matching semantics for the rest.
7056    //   (#i) — case insensitive
7057    //   (#I) — case sensitive (turn (#i) back off)
7058    //   (#l) — lowercase pattern char matches both cases in input;
7059    //          uppercase pattern char is exact-match
7060    //   (#a<n>) — approximate match: up to <n> errors (Levenshtein
7061    //          distance, insert/delete/substitute)
7062    // Inline (#i)/(#l)/(#aN) flag pre-parse — direct call to
7063    // patgetglobflags + bit-mask extraction, matching how C's
7064    // patcompile inlines the same parsing at pattern.c:1066+.
7065    let (pattern, case_insensitive, l_flag, approx_n) =
7066        if let Some((bits, _assert, consumed)) =
7067            crate::ported::pattern::patgetglobflags(pattern)
7068        {
7069            let ci = (bits & crate::ported::zsh_h::GF_IGNCASE) != 0;
7070            let l = (bits & crate::ported::zsh_h::GF_LCMATCHUC) != 0;
7071            let errs = bits & 0xff;
7072            let approx = if errs != 0 { Some(errs as u32) } else { None };
7073            (pattern[consumed..].to_string(), ci, l, approx)
7074        } else {
7075            (pattern.to_string(), false, false, None)
7076        };
7077
7078    if let Some(n) = approx_n {
7079        // Inline (#aN) approximate-match — direct port of the
7080        // Levenshtein-distance check inside patmatch (Src/pattern.c)
7081        // when PAT_APPROX is set. m/k bound check skips early when
7082        // the strings differ in length by more than the budget;
7083        // otherwise standard 2-row DP table.
7084        let s_chars: Vec<char> = s.chars().collect();
7085        let p_chars: Vec<char> = pattern.chars().collect();
7086        let m = s_chars.len();
7087        let k = p_chars.len();
7088        if m.abs_diff(k) as u32 > n {
7089            return false;
7090        }
7091        let mut prev: Vec<usize> = (0..=k).collect();
7092        let mut curr: Vec<usize> = vec![0; k + 1];
7093        for i in 1..=m {
7094            curr[0] = i;
7095            for j in 1..=k {
7096                let cost = if s_chars[i - 1] == p_chars[j - 1] { 0 } else { 1 };
7097                curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
7098            }
7099            std::mem::swap(&mut prev, &mut curr);
7100        }
7101        return prev[k] as u32 <= n;
7102    }
7103
7104    // Build the regex. For (#l) we need to inflate lowercase chars
7105    // to character classes that match either case. Also detect
7106    // zsh's numeric-range glob `<a-b>` (or `<->` for any number,
7107    // `<a->` / `<-b>` for one-sided ranges) — translate to a
7108    // capture group and remember the bounds for a post-match check.
7109    let mut regex_pattern = String::from("^");
7110    // Numeric ranges paired with the regex capture-group index they
7111    // correspond to. Required because user-written `(...)` groups
7112    // in the pattern (esp. alternation `(a|b)`) shift capture
7113    // indices, so we can't assume each `<N-M>` is at numeric_ranges
7114    // index + 1. Direct port of the bookkeeping zsh's pattern.c
7115    // does via `pat_captures` — each numeric atom remembers its
7116    // own group offset. Without this, `[[ 5.9 == (5.<1->*|<6->.*) ]]`
7117    // applied the lo/hi check against the OUTER alternation's
7118    // capture (the literal "5.9") and parse-as-int failed.
7119    let mut numeric_ranges: Vec<(usize, Option<i64>, Option<i64>)> = Vec::new();
7120    // Track the capture-group index. Increments on every `(` that
7121    // OPENS a new group in the emitted regex. Starts at 0 because
7122    // the outer `^...$` anchors don't add a group.
7123    let mut capture_group_count: usize = 0;
7124    let mut chars = pattern.chars().peekable();
7125    // Helper: after emitting any atom, check for zsh extendedglob
7126    // postfix `#` (zero-or-more) / `##` (one-or-more) and append
7127    // the equivalent regex quantifier. Direct port of zsh's
7128    // pattern.c (`Pound` / `POUND2` cases in `patcompswitch`).
7129    // Only fires when extendedglob is enabled.
7130    let consume_extglob_postfix =
7131        |chars: &mut std::iter::Peekable<std::str::Chars>| -> Option<&'static str> {
7132            if !extendedglob_on {
7133                return None;
7134            }
7135            if chars.peek() != Some(&'#') {
7136                return None;
7137            }
7138            chars.next();
7139            if chars.peek() == Some(&'#') {
7140                chars.next();
7141                Some("+")
7142            } else {
7143                Some("*")
7144            }
7145        };
7146    while let Some(c) = chars.next() {
7147        match c {
7148            // ksh-style extglob: ?(p) *(p) +(p) @(p) — translate to
7149            // (?:p)? (?:p)* (?:p)+ (?:p) respectively. Gated on
7150            // the `kshglob` option (zsh's default is off). The
7151            // !(p) (negative) form needs lookahead which the
7152            // `regex` crate doesn't support; left literal.
7153            '?' | '*' | '+' | '@'
7154                if chars.peek() == Some(&'(')
7155                    && with_executor(|e| {
7156                        crate::ported::options::opt_state_get("kshglob").unwrap_or(false)
7157                    }) =>
7158            {
7159                let op = c;
7160                chars.next(); // consume '('
7161                              // Capture body until matching ')'. Track depth so
7162                              // nested parens work.
7163                let mut depth = 1;
7164                let mut body = String::new();
7165                while let Some(&pc) = chars.peek() {
7166                    chars.next();
7167                    if pc == '(' {
7168                        depth += 1;
7169                        body.push(pc);
7170                    } else if pc == ')' {
7171                        depth -= 1;
7172                        if depth == 0 {
7173                            break;
7174                        }
7175                        body.push(pc);
7176                    } else {
7177                        body.push(pc);
7178                    }
7179                }
7180                // Inline ksh-extglob body -> regex translator.
7181                // Direct port of the tiny per-char dispatch zsh's
7182                // pattern.c does inside its extglob handler — no
7183                // anchors, no (#flags), just glob -> regex chars.
7184                let body_re = {
7185                    let mut out = String::new();
7186                    let mut chars = body.chars().peekable();
7187                    while let Some(c) = chars.next() {
7188                        match c {
7189                            '|' => out.push('|'),
7190                            '*' => out.push_str(".*"),
7191                            '?' => out.push('.'),
7192                            '[' => {
7193                                out.push('[');
7194                                for cc in chars.by_ref() {
7195                                    if cc == ']' {
7196                                        out.push(']');
7197                                        break;
7198                                    }
7199                                    out.push(cc);
7200                                }
7201                            }
7202                            '.' | '+' | '^' | '$' | '\\' | '{' | '}' | '(' | ')' => {
7203                                out.push('\\');
7204                                out.push(c);
7205                            }
7206                            _ => out.push(c),
7207                        }
7208                    }
7209                    out
7210                };
7211                let suffix = match op {
7212                    '?' => "?",
7213                    '*' => "*",
7214                    '+' => "+",
7215                    '@' => "",
7216                    _ => "",
7217                };
7218                regex_pattern.push_str(&format!("(?:{}){}", body_re, suffix));
7219            }
7220            '*' => regex_pattern.push_str(".*"),
7221            '?' => {
7222                regex_pattern.push('.');
7223                if let Some(q) = consume_extglob_postfix(&mut chars) {
7224                    regex_pattern.push_str(q);
7225                }
7226            }
7227            '<' => {
7228                // Try to parse `<lo-hi>`. If the form doesn't
7229                // match, fall back to literal `<`. Direct port of
7230                // zsh's numeric-range glob handler — speculative
7231                // scan for the closing `>`, split on `-`, parse
7232                // optional bounds. Matches `<5-10>`, `<5->`,
7233                // `<-10>`, `<->`.
7234                let parsed: Option<(Option<i64>, Option<i64>, usize)> = (|| {
7235                    let mut buf = String::new();
7236                    let peek_iter = chars.clone();
7237                    for c in peek_iter {
7238                        buf.push(c);
7239                        if c == '>' { break; }
7240                        if buf.len() > 64 { return None; }
7241                    }
7242                    if !buf.ends_with('>') {
7243                        return None;
7244                    }
7245                    let inner = &buf[..buf.len() - 1];
7246                    let (lo_str, hi_str) = inner.split_once('-')?;
7247                    let lo: Option<i64> = if lo_str.is_empty() {
7248                        None
7249                    } else {
7250                        Some(lo_str.parse().ok()?)
7251                    };
7252                    let hi: Option<i64> = if hi_str.is_empty() {
7253                        None
7254                    } else {
7255                        Some(hi_str.parse().ok()?)
7256                    };
7257                    let n = buf.chars().count();
7258                    for _ in 0..n { chars.next(); }
7259                    Some((lo, hi, n))
7260                })();
7261                if let Some((lo, hi, consumed)) = parsed {
7262                    regex_pattern.push_str("(\\d+)");
7263                    capture_group_count += 1;
7264                    numeric_ranges.push((capture_group_count, lo, hi));
7265                    let _ = consumed;
7266                } else {
7267                    regex_pattern.push('<');
7268                }
7269            }
7270            '[' => {
7271                // Direct port of zsh's character-class compile
7272                // (pattern.c, see `patcompcls` and the `[`
7273                // handling in `patcompswitch`):
7274                //   - `[!...]` and `[^...]` both negate (POSIX +
7275                //     zsh both accept; only `^` is canonical
7276                //     regex). Translate `!` -> `^` so the regex
7277                //     crate sees the right form. Was being
7278                //     copied verbatim, so `[!a]` matched `!` or
7279                //     `a` instead of "anything but a".
7280                //   - POSIX character classes `[:alpha:]` /
7281                //     `[:digit:]` etc. inside `[...]` already
7282                //     pass through the regex crate, but the
7283                //     trailing `]` of the class would be misread
7284                //     as the closing of the outer bracket. Walk
7285                //     past `[:NAME:]` as a unit so the next `]`
7286                //     after the class isn't taken as the close.
7287                //   - Backslash-escaped `]` (`[\\]]`) keeps the
7288                //     `]` as a literal class member.
7289                regex_pattern.push('[');
7290                let mut first = true;
7291                while let Some(cc) = chars.next() {
7292                    if first && cc == '!' {
7293                        regex_pattern.push('^');
7294                        first = false;
7295                        continue;
7296                    }
7297                    first = false;
7298                    if cc == ']' {
7299                        regex_pattern.push(']');
7300                        break;
7301                    }
7302                    if cc == '\\' {
7303                        // Pass escape + next char through.
7304                        regex_pattern.push('\\');
7305                        if let Some(nx) = chars.next() {
7306                            regex_pattern.push(nx);
7307                        }
7308                        continue;
7309                    }
7310                    if cc == '[' && chars.peek() == Some(&':') {
7311                        // POSIX class `[:NAME:]`. Read until
7312                        // `:]` then push the class verbatim.
7313                        regex_pattern.push('[');
7314                        let mut prev_colon = false;
7315                        for ic in chars.by_ref() {
7316                            regex_pattern.push(ic);
7317                            if prev_colon && ic == ']' {
7318                                break;
7319                            }
7320                            prev_colon = ic == ':';
7321                        }
7322                        continue;
7323                    }
7324                    regex_pattern.push(cc);
7325                }
7326                // After a closed `[...]`, the bracket is a single
7327                // regex atom — apply extendedglob `#`/`##`
7328                // postfix as `*`/`+` directly.
7329                if let Some(q) = consume_extglob_postfix(&mut chars) {
7330                    regex_pattern.push_str(q);
7331                }
7332            }
7333            '(' => {
7334                // `(#cN)` and `(#cN,M)` post-subpattern repetition
7335                // qualifiers: the previous element gets a `{N}` or
7336                // `{N,M}` regex quantifier. Detect by peeking for
7337                // `#c` after the opening `(`.
7338                let peek_iter = chars.clone();
7339                let mut probe: Vec<char> = Vec::new();
7340                let p = peek_iter;
7341                for pc in p {
7342                    probe.push(pc);
7343                    if pc == ')' || probe.len() > 32 {
7344                        break;
7345                    }
7346                }
7347                let probe_str: String = probe.iter().collect();
7348                if probe_str.starts_with("#c") && probe_str.ends_with(')') {
7349                    let body = &probe_str[2..probe_str.len() - 1];
7350                    let quant = if let Some((lo, hi)) = body.split_once(',') {
7351                        format!("{{{},{}}}", lo, hi)
7352                    } else {
7353                        format!("{{{}}}", body)
7354                    };
7355                    regex_pattern.push_str(&quant);
7356                    // Advance the real iterator past the consumed chars.
7357                    for _ in 0..probe.len() {
7358                        chars.next();
7359                    }
7360                } else if probe_str == "#e)" {
7361                    // `(#e)` — match end-of-string anchor. Direct
7362                    // port of zsh's pattern.c P_EOL token (zsh's
7363                    // "globbing flag" `(#e)` per zshexpn(1)).
7364                    // Emits regex `$` to anchor the match at the
7365                    // end of the input. Used by zinit's
7366                    // `(#b)((*)\\(#e)|(*))` to detect a trailing
7367                    // `\` in each element.
7368                    regex_pattern.push('$');
7369                    for _ in 0..probe.len() {
7370                        chars.next();
7371                    }
7372                } else if probe_str == "#s)" {
7373                    // `(#s)` — match start-of-string anchor.
7374                    // zshexpn(1): "matches at the start of the
7375                    // test string". Emits regex `^`.
7376                    regex_pattern.push('^');
7377                    for _ in 0..probe.len() {
7378                        chars.next();
7379                    }
7380                } else {
7381                    regex_pattern.push('(');
7382                    capture_group_count += 1;
7383                }
7384            }
7385            ')' => {
7386                regex_pattern.push(')');
7387                // Closed group is an atom — extendedglob `#`/`##`
7388                // postfix applies to the whole group.
7389                if let Some(q) = consume_extglob_postfix(&mut chars) {
7390                    regex_pattern.push_str(q);
7391                }
7392            }
7393            '|' => regex_pattern.push('|'),
7394            '\\' => {
7395                // Special-case: `\(#e)` / `\(#s)` — literal
7396                // backslash followed by extendedglob end/start
7397                // anchor. Emit `\\$` / `\\^` so the pattern matches
7398                // a literal trailing/leading `\`. Without this the
7399                // `(` of `(#e)` got consumed as the escaped char,
7400                // dropping the anchor entirely. Direct port of
7401                // pattern.c P_EOL/P_BOL recognition after a `\`.
7402                // Only fires under extendedglob — without the
7403                // option, `(#e)` is not a token at all.
7404                if extendedglob_on {
7405                    let mut peek = chars.clone();
7406                    let p1 = peek.next();
7407                    let p2 = peek.next();
7408                    let p3 = peek.next();
7409                    let p4 = peek.next();
7410                    if p1 == Some('(')
7411                        && p2 == Some('#')
7412                        && (p3 == Some('e') || p3 == Some('s'))
7413                        && p4 == Some(')')
7414                    {
7415                        regex_pattern.push_str("\\\\");
7416                        regex_pattern.push(if p3 == Some('e') { '$' } else { '^' });
7417                        chars.next(); chars.next(); chars.next(); chars.next();
7418                        continue;
7419                    }
7420                }
7421                // Backslash escapes the next char — treat literally.
7422                if let Some(next) = chars.next() {
7423                    if matches!(
7424                        next,
7425                        '.' | '+'
7426                            | '^'
7427                            | '$'
7428                            | '\\'
7429                            | '{'
7430                            | '}'
7431                            | '*'
7432                            | '?'
7433                            | '('
7434                            | ')'
7435                            | '|'
7436                            | '['
7437                            | ']'
7438                    ) {
7439                        regex_pattern.push('\\');
7440                    }
7441                    regex_pattern.push(next);
7442                } else {
7443                    regex_pattern.push_str("\\\\");
7444                }
7445            }
7446            '.' | '+' | '^' | '$' | '{' | '}' => {
7447                regex_pattern.push('\\');
7448                regex_pattern.push(c);
7449            }
7450            _ => {
7451                if l_flag && c.is_ascii_lowercase() {
7452                    regex_pattern.push('[');
7453                    regex_pattern.push(c);
7454                    regex_pattern.push(c.to_ascii_uppercase());
7455                    regex_pattern.push(']');
7456                } else {
7457                    regex_pattern.push(c);
7458                }
7459                // After a literal/(#l)-class atom, extendedglob
7460                // `#`/`##` postfix maps to regex `*`/`+` and
7461                // binds to that single atom. Same as zsh's
7462                // pattern.c Pound/POUND2 handling on the atom
7463                // just compiled.
7464                if let Some(q) = consume_extglob_postfix(&mut chars) {
7465                    regex_pattern.push_str(q);
7466                }
7467            }
7468        }
7469    }
7470    regex_pattern.push('$');
7471    let final_pattern = if case_insensitive {
7472        format!("(?i){}", regex_pattern)
7473    } else {
7474        regex_pattern
7475    };
7476    if !numeric_ranges.is_empty() {
7477        // Need captures + per-group numeric range checks.
7478        let re = match regex::Regex::new(&final_pattern) {
7479            Ok(re) => re,
7480            Err(_) => return false,
7481        };
7482        let caps = match re.captures(s) {
7483            Some(c) => c,
7484            None => return false,
7485        };
7486        for (group_idx, lo, hi) in numeric_ranges.iter() {
7487            // A numeric-range `<N-M>` inside an alternation branch
7488            // that didn't fire (e.g. branch B of `(A|B)` when A
7489            // matched) won't have a populated capture. Skip the
7490            // bounds check for those — the alternation's match
7491            // already commits to the branch that DID fire.
7492            let cap_str = match caps.get(*group_idx) {
7493                Some(m) => m.as_str(),
7494                None => continue,
7495            };
7496            let n: i64 = match cap_str.parse() {
7497                Ok(n) => n,
7498                Err(_) => return false,
7499            };
7500            if let Some(l) = lo {
7501                if n < *l {
7502                    return false;
7503                }
7504            }
7505            if let Some(h) = hi {
7506                if n > *h {
7507                    return false;
7508                }
7509            }
7510        }
7511        return true;
7512    }
7513    regex::Regex::new(&final_pattern)
7514        .map(|re| re.is_match(s))
7515        .unwrap_or(false)
7516}
7517
7518
7519// =============================================================================
7520// Direct ports from Src/exec.c — fns whose Rust home was misplaced in
7521// builtin.rs because they're called from `bin_autoload` but actually
7522// defined in Src/exec.c.
7523// =============================================================================
7524
7525/// Direct port of `Shfunc loadautofn(Shfunc shf, int ks, int test_only,
7526/// int ignore_loaddir)` from `Src/exec.c:5050`. Walks `$fpath` for a
7527/// file named `shf->node.nam`, reads it, installs the text body on
7528/// the corresponding `shfunctab` entry, and clears `PM_UNDEFINED`.
7529///
7530/// C body (abridged):
7531///   1. `name = shf->node.nam`
7532///   2. `getfpfunc(name, &dir_path, NULL, 0)` → resolved file path
7533///   3. If !test_only && file found: parse → store eprog on
7534///      `shf->funcdef`; clear PM_UNDEFINED; set `shf->filename`.
7535///   4. Returns shf on success, NULL on failure.
7536///
7537/// Rust port: returns 0 = success, 1 = failure (matches the
7538/// existing call-site convention in `bin_functions -c`). Stores
7539/// raw file text on `ShFunc.body` (the Rust-side ShFunc in
7540/// `hashtable.rs:362`); the parser pass that converts text →
7541/// Eprog runs lazily at first call site.
7542/// Port of `loadautofn(Shfunc shf, int fksh, int autol, int current_fpath)` from `Src/exec.c:5682`.
7543pub fn loadautofn(shf: *mut crate::ported::zsh_h::shfunc,                        // c:5682 (Src/exec.c)
7544              _ks: i32, test_only: i32, _ignore_loaddir: i32) -> i32 {
7545    if shf.is_null() {
7546        return 1;
7547    }
7548    // c:5054 — `name = shf->node.nam`.
7549    let name = unsafe { (*shf).node.nam.clone() };
7550    // c:5070 — `path = getfpfunc(name, &dir_path, NULL, 0)`.
7551    let mut dir_path: Option<String> = None;
7552    let path = match getfpfunc(&name, &mut dir_path, None, 0) {
7553        Some(p) => p,
7554        None => return 1,                                                    // c:5074 not found
7555    };
7556    if test_only != 0 {                                                      // c:5096
7557        return 0;                                                            // test passes — file exists
7558    }
7559    // c:5100-5140 — read the file. C uses zopen + read + parse_string +
7560    // execsave; Rust port stores raw text on the ShFunc and defers
7561    // parse-to-Eprog until the first call.
7562    let body = match std::fs::read_to_string(&path) {
7563        Ok(t) => t,
7564        Err(_) => return 1,
7565    };
7566    // c:5142 — `shf->filename = ztrdup(dir_path)`.
7567    unsafe {
7568        (*shf).filename = dir_path.clone().or(Some(path.clone()));
7569    }
7570    // c:5148 — `shf->node.flags &= ~PM_UNDEFINED`.
7571    unsafe {
7572        (*shf).node.flags &= !(PM_UNDEFINED as i32);
7573    }
7574    // Sync the body string into the Rust-side ShFunc table so the
7575    // lazy-parse path can find it later.
7576    if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
7577        if let Some(existing) = tab.get_mut(&name) {
7578            existing.body = Some(body);
7579            existing.filename = dir_path;
7580        } else {
7581            tab.add(crate::ported::hashtable::ShFunc {
7582                node: crate::ported::zsh_h::hashnode {
7583                    next: None,
7584                    nam: name.clone(),
7585                    flags: 0,
7586                },
7587                filename: dir_path,
7588                lineno: 0,
7589                funcdef: None,
7590                redir: None,
7591                sticky: None,
7592                body: Some(body),
7593            });
7594        }
7595    }
7596    0
7597}
7598
7599/// Port of `getfpfunc(char *s, int *ksh, char **fdir, char **alt_path, int test_only)` from Src/exec.c:5260. Walks `$fpath` (or the
7600/// supplied `spec_path` slice) for a file named `name` and writes the
7601/// resolved directory through `*dir_path_out` (matching the C `char **dir_path`).
7602/// Returns `Some(file_contents_path)` on success, `None` when not found.
7603pub fn getfpfunc(name: &str, dir_path_out: &mut Option<String>,                  // c:5260 (Src/exec.c)
7604             spec_path: Option<&[String]>, _all_loaded: i32) -> Option<String> {
7605    let dirs: Vec<String> = match spec_path {
7606        Some(s) => s.to_vec(),
7607        None => std::env::var("FPATH").or_else(|_| std::env::var("fpath"))
7608            .ok().map(|v| v.split(':').map(String::from).collect())
7609            .unwrap_or_default(),
7610    };
7611    for dir in &dirs {
7612        if dir.is_empty() { continue; }
7613        let path = format!("{}/{}", dir, name);
7614        if std::path::Path::new(&path).exists() {
7615            *dir_path_out = Some(dir.clone());
7616            return Some(path);
7617        }
7618    }
7619    None
7620}