Skip to main content

zsh/
exec.rs

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