Skip to main content

zsh/
fusevm_bridge.rs

1//! fusevm bytecode-VM bridge for ShellExecutor.
2//!
3//! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4//! !!! LAST-RESORT FILE — NOT FOR NEW LOGIC !!!
5//!
6//! This file is a **bridge**, not a port. It exists ONLY because zshrs uses
7//! a fusevm bytecode VM where C zsh uses its own wordcode walker (Src/exec.c
8//! `execlist`). Every line here is plumbing that hooks fusevm opcodes onto
9//! the canonical ports in `src/ported/`.
10//!
11//! **Before adding code to this file, STOP and ask:**
12//!
13//!   1. Is this logic that already lives in `src/ported/`?
14//!      → Call the canonical fn. Don't reinline.
15//!
16//!   2. Is this logic that SHOULD live in `src/ported/` but isn't ported yet?
17//!      → Port it. Add it to `src/ported/<file>.rs` with a `c:` citation.
18//!        Then call the canonical fn from here.
19//!
20//!   3. Is this purely fusevm/bytecode plumbing (Op decode, Value conversion,
21//!      VM-stack manipulation, thread-local executor pointer, etc.)?
22//!      → OK to put it here. Cite the closest C analog in the comment.
23//!
24//! **NEVER:** reinvent paramsubst/expansion/glob/typeset/redirect logic here.
25//! Those have canonical ports in `src/ported/subst.rs`, `src/ported/glob.rs`,
26//! `src/ported/builtin.rs`, etc. The bridge should be SHRINKING over time,
27//! not growing.
28//!
29//! See also: memory `feedback_no_shortcuts_in_porting` (port C bodies
30//! faithfully, no structural shells), `feedback_no_exec_script_from_ported`
31//! (the inverse direction — src/ported must not call back into the bridge).
32//! ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
33//!
34//! **Extension** — has no Src/exec.c counterpart. C zsh's `Src/exec.c::execlist`
35//! (and related routines) implement the native **wordcode VM** that executes
36//! compiler output from `parse.c`. zshrs compiles the parsed AST to fusevm
37//! bytecode and runs it on a stack VM; this
38//! file holds the bridge between fusevm's `ShellHost` trait and our
39//! `ShellExecutor` state, the thread-local executor pointer, all
40//! `BUILTIN_*` opcode constants, and the giant `register_builtins`
41//! handler table that wires zsh builtins onto fusevm CallBuiltin
42//! opcodes.
43
44#![allow(unused_imports)]
45
46use indexmap::IndexMap;
47use std::collections::{HashMap, HashSet};
48use std::env;
49use std::path::PathBuf;
50
51use crate::exec_jobs::JobState;
52use crate::intercepts::Intercept;
53use crate::ported::vm_helper::*;
54use std::io::Write;
55
56// ═══════════════════════════════════════════════════════════════════════════
57// Thread-local executor context for VM builtin dispatch
58// ═══════════════════════════════════════════════════════════════════════════
59
60use crate::ported::options::opt_state_get;
61use crate::ported::zsh_h::{isset, options, ERREXIT, MAX_OPS};
62use fusevm::op::redirect_op as r;
63use fusevm::shell_builtins::*;
64use fusevm::Value;
65use std::cell::{Cell, RefCell};
66use std::cmp::Ordering;
67use std::ffi::CString;
68use std::fs;
69use std::io::BufRead;
70use std::io::Read;
71use std::io::Write as _;
72use std::os::unix::fs::FileTypeExt;
73use std::os::unix::fs::MetadataExt;
74use std::os::unix::fs::PermissionsExt;
75use std::os::unix::io::AsRawFd;
76use std::os::unix::io::IntoRawFd;
77use std::time::Instant;
78use std::time::{SystemTime, UNIX_EPOCH};
79
80thread_local! {
81    /// Mirror of C zsh's `doneps4` local in execcmd_exec
82    /// (Src/exec.c:2517+). Tracks whether PS4 has been emitted
83    /// for the current xtrace line so a coalesced sequence of
84    /// XTRACE_ASSIGN + XTRACE_ARGS produces ONE line:
85    ///   `<PS4>a=1 b=2 echo 1 2\n`
86    /// instead of three. Reset to false by XTRACE_ARGS /
87    /// XTRACE_NEWLINE after emitting the trailing `\n`.
88    static XTRACE_DONE_PS4: Cell<bool> = const { Cell::new(false) };
89
90    /// Stack of (RETFLAG, BREAKS, CONTFLAG, EXIT_PENDING) tuples saved
91    /// at try-block exit so the always-arm body can run cleanly even
92    /// when the try-block fired `return` / `break` / `continue` /
93    /// `exit`. Restored right before the post-always re-jump so the
94    /// escape resumes propagation past the construct.
95    /// c:Src/exec.c WC_TRYBLOCK — zsh's wordcode walker handles this
96    /// inline; the zshrs port lifts it into a paired SET / RESTORE
97    /// pair around the always-arm.
98    static TRY_ESCAPE_SAVE: RefCell<Vec<(i32, i32, i32, i32)>> =
99        const { RefCell::new(Vec::new()) };
100    /// Re-entry guard for BUILTIN_DEBUG_TRAP. While the DEBUG trap
101    /// body is running, the per-statement DEBUG_TRAP dispatch in the
102    /// trap body must NOT re-fire (otherwise infinite recursion +
103    /// stack overflow). zsh's in_trap counter at Src/signals.c
104    /// serves the same purpose.
105    static DEBUG_TRAP_REENTRY: Cell<bool> = const { Cell::new(false) };
106    /// Stack of (saved_stdout, saved_stderr) tuples pushed by
107    /// `cmd_subst` around its nested-VM run. RUST-ONLY: zsh forks
108    /// each cmdsub so trap output during the cmdsub naturally
109    /// lands on the PARENT's stdout. zshrs's in-process cmdsub
110    /// dups fd 1 → pipe, so a trap firing during cmdsub would
111    /// emit into the captured value. Traps consult this stack
112    /// to route their body output to the topmost saved_stdout
113    /// instead of the cmdsub's fd 1. Bug #56 in docs/BUGS.md.
114    pub static CMDSUBST_OUTER_FDS: RefCell<Vec<(i32, i32)>> =
115        const { RefCell::new(Vec::new()) };
116}
117
118/// Peek the outermost cmdsub-saved (stdout, stderr) fds, if any.
119/// Returns None when no cmdsub is currently capturing. Used by the
120/// trap dispatcher in `src/ported/signals.rs::dotrap` to route trap
121/// body output to the parent's real stdout (matching zsh's forked
122/// cmdsub behaviour) instead of the cmdsub's pipe-bound fd 1.
123/// Bug #56 in docs/BUGS.md.
124pub fn cmdsubst_outer_stdout() -> Option<i32> {
125    CMDSUBST_OUTER_FDS.with(|s| s.borrow().last().map(|(o, _)| *o))
126}
127
128// Thread-local pointer to the current ShellExecutor.
129// Set before VM execution, cleared after. Used by builtin handlers.
130thread_local! {
131    static CURRENT_EXECUTOR: RefCell<Option<*mut ShellExecutor>> = const { RefCell::new(None) };
132    /// Set by subshell_end after a deferred subshell `exit N` lands.
133    /// Read + cleared by the next GET_VAR sync_status path so the
134    /// vm.last_status → LASTVAL sync doesn't clobber the deferred
135    /// exit status. RUST-ONLY: needed because zshrs runs subshells
136    /// in-process (no fork) so vm.last_status doesn't track the
137    /// subshell's exit; C zsh's subshell forks and the child's
138    /// process::exit(N) becomes $? in the parent automatically.
139    static SUBSHELL_EXIT_STATUS_PENDING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
140}
141
142/// RAII guard that sets/clears the thread-local executor pointer.
143///
144/// Idempotent: calling `enter` when a context is already active is a no-op
145/// for the entry side, and the guard's drop only clears the thread-local if
146/// *this* call was the one that set it. Nested `execute_command` invocations
147/// (e.g. from inside a builtin handler) reuse the outer pointer instead of
148/// stomping it.
149pub(crate) struct ExecutorContext {
150    we_set_it: bool,
151}
152
153impl ExecutorContext {
154    pub(crate) fn enter(executor: &mut ShellExecutor) -> Self {
155        let we_set_it = CURRENT_EXECUTOR.with(|cell| {
156            let mut slot = cell.borrow_mut();
157            if slot.is_some() {
158                false
159            } else {
160                *slot = Some(executor as *mut ShellExecutor);
161                true
162            }
163        });
164        ExecutorContext { we_set_it }
165    }
166}
167
168impl Drop for ExecutorContext {
169    fn drop(&mut self) {
170        if self.we_set_it {
171            CURRENT_EXECUTOR.with(|cell| {
172                *cell.borrow_mut() = None;
173            });
174        }
175    }
176}
177
178/// Access the current executor from a builtin handler.
179/// # Safety
180/// Only call this from within a VM execution context (after ExecutorContext::enter).
181#[inline]
182pub(crate) fn with_executor<F, R>(f: F) -> R
183where
184    F: FnOnce(&mut ShellExecutor) -> R,
185{
186    CURRENT_EXECUTOR.with(|cell| {
187        let ptr = cell
188            .borrow()
189            .expect("with_executor called outside VM context");
190        // SAFETY: The pointer is valid for the duration of VM execution,
191        // and we're single-threaded within the executor.
192        let executor = unsafe { &mut *ptr };
193        f(executor)
194    })
195}
196
197/// Look up a canonical builtin by name in `BUILTINS` and dispatch
198/// via `execbuiltin` (Src/builtin.c:250). NO shadow check — calls the
199/// builtin even if a user function with the same name exists. Used by
200/// the `builtin foo` prefix opcode (which explicitly bypasses function
201/// lookup per zsh semantics) and by internal call sites where shadowing
202/// is unwanted. For zsh's normal name-resolution order (function shadows
203/// builtin), use `dispatch_builtin` instead.
204/// Shell-identifier prefix for diagnostic lines. Reads the canonical
205/// scriptname (`zsh` in `--zsh` parity mode, `zshrs` otherwise) so a
206/// single helper replaces hardcoded `"zshrs:"` literals across the
207/// file's eprintln paths.
208fn shname() -> String {
209    crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string())
210}
211
212/// Map a builtin name to the zsh module that owns it, IFF zsh does
213/// not auto-load that builtin on first use. Used by
214/// `dispatch_builtin_raw` to gate `--zsh` mode dispatch behind
215/// `zmodload`, mirroring `zsh -fc <name>` returning 127 for these
216/// names without an explicit module load.
217///
218/// Returns `Some(module_name)` if `name` belongs to a non-auto-load
219/// module per the per-module `Src/Modules/<x>.c` `bintab[]` plus
220/// the auto-load flag set at module-build time. `None` for core
221/// builtins and for auto-loaded module builtins (sched, log, echotc,
222/// echoti, zformat, zparseopts, zregexparse, zstyle, strftime,
223/// private, vared, zle, bindkey, comp*) which work without zmodload.
224fn module_bound_builtin_module(name: &str) -> Option<&'static str> {
225    match name {
226        "zftp" => Some("zsh/zftp"),
227        "zsocket" => Some("zsh/net/socket"),
228        "ztcp" => Some("zsh/net/tcp"),
229        "zstat" => Some("zsh/stat"),
230        "zselect" => Some("zsh/zselect"),
231        "zpty" => Some("zsh/zpty"),
232        "zprof" => Some("zsh/zprof"),
233        "zsystem" | "syserror" => Some("zsh/system"),
234        "clone" => Some("zsh/clone"),
235        "zcurses" => Some("zsh/curses"),
236        "ztie" | "zuntie" | "zgdbmpath" => Some("zsh/db/gdbm"),
237        "pcre_compile" | "pcre_match" | "pcre_study" => Some("zsh/pcre"),
238        "example" => Some("zsh/example"),
239        "cap" | "getcap" | "setcap" => Some("zsh/cap"),
240        "zgetattr" | "zsetattr" | "zdelattr" | "zlistattr" => Some("zsh/attr"),
241        // c:Src/Modules/datetime.c — `strftime` is registered via
242        // partab[] when zsh/datetime loads. Verified by
243        // `zsh -fc 'strftime -s s %Y 0'` → 127 "command not found".
244        "strftime" => Some("zsh/datetime"),
245        _ => None,
246    }
247}
248
249pub(crate) fn dispatch_builtin_raw(name: &str, args: Vec<String>) -> i32 {
250    // c:Bugs #475/#504/#555 — bash-only builtins (`mapfile`,
251    // `readarray`, `compopt`) should emit "command not found" in
252    // `--zsh` mode matching zsh's external-command-lookup miss.
253    // The per-opcode closures for caller/help/complete/compgen
254    // already gate via IS_ZSH_MODE at their registration sites;
255    // names without dedicated opcodes (compopt/mapfile/readarray)
256    // route through this generic builtintab lookup and need the
257    // gate here.
258    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
259        && matches!(name, "compopt" | "mapfile" | "readarray")
260    {
261        eprintln!("zsh:1: command not found: {}", name);
262        let _ = args;
263        return 127;
264    }
265    // c:Src/Modules/<mod>.c boot_/setup_ chain — module-bound builtins
266    // (zftp, zsocket, ztcp, zstat, etc.) are only registered into
267    // `builtintab` when their module is loaded via `zmodload`. In
268    // zsh `-fc` (the parity test harness's invocation), the modules
269    // are NOT pre-loaded, so each name reports "command not found"
270    // with exit 127. zshrs intentionally pre-loads all module bintabs
271    // in `createbuiltintable` (builtin.rs:131-152) for the default
272    // mode so users can call these without `zmodload`; that auto-load
273    // diverges from zsh's gate behavior. Match zsh's stance only when
274    // the user explicitly asked for parity via `--zsh`.
275    //
276    // The list is the union of builtins from modules that zsh does
277    // NOT auto-load (verified via `zsh -fc <name>` returning 127):
278    //   zsh/zftp          → zftp
279    //   zsh/net/socket    → zsocket
280    //   zsh/net/tcp       → ztcp
281    //   zsh/stat          → zstat (NOT `stat`; that name resolves to
282    //                              /bin/stat on PATH per zsh's setup)
283    //   zsh/zselect       → zselect
284    //   zsh/zpty          → zpty
285    //   zsh/zprof         → zprof
286    //   zsh/system        → zsystem, syserror
287    //   zsh/clone         → clone
288    //   zsh/curses        → zcurses
289    //   zsh/db/gdbm       → ztie, zuntie, zgdbmpath
290    //   zsh/pcre          → pcre_compile, pcre_match, pcre_study
291    //   zsh/example       → example
292    //   zsh/cap           → cap, getcap, setcap
293    //   zsh/attr          → zgetattr, zsetattr, zdelattr, zlistattr
294    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
295        && module_bound_builtin_module(name)
296            .map(|m| {
297                !crate::ported::module::MODULESTAB
298                    .lock()
299                    .map(|t| t.is_loaded(m))
300                    .unwrap_or(false)
301            })
302            .unwrap_or(false)
303    {
304        eprintln!("zsh:1: command not found: {}", name);
305        let _ = args;
306        return 127;
307    }
308    // c:Src/Modules/files.c:806-824 — zsh/files registers `chmod`,
309    // `chown`, `chgrp`, `ln`, `mkdir`, `mv`, `rm`, `rmdir`, `sync`
310    // (plus their `zf_*` aliases) into builtintab on module load.
311    // Without an explicit `zmodload zsh/files`, zsh resolves the
312    // names through PATH lookup — `zsh -fc 'chmod +x f'` runs
313    // `/bin/chmod`, whose argv-parser accepts symbolic modes like
314    // `+x` that bin_chmod's octal-only parser rejects with
315    // "invalid mode `+x'". The shadow-aware wrapper at
316    // `dispatch_builtin` (line 438) already has this gate, but the
317    // direct `dispatch_builtin_raw` path used by fusevm's
318    // CallBuiltin opcode bypasses it. Mirror the gate here so the
319    // low-level dispatch matches C's PATH-fall-through behavior.
320    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
321        && module_gated_files_builtin(name)
322        && !crate::ported::module::MODULESTAB
323            .lock()
324            .map(|t| t.is_loaded("zsh/files"))
325            .unwrap_or(false)
326    {
327        // Strip the `zf_` prefix when routing to PATH (Src/Modules/
328        // files.c:816-824 — `zf_*` aliases point at the same handler
329        // as the bare name; PATH only has `/bin/rm`, not `/bin/zf_rm`).
330        let path_name = name.strip_prefix("zf_").unwrap_or(name);
331        let status = with_executor(|exec| exec.execute_external(path_name, &args, &[]))
332            .unwrap_or(127);
333        return status;
334    }
335    // c:Src/exec.c:3050-3068 — builtin lookup hits `builtintab` (the
336    // merged table containing module-provided builtins). The previous
337    // port walked only the core `BUILTINS` slice, so per-module
338    // entries like `log` (Src/Modules/watch.c:693 `BUILTIN("log", …,
339    // bin_log, …)`) were registered into builtintab via
340    // createbuiltintable but never reached at dispatch — `log` fell
341    // through to PATH and ran `/usr/bin/log`. Bug #72 in docs/BUGS.md.
342    let tab = crate::ported::builtin::createbuiltintable();
343    if let Some(bn_static) = tab.get(name) {
344        let bn_ptr = *bn_static as *const _ as *mut _;
345        return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
346    }
347    1
348}
349
350/// Shadow-aware dispatch matching zsh's name-resolution order:
351/// alias → reserved word → **function (shadows builtin)** → builtin →
352/// external. All `BUILTIN_X` opcode handlers route through here so a
353/// user-defined `cd () { … }` (or `r`, `fc`, `which`, … anything in
354/// fusevm's name→opcode map) takes precedence over the C builtin —
355/// matching `Src/exec.c:execcmd_exec`'s dispatch at c:3050-3068.
356/// Without this, compile-time builtin resolution silently ignored
357/// user wrappers (e.g. ZPWR's `cd () { builtin cd "$@"; … }`).
358/// True for builtins that are bound by zsh/files's boot_/setup_
359/// chain (Src/Modules/files.c:806-824). These are the bare-name
360/// `mkdir`/`rm`/`mv`/`ln`/`chmod`/`chown`/`chgrp`/`sync`/`rmdir`
361/// AND their `zf_*` aliases at c:816-824. Without explicit
362/// `zmodload zsh/files`, the names fall through to PATH lookup
363/// (zsh's `type rm` reports `/bin/rm`). Bug #28.
364fn module_gated_files_builtin(name: &str) -> bool {
365    matches!(
366        name,
367        "mkdir" | "rmdir" | "rm" | "mv" | "ln" | "chmod" | "chown" | "chgrp" | "sync"
368            | "zf_mkdir" | "zf_rmdir" | "zf_rm" | "zf_mv" | "zf_ln" | "zf_chmod"
369            | "zf_chown" | "zf_chgrp" | "zf_sync"
370    )
371}
372
373pub(crate) fn dispatch_builtin(name: &str, args: Vec<String>) -> i32 {
374    // c:Src/exec.c — when any redirect in the current scope failed
375    // (e.g. noclobber blocked a `>` overwrite), zsh refuses to
376    // execute the command and exits with status 1. The Rust port
377    // still applied the command (writing to the /dev/null sink
378    // installed by host_apply_redirect's noclobber arm) so the
379    // success status overwrote the intended 1. Short-circuit here
380    // for builtins (the external-exec equivalent lives in
381    // ZshrsHost::exec).
382    let redir_failed = with_executor(|exec| {
383        let f = exec.redirect_failed;
384        exec.redirect_failed = false;
385        f
386    });
387    if redir_failed {
388        return 1;
389    }
390    // c:Src/glob.c:1876-1880 NOMATCH path — when expand_glob() failed
391    // on a no-match glob, zsh aborts the simple command after zerr()
392    // printed "no matches found". In C, this works because zerr()
393    // sets ERRFLAG_ERROR (Src/utils.c) and execcmd_exec()
394    // (Src/exec.c:3050+) checks errflag before invoking the builtin
395    // table. Rust's builtin dispatch doesn't sit on the same errflag
396    // gate, so we explicitly consume the per-command glob-fail cell
397    // and short-circuit with status 1. Mirrors the external-path
398    // guard at host_exec_external (line 5167). Without this:
399    // `echo /never/*` would print empty (silently rolled back to ""
400    // by the empty glob expansion). Parity bug #13.
401    let glob_failed = with_executor(|exec| {
402        let f = exec.current_command_glob_failed.get();
403        exec.current_command_glob_failed.set(false); // c:1879 cleanup
404        f
405    });
406    if glob_failed {
407        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH zerr sets
408        // ERRFLAG_ERROR (via utils.c:184); the C execlist loop's per-
409        // sublist post-exec path then resets the bit so subsequent
410        // sublists continue (verified: `zsh -fc 'ls /nope_*; echo
411        // after'` prints `after`). zshrs's vm dispatch doesn't have
412        // C's central execlist loop — the post-command-boundary
413        // equivalent is right HERE, where the dispatcher consumes
414        // `current_command_glob_failed` and surfaces status 1 for THIS
415        // command. Clear ERRFLAG_ERROR at the same boundary so the
416        // next command runs (while leaving ERRFLAG_INT etc. alone so
417        // ctrl-c still propagates).
418        crate::ported::utils::errflag.fetch_and(
419            !crate::ported::zsh_h::ERRFLAG_ERROR,
420            std::sync::atomic::Ordering::Relaxed,
421        );
422        return 1; // c:1880 — command aborted, status 1
423    }
424    if let Some(status) = try_user_fn_override(name, &args) {
425        // c:Src/jobs.c:1748 waitonejob — canonical single-command
426        // pipestats update via the no-procs else-branch.
427        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
428        let mut synth = crate::ported::zsh_h::job::default();
429        crate::ported::jobs::waitonejob(&mut synth);
430        return status;
431    }
432    // c:Src/builtin.c:587 + Src/exec.c:3056 — a builtin disabled via
433    // `disable <name>` has its `DISABLED` flag set in `builtintab`;
434    // `builtintab->getnode` (the DISABLED-filtering accessor) returns
435    // NULL for it at lookup time, so execcmd_exec falls through to
436    // PATH lookup and runs the external. The Rust port stores the
437    // disabled set in `BUILTINS_DISABLED`; the previous dispatcher
438    // only checked the immutable `createbuiltintable` HashMap which
439    // never reflects disablement — so `disable echo; echo hi` kept
440    // running the bin_echo builtin. Bug #106 in docs/BUGS.md.
441    //
442    // dispatch_builtin (the high-level wrapper used by the BUILTIN_*
443    // opcode handlers and reg_passthru! callsites) is the correct
444    // gate: `dispatch_builtin_raw` is the low-level entry point
445    // used by `bin_builtin` itself which MUST bypass the disabled
446    // set (man zshbuiltins: `builtin name` runs the builtin
447    // regardless of disable state). Place the check here so the
448    // bypass path stays clean.
449    let disabled = crate::ported::builtin::BUILTINS_DISABLED
450        .lock()
451        .map(|s| s.contains(name))
452        .unwrap_or(false);
453    if disabled {
454        let status = with_executor(|exec| exec.execute_external(name, &args, &[]))
455            .unwrap_or(127);
456        crate::ported::builtin::LASTVAL
457            .store(status, std::sync::atomic::Ordering::Relaxed);
458        let mut synth = crate::ported::zsh_h::job::default();
459        crate::ported::jobs::waitonejob(&mut synth);
460        return status;
461    }
462    // c:Src/Modules/files.c:806-814 — `mkdir`, `rm`, `mv`, `ln`, `chmod`,
463    // `chown`, `chgrp`, `sync`, `rmdir` are bound by the `zsh/files`
464    // module's boot_/setup_ chain. Without explicit `zmodload zsh/files`,
465    // these bare names fall through to PATH (`/bin/rm`, `/usr/bin/chmod`,
466    // etc.) in zsh; `type rm` reports `rm is /bin/rm`. The `zf_*`
467    // aliases (`zf_rm`, `zf_chmod`, …) are bound by the same module
468    // and gated the same way. Bug #28 in docs/BUGS.md.
469    if module_gated_files_builtin(name) {
470        if !crate::ported::module::MODULESTAB.lock().unwrap().is_loaded("zsh/files") {
471            // Strip the `zf_` prefix when routing to PATH so `zf_rm`
472            // (when zsh/files isn't loaded) still finds /bin/rm.
473            let path_name = name.strip_prefix("zf_").unwrap_or(name);
474            let status = with_executor(|exec| exec.execute_external(path_name, &args, &[]))
475                .unwrap_or(127);
476            crate::ported::builtin::LASTVAL
477                .store(status, std::sync::atomic::Ordering::Relaxed);
478            let mut synth = crate::ported::zsh_h::job::default();
479            crate::ported::jobs::waitonejob(&mut synth);
480            return status;
481        }
482    }
483    let status = dispatch_builtin_raw(name, args);
484    // c:Src/jobs.c:1748 waitonejob — canonical single-command pipestats update.
485    crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
486    let mut synth = crate::ported::zsh_h::job::default();
487    crate::ported::jobs::waitonejob(&mut synth);
488    status
489}
490
491/// Install the `crate::ported::exec_hooks` fn-pointer registry so
492/// code under `src/ported/` can dispatch to operations owned by
493/// `ShellExecutor` (array/assoc storage, script eval, function
494/// dispatch, command substitution) WITHOUT a direct executor
495/// reference or `with_executor` call from inside src/ported/.
496///
497/// Idempotent — each hook uses `OnceLock::set`, so calling this
498/// multiple times (once per `ShellExecutor::new`) is safe; the second
499/// and later calls are no-ops.
500///
501/// **Extension** — no C analog. Bridges the Rust-only `src/ported/`
502/// → executor boundary that the user pinned as forbidden via memory
503/// `feedback_no_exec_script_from_ported` /
504/// `feedback_no_shellexecutor_in_ported`.
505pub(crate) fn install_exec_hooks() {
506    use crate::ported::exec_hooks as h;
507    h::install_array_get(|name| with_executor(|exec| exec.array(name)));
508    h::install_assoc_get(|name| with_executor(|exec| exec.assoc(name)));
509    h::install_array_set(|name, val| {
510        with_executor(|exec| exec.set_array(name.to_string(), val));
511    });
512    h::install_assoc_set(|name, val| {
513        with_executor(|exec| exec.set_assoc(name.to_string(), val));
514    });
515    h::install_scalar_unset(|name| {
516        with_executor(|exec| exec.unset_scalar(name));
517    });
518    h::install_array_unset(|name| {
519        with_executor(|exec| exec.unset_array(name));
520    });
521    h::install_assoc_unset(|name| {
522        with_executor(|exec| exec.unset_assoc(name));
523    });
524    h::install_dispatch_function_call(|name, args| {
525        with_executor(|exec| exec.dispatch_function_call(name, args))
526    });
527    h::install_run_function_body(|name, args| {
528        with_executor(|exec| exec.run_function_body_only(name, args))
529    });
530    h::install_execute_script(|src| with_executor(|exec| exec.execute_script(src)));
531    h::install_execute_script_zsh_pipeline(|src| {
532        with_executor(|exec| exec.execute_script_zsh_pipeline(src))
533    });
534    h::install_run_command_substitution(|cmd| {
535        with_executor(|exec| exec.run_command_substitution(cmd))
536    });
537    h::install_pparams_get(|| with_executor(|exec| exec.pparams()));
538    h::install_pparams_set(|v| {
539        with_executor(|exec| exec.set_pparams(v));
540    });
541    h::install_unregister_function(|name| {
542        with_executor(|exec| {
543            let a = exec.functions_compiled.remove(name).is_some();
544            let b = exec.function_source.remove(name).is_some();
545            a || b
546        })
547    });
548}
549
550/// Register all zsh builtins with the VM.
551pub(crate) fn register_builtins(vm: &mut fusevm::VM) {
552    // exec_hooks fn-ptrs MUST be installed before any builtin can
553    // reach into src/ported/ code that consults them (e.g.
554    // `BUILTIN_ERREXIT_CHECK` → `dotrap` → `exec_hooks::dispatch_function_call`).
555    // OnceLock makes the call idempotent — repeated invocations from
556    // every `ShellExecutor::new` are no-ops.
557    install_exec_hooks();
558    // Engage fusevm's tiered JIT (block + tracing) so hot, fully-eligible
559    // numeric chunks run in native code and — with the `jit-disk-cache`
560    // feature (on by default) — persist that native code to
561    // `~/.cache/fusevm-jit`, letting repeated zsh invocations skip Cranelift
562    // codegen. fusevm gates the JIT on per-chunk eligibility and warms up by
563    // an invocation threshold, falling back to the interpreter for any chunk
564    // it cannot compile (e.g. host-builtin/`Extended` command dispatch), so
565    // enabling it here never changes observable behaviour — it only caches
566    // the numeric hot path. Idempotent: re-enabling on each VM is a no-op.
567    vm.enable_tracing_jit();
568    // Macro for builtins that user functions are allowed to shadow.
569    // zsh dispatch order is alias → function → builtin; without the
570    // try_user_fn_override probe a `cat() { ... }; cat` would silently
571    // run the C builtin and ignore the user function.
572    macro_rules! reg_overridable {
573        ($vm:expr, $id:expr, $name:literal, $method:ident) => {
574            $vm.register_builtin($id, |vm, argc| {
575                let args = pop_args(vm, argc);
576                if let Some(s) = try_user_fn_override($name, &args) {
577                    return Value::Status(s);
578                }
579                // c:Src/exec.c — redirect failure in the current
580                // scope means the command must NOT run. coreutils
581                // shadows (cat / head / tail / etc.) take a separate
582                // dispatch path from dispatch_builtin, so they need
583                // their own gate. Without this `cat <&3` after a
584                // closed-fd diagnostic still ran the shadow and
585                // overwrote $? from the forced 1.
586                let redir_failed = with_executor(|exec| {
587                    let f = exec.redirect_failed;
588                    exec.redirect_failed = false;
589                    f
590                });
591                if redir_failed {
592                    return Value::Status(1);
593                }
594                // `[builtins].coreutils_shadows = off` in
595                // ~/.zshrs/zshrs.toml (or `ZSHRS_NO_COREUTILS_SHADOWS=1`
596                // env override) bypasses the in-process shadow and
597                // fork-execs the real /bin/X. Safety valve for any
598                // script that hits an edge-case divergence between
599                // the zshrs shadow and system coreutils. Cached
600                // after first call, so the hot path is one atomic
601                // load per shadowed-builtin invocation.
602                if !crate::daemon_presence::coreutils_shadows_enabled() {
603                    return Value::Status(exec_system_command($name, &args));
604                }
605                let status = with_executor(|exec| exec.$method(&args));
606                Value::Status(status)
607            });
608        };
609    }
610
611    // Pure-passthru builtin: pops args, routes to canonical
612    // `dispatch_builtin(name, args)` (which goes via execbuiltin →
613    // BUILTINS[name] → bin_X). No pre/post bridge work. Used by
614    // ~25 handlers that were 4-line copy-paste boilerplate.
615    macro_rules! reg_passthru {
616        ($vm:expr, $id:expr, $name:literal) => {
617            $vm.register_builtin($id, |vm, argc| {
618                Value::Status(dispatch_builtin($name, pop_args(vm, argc)))
619            });
620        };
621    }
622
623    // Core builtins
624    vm.register_builtin(BUILTIN_CD, |vm, argc| {
625        let args = pop_args(vm, argc);
626        if let Some(s) = try_user_fn_override("cd", &args) {
627            return Value::Status(s);
628        }
629        let status = dispatch_builtin("cd", args);
630        // c:Src/builtin.c:1258 — `callhookfunc("chpwd", NULL, 1, NULL)`
631        // after cd succeeds. The canonical port at
632        // src/ported/utils.rs:1532 handles both the `chpwd` shfunc
633        // dispatch AND the `chpwd_functions` array walk.
634        if status == 0 {
635            crate::ported::utils::callhookfunc("chpwd", None, 1, std::ptr::null_mut());
636        }
637        Value::Status(status)
638    });
639
640    vm.register_builtin(BUILTIN_PWD, |vm, argc| {
641        let args = pop_args(vm, argc);
642        if let Some(s) = try_user_fn_override("pwd", &args) {
643            return Value::Status(s);
644        }
645        // Route through the canonical execbuiltin path so the `rLP`
646        // optstr at BUILTINS["pwd"] is parsed into `ops`.
647        let status = dispatch_builtin("pwd", args);
648        Value::Status(status)
649    });
650
651    vm.register_builtin(BUILTIN_ECHO, |vm, argc| {
652        let args = pop_args(vm, argc);
653        if let Some(s) = try_user_fn_override("echo", &args) {
654            return Value::Status(s);
655        }
656        // Update `$_` to the last arg before running. C zsh sets
657        // zunderscore in execcmd_exec for every simple command,
658        // including builtins.
659        crate::ported::params::set_zunderscore(&args);
660        let status = dispatch_builtin("echo", args);
661        Value::Status(status)
662    });
663
664    vm.register_builtin(BUILTIN_PRINT, |vm, argc| {
665        let args = pop_args(vm, argc);
666        if let Some(s) = try_user_fn_override("print", &args) {
667            return Value::Status(s);
668        }
669        crate::ported::params::set_zunderscore(&args);
670        let status = dispatch_builtin("print", args);
671        Value::Status(status)
672    });
673
674    reg_passthru!(vm, BUILTIN_PRINTF, "printf");
675    reg_passthru!(vm, BUILTIN_EXPORT, "export");
676    reg_passthru!(vm, BUILTIN_UNSET, "unset");
677    // `source` (Src/builtin.c c:116) wired to bin_dot via BUILTINS.
678    reg_passthru!(vm, BUILTIN_SOURCE, "source");
679    reg_passthru!(vm, BUILTIN_DOT, ".");
680    reg_passthru!(vm, BUILTIN_LOGOUT, "logout");
681
682    vm.register_builtin(BUILTIN_EXIT, |vm, argc| {
683        let args = pop_args(vm, argc);
684        let status = dispatch_builtin("exit", args);
685        Value::Status(status)
686    });
687
688    vm.register_builtin(BUILTIN_RETURN, |vm, argc| {
689        let args = pop_args(vm, argc);
690        // zsh: bare `return` (no arg) returns with the status of
691        // the most recently executed command — `false; return`
692        // returns 1, not 0. Direct port of zsh's bin_break/RETURN.
693        // The executor's `last_status` is stale here (synced at
694        // statement boundaries, not after each VM op), so read
695        // the live `vm.last_status` instead.
696        let live_status = vm.last_status;
697        let status = {
698            // Sync canonical LASTVAL to the VM's view BEFORE
699            // bin_break("return") reads it for the no-arg fallback.
700            with_executor(|exec| exec.set_last_status(live_status));
701            dispatch_builtin("return", args)
702        };
703        Value::Status(status)
704    });
705
706    vm.register_builtin(BUILTIN_TRUE, |vm, argc| {
707        let args = pop_args(vm, argc);
708        if let Some(s) = try_user_fn_override("true", &args) {
709            return Value::Status(s);
710        }
711        // c:Src/exec.c:1257 — zsh sets `zunderscore` AT THE END of
712        // each command (the `if (!noerrs)` block runs `zsfree(prev_argv0); …;
713        // zunderscore = …`). For no-arg `true`, $_ becomes the
714        // command name itself. Set DIRECTLY (not via pending_underscore)
715        // so the NEXT command's argv-expansion of `$_` reads "true",
716        // not the stale prior value — pending_underscore is consumed
717        // by pop_args which runs AFTER argv expansion, too late.
718        // c:Src/exec.c:1257 — `zunderscore = …` at end-of-command.
719        // With args, $_ = args.last(). Without args, $_ = command name.
720        // Write DIRECTLY to the canonical zunderscore static (the
721        // underscoregetfn at params.rs:7003 reads from there); the
722        // paramtab "_" slot is shadowed by lookup_special_var so
723        // set_scalar on it has no effect on `$_` reads.
724        if args.is_empty() {
725            crate::ported::params::set_zunderscore(&["true".to_string()]);
726        } else {
727            crate::ported::params::set_zunderscore(&args);
728        }
729        // Route through canonical execbuiltin so PS4 xtrace fires
730        // via the c:442 printprompt4 path.
731        Value::Status(dispatch_builtin("true", args))
732    });
733    vm.register_builtin(BUILTIN_FALSE, |vm, argc| {
734        let args = pop_args(vm, argc);
735        if let Some(s) = try_user_fn_override("false", &args) {
736            return Value::Status(s);
737        }
738        // Direct set; see BUILTIN_TRUE above for rationale.
739        if args.is_empty() {
740            crate::ported::params::set_zunderscore(&["false".to_string()]);
741        } else {
742            crate::ported::params::set_zunderscore(&args);
743        }
744        // Route through canonical execbuiltin — see BUILTIN_TRUE
745        // above for the same rationale (xtrace + fast-path removal).
746        let status = dispatch_builtin("false", args);
747        Value::Status(status)
748    });
749    vm.register_builtin(BUILTIN_COLON, |vm, argc| {
750        let args = pop_args(vm, argc);
751        // Direct set; see BUILTIN_TRUE above for rationale.
752        if args.is_empty() {
753            crate::ported::params::set_zunderscore(&[":".to_string()]);
754        } else {
755            crate::ported::params::set_zunderscore(&args);
756        }
757        let status = dispatch_builtin(":", args);
758        Value::Status(status)
759    });
760
761    vm.register_builtin(BUILTIN_TEST, |vm, argc| {
762        let args = pop_args(vm, argc);
763        // Distinguish `[ … ]` from `test …` by sniffing the trailing
764        // `]` — `[` requires it (c:Src/builtin.c:7241), `test` rejects
765        // it. The compile path emits BUILTIN_TEST for both, so the
766        // dispatch name carries the `[` vs `test` semantic for
767        // execbuiltin's funcid (BIN_BRACKET=21 vs BIN_TEST=20). Without
768        // this, bin_test's `if func == BIN_BRACKET` arm (which pops
769        // the trailing `]`) never fired for `[` calls, so the `]`
770        // leaked into evalcond as a positional and silently changed
771        // the result. Bug surfaced via test_test_dashdash_unknown_condition.
772        let name = if args.last().map(|s| s.as_str()) == Some("]") {
773            "["
774        } else {
775            "test"
776        };
777        let status = dispatch_builtin(name, args);
778        Value::Status(status)
779    });
780
781    // Variable declaration. `local` (Src/builtin.c bin_local) handles
782    // the scope chain (`pm->old = oldpm` at Src/params.c:1137 inside
783    // createparam, `pm->level = locallevel` at Src/builtin.c:2576).
784    // `typeset` / `declare` are aliases — fusevm maps both to
785    // BUILTIN_TYPESET; compile_zsh special-cases `declare` to keep
786    // the `declare:` error prefix.
787    reg_passthru!(vm, BUILTIN_LOCAL, "local");
788    reg_passthru!(vm, BUILTIN_TYPESET, "typeset");
789
790    reg_passthru!(vm, BUILTIN_DECLARE, "declare");
791    reg_passthru!(vm, BUILTIN_READONLY, "readonly");
792    reg_passthru!(vm, BUILTIN_INTEGER, "integer");
793    reg_passthru!(vm, BUILTIN_FLOAT, "float");
794    reg_passthru!(vm, BUILTIN_READ, "read");
795    // c:Bug #504 — fusevm reserves BUILTIN_MAPFILE for the bash
796    // mapfile/readarray builtins. Neither exists in zsh; in --zsh
797    // parity mode the dispatch must emit "command not found" + rc=127
798    // matching zsh's external-command-lookup miss. The previous wiring
799    // left BUILTIN_MAPFILE unregistered, so fusevm's VM treated the op
800    // as a no-op rc=0 — `mapfile` (and `readarray`) silently succeeded
801    // in --zsh mode. The host gate in `dispatch_builtin_raw` never
802    // fired because the compile path emitted `Op::CallBuiltin(31, ..)`
803    // directly. Register the slot so the gate runs (or a future
804    // non-zsh mode can wire in a real impl).
805    vm.register_builtin(fusevm::shell_builtins::BUILTIN_MAPFILE, |vm, argc| {
806        let args = pop_args(vm, argc);
807        // The fusevm name→id map collapses both `mapfile` and
808        // `readarray` to the same opcode; pick the right diagnostic
809        // by sniffing the user's actual invocation. The xtrace ARGS
810        // push earlier records the cmd-prefix as the bottom of the
811        // popped argv, but `args` here excludes the prefix — so we
812        // can't recover the user-typed name from the stack. Default
813        // to `mapfile` (the more-common spelling); both produce
814        // identical diagnostics in any case.
815        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
816            eprintln!("zsh:1: command not found: mapfile");
817            let _ = args;
818            return Value::Status(127);
819        }
820        // Non-zsh modes (bash drop-in) get a passthru to the canonical
821        // dispatcher in case a future port adds a real mapfile builtin
822        // — until then the rc=1 unknown-command default applies.
823        Value::Status(dispatch_builtin("mapfile", args))
824    });
825    reg_passthru!(vm, BUILTIN_BREAK, "break");
826    reg_passthru!(vm, BUILTIN_CONTINUE, "continue");
827    reg_passthru!(vm, BUILTIN_SHIFT, "shift");
828
829    vm.register_builtin(BUILTIN_EVAL, |vm, argc| {
830        // Direct port of `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))` body from Src/builtin.c:6151:
831        //   `if (!*argv) return 0;`
832        //   `prog = parse_string(zjoin(argv, ' ', 1), 1);`
833        //   `execode(prog, 1, 0, "eval");`
834        // The execode invocation lives here (not in the canonical
835        // free-fn) because it must run through the bytecode VM's
836        // current executor — the same VM that's mid-dispatch.
837        let mut args = pop_args(vm, argc);
838        // c:Src/builtin.c:407-411 — generic `--` end-of-options
839        // strip applied by `execbuiltin` for builtins that have
840        // NULL optstr AND no BINF_HANDLES_OPTS. `eval` qualifies
841        // (Src/builtin.c:65 `BUILTIN("eval", BINF_PSPECIAL, ...,
842        // NULL, NULL)`). The BUILTIN_EVAL fast-path bypasses
843        // execbuiltin, so we mirror the strip inline. Bug #319.
844        if args.first().is_some_and(|s| s == "--") {
845            args.remove(0);
846        }
847        if args.is_empty() {
848            return Value::Status(0); // c:6160
849        }
850        let src = args.join(" "); // c:6166
851        // c:Src/builtin.c:6164-6165 — `if (!ineval) scriptname =
852        // "(eval)";`. Diagnostics emitted while the eval body runs
853        // (command-not-found, parse errors, etc.) use scriptname as
854        // the source-context prefix. Without setting it here the
855        // BUILTIN_EVAL fast-path leaked the outer "zsh" prefix
856        // through, breaking the `(eval):N:` convention zsh uses
857        // for in-eval errors. Bug #420.
858        let oscriptname = crate::ported::utils::scriptname_get();
859        crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
860        let status = with_executor(|exec| {
861            // c:6175 execode
862            exec.execute_script(&src).unwrap_or(1)
863        });
864        crate::ported::utils::set_scriptname(oscriptname);
865        Value::Status(status)
866    });
867
868    // `builtin foo args…`: precmd-modifier that forces builtin dispatch,
869    // bypassing alias AND function lookup. Without this, `builtin cd /`
870    // inside a user `cd () { … }` wrapper recurses (real-world ZPWR pattern).
871    // Handler pops argc args from the stack, treats args[0] as the builtin
872    // name, and dispatches the rest via `dispatch_builtin` → `execbuiltin`
873    // → `bin_*` directly. No function/alias lookup happens.
874    vm.register_builtin(BUILTIN_BUILTIN, |vm, argc| {
875        let args = pop_args(vm, argc);
876        let Some((name, rest)) = args.split_first() else {
877            // `builtin` with no args → list builtins (zsh emits nothing,
878            // exit 0). Match that behavior; the BIN_BUILTIN bin_* in C
879            // does the same default-list-nothing.
880            return Value::Status(0);
881        };
882        // c:Src/exec.c:3435-3436 — `builtin NAME` with NAME not in
883        // builtintab emits `zwarn("no such builtin: %s", cmdarg)`
884        // and returns 1. zshrs's dispatch_builtin_raw bare-returned 1
885        // silently. Probe the table here so the diagnostic fires
886        // before dispatch.
887        let tab = crate::ported::builtin::createbuiltintable();
888        if !tab.contains_key(name.as_str()) {
889            eprintln!("zshrs:1: no such builtin: {}", name);
890            return Value::Status(1);
891        }
892        // `builtin foo` MUST bypass function shadow — that's the whole
893        // point of the prefix. Use the _raw helper, not the shadow-aware
894        // one. Without this, `cd () { builtin cd "$@"; }` recurses.
895        Value::Status(dispatch_builtin_raw(name, rest.to_vec()))
896    });
897
898    // `command foo args…` — BINF_COMMAND prefix (Src/builtin.c:44). Zsh
899    // semantic: bypass alias+function lookup, search builtin then $PATH.
900    // Without this, `cd () { command cd "$@" }` would re-invoke the user
901    // wrapper (same root cause as the `builtin` bug). Flags `-p`/`-v`/`-V`
902    // route to bin_whence with BIN_COMMAND funcid; bare `command foo`
903    // dispatches builtin if present, else external (no fork — direct
904    // spawn via execute_external since zshrs is non-forking).
905    // BUILTIN_COMMAND — `command [-p] [-v|-V] cmd args…` BIN_PREFIX
906    // (Src/builtin.c:45). PURE PASSTHRU: prepend "command" and hand
907    // to `exec::execcmd_compile_head` (the fusevm-bytecode-time head
908    // resolver mirroring `Src/exec.c::execcmd_exec` precommand-modifier
909    // walk at c:3104-3187). That helper already does the -p / -v / -V
910    // option parsing, surfaces `has_command_vv` for the whence
911    // redirect, and reports the dispatch shape (is_builtin vs external).
912    vm.register_builtin(BUILTIN_COMMAND, |vm, argc| {
913        let args = pop_args(vm, argc);
914        let mut full = Vec::with_capacity(args.len() + 1);
915        full.push("command".to_string());
916        full.extend(args.clone());
917        let dispatch =
918            crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
919        let post = &full[dispatch.precmd_skip..];
920        // c:Src/builtin.c:4500 — `command -p` resets PATH for the
921        // exec to the POSIX-defined default (`getconf PATH`), so
922        // standard utilities resolve even when the caller has
923        // emptied $PATH. zsh restores the original PATH after the
924        // command returns. Mirror via a scoped env::set_var.
925        let dash_p = args.iter().any(|a| {
926            a == "-p"
927                || a == "-pv"
928                || a == "-pV"
929                || (a.starts_with('-') && a.contains('p') && !a.starts_with("--"))
930        });
931        // The post slice from execcmd_compile_head may still contain
932        // `-p` as the first element because precmd-modifier opt
933        // parsing isn't wired here. Strip it manually so the dispatch
934        // below sees the real command name.
935        let mut post: Vec<String> = if dash_p {
936            post.iter()
937                .filter(|a| {
938                    let s = a.as_str();
939                    !(s.starts_with('-')
940                        && s.len() >= 2
941                        && s[1..].chars().all(|c| c == 'p' || c == 'v' || c == 'V'))
942                })
943                .cloned()
944                .collect()
945        } else {
946            post.to_vec()
947        };
948        // c:Src/exec.c:3176-3177 — `BINF_COMMAND` arm strips a single
949        // leading `--` end-of-options marker.
950        // `execcmd_compile_head` (src/ported/exec.rs:1042) performs
951        // this removal on its LOCAL `preargs` Vec but doesn't surface
952        // the modified args; the caller still sees `--` in `full` and
953        // tried to dispatch it as the command name. Bug #251. Mirror
954        // the C strip here so `command -- echo hi` and
955        // `command -p -- echo hi` route correctly.
956        if let Some(first) = post.first() {
957            if first == "--" {
958                post.remove(0);
959            }
960        }
961        let post = post.as_slice();
962        let _path_guard = if dash_p {
963            let saved = env::var("PATH").ok();
964            let default_path = std::process::Command::new("getconf")
965                .arg("PATH")
966                .output()
967                .ok()
968                .and_then(|o| String::from_utf8(o.stdout).ok())
969                .map(|s| s.trim().to_string())
970                .filter(|s| !s.is_empty())
971                .unwrap_or_else(|| "/usr/bin:/bin:/usr/sbin:/sbin".to_string());
972            env::set_var("PATH", &default_path);
973            crate::ported::params::setsparam("PATH", &default_path);
974            Some(saved)
975        } else {
976            None
977        };
978        struct PathGuard {
979            saved: Option<String>,
980            active: bool,
981        }
982        impl Drop for PathGuard {
983            fn drop(&mut self) {
984                if !self.active {
985                    return;
986                }
987                match self.saved.take() {
988                    Some(p) => {
989                        env::set_var("PATH", &p);
990                        crate::ported::params::setsparam("PATH", &p);
991                    }
992                    None => {
993                        env::remove_var("PATH");
994                        crate::ported::params::setsparam("PATH", "");
995                    }
996                }
997            }
998        }
999        let _restore = PathGuard {
1000            saved: _path_guard.unwrap_or(None),
1001            active: dash_p,
1002        };
1003        if dispatch.has_command_vv {
1004            // `-v` / `-V` → bin_whence with BIN_COMMAND funcid.
1005            let mut ops = options {
1006                ind: [0u8; MAX_OPS],
1007                args: Vec::new(),
1008                argscount: 0,
1009                argsalloc: 0,
1010            };
1011            let mut name_pos = 0usize;
1012            let mut flag_byte = b'v';
1013            for (i, a) in post.iter().enumerate() {
1014                if a.starts_with('-') && a.len() >= 2 {
1015                    let body = &a.as_bytes()[1..];
1016                    if body.contains(&b'V') {
1017                        flag_byte = b'V';
1018                    }
1019                    name_pos = i + 1;
1020                } else {
1021                    name_pos = i;
1022                    break;
1023                }
1024            }
1025            ops.ind[flag_byte as usize] = 1;
1026            let whence_args: Vec<String> = post[name_pos..].to_vec();
1027            return Value::Status(crate::ported::builtin::bin_whence(
1028                "command",
1029                &whence_args,
1030                &ops,
1031                crate::ported::hashtable_h::BIN_COMMAND,
1032            ));
1033        }
1034        if dispatch.is_empty_command {
1035            return Value::Status(0);
1036        }
1037        let Some((name, rest)) = post.split_first() else {
1038            return Value::Status(0);
1039        };
1040        // c:Src/exec.c:3275-3278 — `execcmd_compile_head` cleared
1041        // hn for the BINF_COMMAND + !POSIXBUILTINS case, surfacing
1042        // is_builtin=false. Run as external. Under POSIXBUILTINS
1043        // dispatch.is_builtin would be true; honour it.
1044        let n = name.clone();
1045        let r = rest.to_vec();
1046        if dispatch.is_builtin
1047            && crate::ported::builtin::BUILTINS
1048                .iter()
1049                .any(|b| b.node.nam == n.as_str())
1050        {
1051            return Value::Status(dispatch_builtin_raw(&n, r));
1052        }
1053        Value::Status(with_executor(|exec| exec.execute_external(&n, &r, &[])).unwrap_or(127))
1054    });
1055
1056    // `exec cmd args…` — BINF_EXEC prefix (Src/builtin.c:45). Zsh
1057    // semantic: replace the current shell process with `cmd`. On Unix
1058    // this is `execvp(2)`; the call only returns on error. zshrs is
1059    // non-forking, so the shell process IS the calling process —
1060    // execvp here directly replaces it. Options `-a name` (override
1061    // argv[0]), `-c` (clean env), `-l` (login shell — prepend `-`)
1062    // ported minimally; advanced redirect-only `exec >file` is handled
1063    // upstream by compile_zsh and never reaches this handler.
1064    vm.register_builtin(BUILTIN_EXEC, |vm, argc| {
1065        let mut args = pop_args(vm, argc);
1066        let mut argv0_override: Option<String> = None;
1067        let mut clean_env = false;
1068        let mut login = false;
1069        let mut i = 0;
1070        // c:Src/builtin.c:1075-1080 — track if any flag was consumed.
1071        // `exec -c`, `exec -l`, `exec -a NAME` without a following
1072        // command emit "exec requires a command to execute" rc=1.
1073        // Bare `exec` (no args at all) is the silent-redirect-apply
1074        // form per POSIX.
1075        let mut saw_flag = false;
1076        while i < args.len() {
1077            let a = &args[i];
1078            if a == "--" {
1079                args.remove(i);
1080                break;
1081            }
1082            // c:Src/builtin.c:42 `BIN_PREFIX("-", BINF_DASH)`. A bare
1083            // `-` is its own BINF_PREFIX builtin (BINF_DASH flag —
1084            // "login shell, prepend `-` to argv[0]"). In the canonical
1085            // precmd-walk at Src/exec.c:3056-3091 a bare `-` after
1086            // `exec` is recognized AS a builtin and stripped from
1087            // preargs (precmd_skip++), accumulating BINF_DASH into
1088            // cflags. The fast-path here bypasses execcmd_compile_head,
1089            // so we mirror the strip locally: bare `-` → set login,
1090            // remove, continue. Without this `exec -` (with no command
1091            // following) tried to exec `-` as a literal command and
1092            // exited the shell. Bug #252.
1093            if a == "-" {
1094                saw_flag = true;
1095                login = true;
1096                args.remove(i);
1097                continue;
1098            }
1099            if !a.starts_with('-') || a.len() < 2 {
1100                break;
1101            }
1102            match a.as_str() {
1103                "-a" => {
1104                    saw_flag = true;
1105                    args.remove(i);
1106                    if i < args.len() {
1107                        argv0_override = Some(args.remove(i));
1108                    }
1109                }
1110                "-c" => {
1111                    saw_flag = true;
1112                    clean_env = true;
1113                    args.remove(i);
1114                }
1115                "-l" => {
1116                    saw_flag = true;
1117                    login = true;
1118                    args.remove(i);
1119                }
1120                _ => {
1121                    // c:Src/exec.c:3196-3208 — when an unrecognized
1122                    // `-X`-style arg has NO following arg, the lexer's
1123                    // IS_DASH walk hits the "no next node" branch at
1124                    // c:3199 before the unknown-flag-letter switch at
1125                    // c:3249, so the canonical message is "exec
1126                    // requires a command to execute" rc=1 (verified vs
1127                    // `/opt/homebrew/bin/zsh -fc 'exec --bad'`).
1128                    // Consume the lone flag so the post-loop check
1129                    // fires. When a following arg exists, leave the
1130                    // unknown-flag arg in place — that arg becomes
1131                    // the command name and execution proceeds.
1132                    if args.len() == 1 {
1133                        saw_flag = true;
1134                        args.remove(i);
1135                        continue;
1136                    }
1137                    break;
1138                }
1139            }
1140        }
1141        let Some(cmd) = args.first().cloned() else {
1142            if saw_flag {
1143                // c:Src/builtin.c:1078-1080 — flags consumed but no
1144                // command follows → "exec requires a command to
1145                // execute" rc=1.
1146                eprintln!("zshrs:1: exec requires a command to execute");
1147                return Value::Status(1);
1148            }
1149            // `exec` with no command + no redirects = no-op success.
1150            return Value::Status(0);
1151        };
1152        let rest: Vec<String> = args[1..].to_vec();
1153        let display_argv0 = match argv0_override {
1154            Some(a) => a,
1155            None => {
1156                if login {
1157                    format!("-{}", cmd)
1158                } else {
1159                    cmd.clone()
1160                }
1161            }
1162        };
1163        // c:Src/exec.c::execcmd — `exec funcname` runs the function
1164        // in-process as the shell's last act, then exits with the
1165        // function's status. zsh's dispatcher falls through from the
1166        // BINF_EXEC prefix into the normal Builtin/External/Function
1167        // resolution and only execvp's if the target ISN'T a
1168        // function. Bug #101 in docs/BUGS.md: zshrs's exec went
1169        // straight to execvp and errored `not found` for shell
1170        // functions.
1171        //
1172        // For both subshell and top-level contexts: dispatch through
1173        // the function/builtin lookup first; only fall through to
1174        // execvp/spawn if the name isn't shell-resolvable.
1175        let has_user_fn = with_executor(|exec| exec.functions_compiled.contains_key(&cmd));
1176        if has_user_fn {
1177            let status = with_executor(|exec| {
1178                exec.dispatch_function_call(&cmd, &rest).unwrap_or(127)
1179            });
1180            // Top-level `exec funcname` — exit the shell with the
1181            // function's status (mirrors C's "exec replaces shell as
1182            // last act"). Subshell `(exec funcname)` — return through
1183            // the EXIT_PENDING path so the subshell body aborts and
1184            // the parent resumes via subshell_end.
1185            let in_subshell_now =
1186                with_executor(|exec| !exec.subshell_snapshots.is_empty());
1187            if in_subshell_now {
1188                crate::ported::builtin::EXIT_VAL
1189                    .store(status, std::sync::atomic::Ordering::Relaxed);
1190                crate::ported::builtin::EXIT_PENDING
1191                    .store(1, std::sync::atomic::Ordering::Relaxed);
1192                return Value::Status(status);
1193            }
1194            std::process::exit(status);
1195        }
1196        // c:Src/exec.c — builtin path: `exec builtin` runs the
1197        // builtin in-process and exits.
1198        let bn_in_tab = crate::ported::builtin::createbuiltintable().contains_key(&cmd);
1199        if bn_in_tab {
1200            let status = dispatch_builtin_raw(&cmd, rest.clone());
1201            let in_subshell_now =
1202                with_executor(|exec| !exec.subshell_snapshots.is_empty());
1203            if in_subshell_now {
1204                crate::ported::builtin::EXIT_VAL
1205                    .store(status, std::sync::atomic::Ordering::Relaxed);
1206                crate::ported::builtin::EXIT_PENDING
1207                    .store(1, std::sync::atomic::Ordering::Relaxed);
1208                return Value::Status(status);
1209            }
1210            std::process::exit(status);
1211        }
1212        // c:Src/exec.c — `exec` inside a subshell (`(exec cmd)`)
1213        // replaces ONLY the subshell child process; the parent shell
1214        // continues. C zsh always forks for `(...)`, so the actual
1215        // execvp lands in the forked child. zshrs runs subshells via
1216        // a snapshot/restore pattern in the SAME process — calling
1217        // execvp here would replace the parent too. Bug #94 in
1218        // docs/BUGS.md.
1219        //
1220        // Detect subshell context via the non-empty
1221        // `subshell_snapshots` stack. When in a subshell: spawn the
1222        // command as a child, wait for it, then signal the subshell
1223        // body to abort (return Status(N) and the caller's
1224        // subshell_end will pop the snapshot and resume the parent).
1225        let in_subshell = with_executor(|exec| !exec.subshell_snapshots.is_empty());
1226        if in_subshell {
1227            let mut command = std::process::Command::new(&cmd);
1228            command.arg0(&display_argv0);
1229            command.args(&rest);
1230            if clean_env {
1231                command.env_clear();
1232            }
1233            let status = match command.spawn() {
1234                Ok(mut child) => match child.wait() {
1235                    Ok(s) => s.code().unwrap_or(127),
1236                    Err(_) => 127,
1237                },
1238                Err(e) => {
1239                    // c:Src/exec.c:797 — `zerr("%e: %s", lerrno, arg0)`
1240                    //                     when arg0 contains `/`.
1241                    // c:872-876 — when arg0 has no `/` (PATH search
1242                    //              path), C tracks the "good" errno
1243                    //              via `isgooderr`; if all PATH entries
1244                    //              were ENOENT-not-good, eno stays 0
1245                    //              and C emits `command not found: %s`
1246                    //              instead of strerror.
1247                    // %e expands to strerror(errno) with the first
1248                    // letter lowercased (unless errno == EIO; see
1249                    // Src/utils.c:362-368). `zerr` prepends the
1250                    // scriptname:lineno: prefix — matching zsh's
1251                    // canonical `zsh:N: <errmsg>: <cmd>` pattern.
1252                    // Previously emitted `zshrs: exec: {}: not found`
1253                    // (wrong prefix, hardcoded message, missing
1254                    // lineno). Bug #140 in docs/BUGS.md.
1255                    let errno = e.raw_os_error().unwrap_or(libc::ENOENT);
1256                    let has_slash = cmd.contains('/');
1257                    if !has_slash && errno == libc::ENOENT {
1258                        // c:876 — PATH search exhausted with no good
1259                        // errno → `command not found: arg0`.
1260                        crate::ported::utils::zerr(&format!(
1261                            "command not found: {}",
1262                            cmd
1263                        ));
1264                    } else {
1265                        let mut errmsg = crate::ported::compat::strerror(errno);
1266                        if errno != libc::EIO {
1267                            if let Some(c) = errmsg.chars().next() {
1268                                errmsg = format!(
1269                                    "{}{}",
1270                                    c.to_ascii_lowercase(),
1271                                    &errmsg[c.len_utf8()..]
1272                                );
1273                            }
1274                        }
1275                        crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
1276                    }
1277                    // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
1278                    if errno == libc::EACCES || errno == libc::ENOEXEC {
1279                        126
1280                    } else {
1281                        127
1282                    }
1283                }
1284            };
1285            // Mark the subshell as exec-replaced so subsequent body
1286            // commands skip — mirrors the post-execvp "child process
1287            // is gone" reality in C. EXIT_PENDING + EXIT_VAL drive
1288            // the next ERREXIT_CHECK to unwind to the subshell-end
1289            // patch.
1290            crate::ported::builtin::EXIT_VAL
1291                .store(status, std::sync::atomic::Ordering::Relaxed);
1292            crate::ported::builtin::EXIT_PENDING
1293                .store(1, std::sync::atomic::Ordering::Relaxed);
1294            return Value::Status(status);
1295        }
1296        let mut command = std::process::Command::new(&cmd);
1297        command.arg0(&display_argv0);
1298        command.args(&rest);
1299        if clean_env {
1300            command.env_clear();
1301        }
1302        use std::os::unix::process::CommandExt;
1303        // `exec` returns the OS error iff exec(2) failed; on success
1304        // it never returns. Match zsh: print the error to stderr with
1305        // the `exec` prefix and exit 127 (cmd not found) or 126 (not
1306        // executable).
1307        let err = command.exec();
1308        // c:Src/exec.c:797 / c:872-876 — same format as in-subshell
1309        // branch. arg0-has-/ → `<strerror>: <cmd>`; arg0-no-/ +
1310        // ENOENT → `command not found: <cmd>`. Lowercase strerror
1311        // first letter unless EIO. Bug #140 in docs/BUGS.md.
1312        let errno = err.raw_os_error().unwrap_or(libc::ENOENT);
1313        let has_slash = cmd.contains('/');
1314        if !has_slash && errno == libc::ENOENT {
1315            crate::ported::utils::zerr(&format!("command not found: {}", cmd));
1316        } else {
1317            let mut errmsg = crate::ported::compat::strerror(errno);
1318            if errno != libc::EIO {
1319                if let Some(c) = errmsg.chars().next() {
1320                    errmsg = format!(
1321                        "{}{}",
1322                        c.to_ascii_lowercase(),
1323                        &errmsg[c.len_utf8()..]
1324                    );
1325                }
1326            }
1327            crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
1328        }
1329        // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
1330        let code = if errno == libc::EACCES || errno == libc::ENOEXEC {
1331            126
1332        } else {
1333            127
1334        };
1335        std::process::exit(code);
1336    });
1337
1338    reg_passthru!(vm, BUILTIN_LET, "let");
1339
1340    // Job control
1341    reg_passthru!(vm, BUILTIN_JOBS, "jobs");
1342    reg_passthru!(vm, BUILTIN_FG, "fg");
1343    reg_passthru!(vm, BUILTIN_BG, "bg");
1344    reg_passthru!(vm, BUILTIN_KILL, "kill");
1345    reg_passthru!(vm, BUILTIN_DISOWN, "disown");
1346    reg_passthru!(vm, BUILTIN_WAIT, "wait");
1347    reg_passthru!(vm, BUILTIN_SUSPEND, "suspend");
1348
1349    // History — `fc` / `history` / `r` all route to `bin_fc` (zsh
1350    // registers them as aliases of the same builtin per Src/builtin.c).
1351    reg_passthru!(vm, BUILTIN_FC, "fc");
1352    reg_passthru!(vm, BUILTIN_HISTORY, "history");
1353    reg_passthru!(vm, BUILTIN_R, "r");
1354
1355    // Aliases
1356    reg_passthru!(vm, BUILTIN_ALIAS, "alias");
1357
1358    // Options. `setopt` (BIN_SETOPT=0) / `unsetopt` (BIN_UNSETOPT=1)
1359    // share bin_setopt (options.c:580) — funcid bit discriminates
1360    // the polarity via BUILTINS table entries.
1361    reg_passthru!(vm, BUILTIN_SET, "set");
1362    reg_passthru!(vm, BUILTIN_SETOPT, "setopt");
1363    reg_passthru!(vm, BUILTIN_UNSETOPT, "unsetopt");
1364
1365    vm.register_builtin(BUILTIN_SHOPT, |vm, argc| {
1366        let args = pop_args(vm, argc);
1367        let status = crate::extensions::ext_builtins::shopt(&args);
1368        Value::Status(status)
1369    });
1370
1371    reg_passthru!(vm, BUILTIN_EMULATE, "emulate");
1372    reg_passthru!(vm, BUILTIN_GETOPTS, "getopts");
1373    reg_passthru!(vm, BUILTIN_AUTOLOAD, "autoload");
1374    reg_passthru!(vm, BUILTIN_FUNCTIONS, "functions");
1375    reg_passthru!(vm, BUILTIN_TRAP, "trap");
1376    reg_passthru!(vm, BUILTIN_DIRS, "dirs");
1377    // pushd / popd dispatch through canonical bin_cd via execbuiltin
1378    // — the BUILTINS table at src/ported/builtin.rs:9298 wires
1379    // `pushd` to bin_cd with funcid=BIN_PUSHD, and `popd` similarly
1380    // with BIN_POPD. Without these reg_passthru lines the fusevm
1381    // BUILTIN_PUSHD/POPD opcodes had no handler installed, so the
1382    // emitted CallBuiltin(110, …) silently returned a no-op and the
1383    // dirstack/$dirstack/pwd all stayed unchanged.
1384    reg_passthru!(vm, BUILTIN_PUSHD, "pushd");
1385    reg_passthru!(vm, BUILTIN_POPD, "popd");
1386    // type / whence / where / which all route through `bin_whence`
1387    // (canonical port at `src/ported/builtin.rs:3734` of
1388    // `Src/builtin.c:3975`). Each gets its own opcode so funcid +
1389    // defopts come from the BUILTINS table entry — execbuiltin
1390    // applies them correctly via the module-level dispatch_builtin.
1391    reg_passthru!(vm, BUILTIN_WHENCE, "whence");
1392    reg_passthru!(vm, BUILTIN_TYPE, "type");
1393    reg_passthru!(vm, BUILTIN_WHICH, "which");
1394    reg_passthru!(vm, BUILTIN_WHERE, "where");
1395    reg_passthru!(vm, BUILTIN_HASH, "hash");
1396    reg_passthru!(vm, BUILTIN_REHASH, "rehash");
1397
1398    // `unhash`/`unalias`/`unfunction` share `bin_unhash` (Src/builtin.c
1399    // c:4350) but each carries its own funcid (BIN_UNHASH /
1400    // BIN_UNALIAS / BIN_UNFUNCTION) in the BUILTINS table.
1401    reg_passthru!(vm, BUILTIN_UNHASH, "unhash");
1402    vm.register_builtin(BUILTIN_UNALIAS, |vm, argc| {
1403        let args = pop_args(vm, argc);
1404        Value::Status(dispatch_builtin("unalias", args))
1405    });
1406    vm.register_builtin(BUILTIN_UNFUNCTION, |vm, argc| {
1407        let args = pop_args(vm, argc);
1408        Value::Status(dispatch_builtin("unfunction", args))
1409    });
1410
1411    // Completion
1412    vm.register_builtin(BUILTIN_COMPGEN, |vm, argc| {
1413        let args = pop_args(vm, argc);
1414        // c:Bug #475/#555 — `compgen` is a bash-only builtin. In
1415        // `--zsh` mode emit "command not found" matching zsh's
1416        // external-command lookup miss.
1417        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1418            eprintln!("zsh:1: command not found: compgen");
1419            let _ = args;
1420            return Value::Status(127);
1421        }
1422        let status = with_executor(|exec| exec.builtin_compgen(&args));
1423        Value::Status(status)
1424    });
1425
1426    vm.register_builtin(BUILTIN_COMPLETE, |vm, argc| {
1427        let args = pop_args(vm, argc);
1428        // c:Bug #475 — `complete` is a bash-only builtin. Same gate
1429        // as BUILTIN_COMPGEN above.
1430        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1431            eprintln!("zsh:1: command not found: complete");
1432            let _ = args;
1433            return Value::Status(127);
1434        }
1435        let status = with_executor(|exec| exec.builtin_complete(&args));
1436        Value::Status(status)
1437    });
1438
1439    reg_passthru!(vm, BUILTIN_COMPADD, "compadd");
1440    reg_passthru!(vm, BUILTIN_COMPSET, "compset");
1441
1442    vm.register_builtin(BUILTIN_COMPDEF, |vm, argc| {
1443        let args = pop_args(vm, argc);
1444        Value::Status(with_executor(|exec| exec.builtin_compdef(&args)))
1445    });
1446
1447    vm.register_builtin(BUILTIN_COMPINIT, |vm, argc| {
1448        let args = pop_args(vm, argc);
1449        Value::Status(with_executor(|exec| exec.builtin_compinit(&args)))
1450    });
1451
1452    vm.register_builtin(BUILTIN_CDREPLAY, |vm, argc| {
1453        let args = pop_args(vm, argc);
1454        Value::Status(with_executor(|exec| exec.builtin_cdreplay(&args)))
1455    });
1456
1457    // Zsh-specific
1458    reg_passthru!(vm, BUILTIN_ZSTYLE, "zstyle");
1459    reg_passthru!(vm, BUILTIN_ZMODLOAD, "zmodload");
1460    reg_passthru!(vm, BUILTIN_BINDKEY, "bindkey");
1461    reg_passthru!(vm, BUILTIN_ZLE, "zle");
1462    reg_passthru!(vm, BUILTIN_VARED, "vared");
1463    reg_passthru!(vm, BUILTIN_ZCOMPILE, "zcompile");
1464    reg_passthru!(vm, BUILTIN_ZFORMAT, "zformat");
1465    reg_passthru!(vm, BUILTIN_ZPARSEOPTS, "zparseopts");
1466    reg_passthru!(vm, BUILTIN_ZREGEXPARSE, "zregexparse");
1467
1468    // Resource limits
1469    reg_passthru!(vm, BUILTIN_ULIMIT, "ulimit");
1470    reg_passthru!(vm, BUILTIN_LIMIT, "limit");
1471    reg_passthru!(vm, BUILTIN_UNLIMIT, "unlimit");
1472    reg_passthru!(vm, BUILTIN_UMASK, "umask");
1473
1474    // Misc
1475    reg_passthru!(vm, BUILTIN_TIMES, "times");
1476
1477    vm.register_builtin(BUILTIN_CALLER, |vm, argc| {
1478        let args = pop_args(vm, argc);
1479        // c:Bug #475 — `caller` is a bash-only builtin. In `--zsh`
1480        // mode emit the canonical "command not found" diagnostic
1481        // and rc=127 matching zsh's external-command-lookup miss.
1482        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1483            eprintln!("zsh:1: command not found: caller");
1484            let _ = args;
1485            return Value::Status(127);
1486        }
1487        Value::Status(with_executor(|exec| exec.builtin_caller(&args)))
1488    });
1489
1490    vm.register_builtin(BUILTIN_HELP, |vm, argc| {
1491        let args = pop_args(vm, argc);
1492        // c:Bug #475 — `help` is a bash-only builtin. Same gate as
1493        // BUILTIN_CALLER above.
1494        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1495            eprintln!("zsh:1: command not found: help");
1496            let _ = args;
1497            return Value::Status(127);
1498        }
1499        Value::Status(with_executor(|exec| exec.builtin_help(&args)))
1500    });
1501
1502    reg_passthru!(vm, BUILTIN_ENABLE, "enable");
1503    reg_passthru!(vm, BUILTIN_DISABLE, "disable");
1504    reg_passthru!(vm, BUILTIN_TTYCTL, "ttyctl");
1505    reg_passthru!(vm, BUILTIN_SYNC, "sync");
1506    reg_passthru!(vm, BUILTIN_MKDIR, "mkdir");
1507    reg_passthru!(vm, BUILTIN_STRFTIME, "strftime");
1508
1509    vm.register_builtin(BUILTIN_ZSLEEP, |vm, argc| {
1510        Value::Status(crate::extensions::ext_builtins::zsleep(&pop_args(vm, argc)))
1511    });
1512
1513    reg_passthru!(vm, BUILTIN_ZSYSTEM, "zsystem");
1514
1515    // PCRE
1516    reg_passthru!(vm, BUILTIN_PCRE_COMPILE, "pcre_compile");
1517    reg_passthru!(vm, BUILTIN_PCRE_MATCH, "pcre_match");
1518    reg_passthru!(vm, BUILTIN_PCRE_STUDY, "pcre_study");
1519
1520    // Database (GDBM)
1521    reg_passthru!(vm, BUILTIN_ZTIE, "ztie");
1522    reg_passthru!(vm, BUILTIN_ZUNTIE, "zuntie");
1523    reg_passthru!(vm, BUILTIN_ZGDBMPATH, "zgdbmpath");
1524
1525    // Prompt
1526    vm.register_builtin(BUILTIN_PROMPTINIT, |vm, argc| {
1527        let args = pop_args(vm, argc);
1528        Value::Status(crate::extensions::ext_builtins::promptinit(&args))
1529    });
1530
1531    vm.register_builtin(BUILTIN_PROMPT, |vm, argc| {
1532        let args = pop_args(vm, argc);
1533        Value::Status(crate::extensions::ext_builtins::prompt(&args))
1534    });
1535
1536    // Async / Parallel (zshrs extensions)
1537    vm.register_builtin(BUILTIN_ASYNC, |vm, argc| {
1538        let args = pop_args(vm, argc);
1539        let status = with_executor(|exec| exec.builtin_async(&args));
1540        Value::Status(status)
1541    });
1542
1543    vm.register_builtin(BUILTIN_AWAIT, |vm, argc| {
1544        let args = pop_args(vm, argc);
1545        let status = with_executor(|exec| exec.builtin_await(&args));
1546        Value::Status(status)
1547    });
1548
1549    vm.register_builtin(BUILTIN_PMAP, |vm, argc| {
1550        let args = pop_args(vm, argc);
1551        let status = with_executor(|exec| exec.builtin_pmap(&args));
1552        Value::Status(status)
1553    });
1554
1555    vm.register_builtin(BUILTIN_PGREP, |vm, argc| {
1556        let args = pop_args(vm, argc);
1557        let status = with_executor(|exec| exec.builtin_pgrep(&args));
1558        Value::Status(status)
1559    });
1560
1561    vm.register_builtin(BUILTIN_PEACH, |vm, argc| {
1562        let args = pop_args(vm, argc);
1563        let status = with_executor(|exec| exec.builtin_peach(&args));
1564        Value::Status(status)
1565    });
1566
1567    vm.register_builtin(BUILTIN_BARRIER, |vm, argc| {
1568        let args = pop_args(vm, argc);
1569        let status = with_executor(|exec| exec.builtin_barrier(&args));
1570        Value::Status(status)
1571    });
1572
1573    // Intercept (AOP)
1574    vm.register_builtin(BUILTIN_INTERCEPT, |vm, argc| {
1575        let args = pop_args(vm, argc);
1576        let status = with_executor(|exec| exec.builtin_intercept(&args));
1577        Value::Status(status)
1578    });
1579
1580    vm.register_builtin(BUILTIN_INTERCEPT_PROCEED, |vm, argc| {
1581        let args = pop_args(vm, argc);
1582        let status = with_executor(|exec| exec.builtin_intercept_proceed(&args));
1583        Value::Status(status)
1584    });
1585
1586    // Debug / Profile
1587    vm.register_builtin(BUILTIN_DOCTOR, |vm, argc| {
1588        let args = pop_args(vm, argc);
1589        let status = with_executor(|exec| exec.builtin_doctor(&args));
1590        Value::Status(status)
1591    });
1592
1593    vm.register_builtin(BUILTIN_DBVIEW, |vm, argc| {
1594        let args = pop_args(vm, argc);
1595        let status = with_executor(|exec| exec.builtin_dbview(&args));
1596        Value::Status(status)
1597    });
1598
1599    vm.register_builtin(BUILTIN_PROFILE, |vm, argc| {
1600        let args = pop_args(vm, argc);
1601        let status = with_executor(|exec| exec.builtin_profile(&args));
1602        Value::Status(status)
1603    });
1604
1605    reg_passthru!(vm, BUILTIN_ZPROF, "zprof");
1606
1607    // ═══════════════════════════════════════════════════════════════════════
1608    // Coreutils builtins (anti-fork, gated by !posix_mode)
1609    //
1610    // All of these are routinely wrapped by user functions in real
1611    // dotfiles (zpwr, oh-my-zsh, etc.) — `cat() { ... }`, `ls() { ... }`,
1612    // `find() { ... }`. Each handler MUST consult try_user_fn_override
1613    // first (via reg_overridable!) so the user definition wins, matching
1614    // zsh's alias → function → builtin dispatch order.
1615    // ═══════════════════════════════════════════════════════════════════════
1616
1617    reg_overridable!(vm, BUILTIN_CAT, "cat", builtin_cat);
1618    reg_overridable!(vm, BUILTIN_HEAD, "head", builtin_head);
1619    reg_overridable!(vm, BUILTIN_TAIL, "tail", builtin_tail);
1620    reg_overridable!(vm, BUILTIN_WC, "wc", builtin_wc);
1621    reg_overridable!(vm, BUILTIN_BASENAME, "basename", builtin_basename);
1622    reg_overridable!(vm, BUILTIN_DIRNAME, "dirname", builtin_dirname);
1623    reg_overridable!(vm, BUILTIN_TOUCH, "touch", builtin_touch);
1624    reg_overridable!(vm, BUILTIN_REALPATH, "realpath", builtin_realpath);
1625    reg_overridable!(vm, BUILTIN_SORT, "sort", builtin_sort);
1626    reg_overridable!(vm, BUILTIN_FIND, "find", builtin_find);
1627    reg_overridable!(vm, BUILTIN_UNIQ, "uniq", builtin_uniq);
1628    reg_overridable!(vm, BUILTIN_CUT, "cut", builtin_cut);
1629    reg_overridable!(vm, BUILTIN_TR, "tr", builtin_tr);
1630    reg_overridable!(vm, BUILTIN_SEQ, "seq", builtin_seq);
1631    reg_overridable!(vm, BUILTIN_REV, "rev", builtin_rev);
1632    reg_overridable!(vm, BUILTIN_TEE, "tee", builtin_tee);
1633    reg_overridable!(vm, BUILTIN_SLEEP, "sleep", builtin_sleep);
1634    reg_overridable!(vm, BUILTIN_WHOAMI, "whoami", builtin_whoami);
1635    reg_overridable!(vm, BUILTIN_ID, "id", builtin_id);
1636
1637    reg_overridable!(vm, BUILTIN_HOSTNAME, "hostname", builtin_hostname);
1638    reg_overridable!(vm, BUILTIN_UNAME, "uname", builtin_uname);
1639    reg_overridable!(vm, BUILTIN_DATE, "date", builtin_date);
1640    reg_overridable!(vm, BUILTIN_MKTEMP, "mktemp", builtin_mktemp);
1641    // `cp` — zshrs extension (NOT in upstream zsh; upstream's
1642    // zsh/files module ships `ln`/`mv`/`rm`/`chmod`/`chown` but no
1643    // `cp`). In-process implementation in
1644    // `ext_builtins::cp_impl` — recursive copy with -r/-R, -f, -i,
1645    // -n, -p (chown + utimensat), -v. ID 263 is the first slot
1646    // past fusevm's built-in range (260-262) and before BUILTIN_MAX
1647    // (280).
1648    /// `BUILTIN_CP` constant.
1649    pub const BUILTIN_CP: u16 = 263;
1650    reg_overridable!(vm, BUILTIN_CP, "cp", builtin_cp);
1651
1652    // Pipeline execution — bytecode-native fork-per-stage. Pops N sub-chunk
1653    // indices, forks N children with stdin/stdout wired through N-1 pipes,
1654    // each child runs its stage's compiled bytecode and exits. Parent waits
1655    // and returns the last stage's status.
1656    //
1657    // Caveats: post-fork in a multi-threaded program, only async-signal-safe
1658    // ops are POSIX-safe. We violate this (running the bytecode VM after fork
1659    // touches mutexes like REGEX_CACHE). In practice, most pipeline stages
1660    // don't touch shared mutex state — externals fork/exec away, builtins do
1661    // pure I/O. Risks are bounded; if a stage does touch a held mutex, the
1662    // child deadlocks.
1663    vm.register_builtin(BUILTIN_RUN_PIPELINE, |vm, argc| {
1664        let n = argc as usize;
1665        if n == 0 {
1666            return Value::Status(0);
1667        }
1668
1669        // Pop N sub-chunk indices (LIFO → reverse to stage order)
1670        let mut indices: Vec<u16> = Vec::with_capacity(n);
1671        for _ in 0..n {
1672            indices.push(vm.pop().to_int() as u16);
1673        }
1674        indices.reverse();
1675
1676        // Clone each stage's sub-chunk
1677        let stages: Vec<fusevm::Chunk> = indices
1678            .iter()
1679            .filter_map(|&i| vm.chunk.sub_chunks.get(i as usize).cloned())
1680            .collect();
1681        if stages.len() != n {
1682            return Value::Status(1);
1683        }
1684
1685        // Single stage — no pipe, just run inline
1686        if n == 1 {
1687            let stage = stages.into_iter().next().unwrap();
1688            crate::fusevm_disasm::maybe_print_stdout("pipeline:single", &stage);
1689            let mut stage_vm = fusevm::VM::new(stage);
1690            register_builtins(&mut stage_vm);
1691            let _ = stage_vm.run();
1692            return Value::Status(stage_vm.last_status);
1693        }
1694
1695        // Build N-1 pipes
1696        let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(n - 1);
1697        for _ in 0..n - 1 {
1698            let mut fds = [0i32; 2];
1699            if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
1700                // Cleanup any pipes we already created
1701                for (r, w) in &pipes {
1702                    unsafe {
1703                        libc::close(*r);
1704                        libc::close(*w);
1705                    }
1706                }
1707                return Value::Status(1);
1708            }
1709            pipes.push((fds[0], fds[1]));
1710        }
1711
1712        // zsh runs the LAST stage of a pipeline in the CURRENT shell
1713        // (not a forked child) so a trailing `read x` keeps its
1714        // assignment in the parent. Other shells (bash) fork every
1715        // stage. Honor zsh by leaving stage N-1 inline. Forks the
1716        // first N-1 stages with fork(); runs the last in this process
1717        // with stdin dup2'd to the last pipe's read end and stdout
1718        // restored after.
1719        let last_idx = n - 1;
1720        let stages_vec: Vec<fusevm::Chunk> = stages.into_iter().collect();
1721
1722        let mut child_pids: Vec<libc::pid_t> = Vec::with_capacity(n - 1);
1723        for (i, chunk) in stages_vec.iter().take(last_idx).enumerate() {
1724            match unsafe { libc::fork() } {
1725                -1 => {
1726                    // fork failed — kill any children we already started
1727                    for pid in &child_pids {
1728                        unsafe { libc::kill(*pid, libc::SIGTERM) };
1729                    }
1730                    for (r, w) in &pipes {
1731                        unsafe {
1732                            libc::close(*r);
1733                            libc::close(*w);
1734                        }
1735                    }
1736                    return Value::Status(1);
1737                }
1738                0 => {
1739                    // Reset SIGPIPE to default so a broken-pipe write
1740                    // kills the child cleanly instead of triggering a
1741                    // Rust println! panic. The parent shell ignores
1742                    // SIGPIPE so it can handle EPIPE itself, but child
1743                    // pipeline stages should die quietly when their
1744                    // downstream stage closes early (e.g. `seq | head -3`).
1745                    unsafe {
1746                        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
1747                    }
1748                    // c:Src/exec.c — pipeline children are forked
1749                    // subshells; their EXIT trap context is reset so
1750                    // the parent's `trap '...' EXIT` doesn't fire when
1751                    // the child exits. Mirror by dropping EXIT from
1752                    // the inherited traps_table inside the child.
1753                    if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
1754                        t.remove("EXIT");
1755                    }
1756                    // Child: wire stdin from previous pipe's read end
1757                    if i > 0 {
1758                        unsafe {
1759                            libc::dup2(pipes[i - 1].0, libc::STDIN_FILENO);
1760                        }
1761                    }
1762                    // Wire stdout to next pipe's write end
1763                    unsafe {
1764                        libc::dup2(pipes[i].1, libc::STDOUT_FILENO);
1765                    }
1766                    // Close all original pipe fds (keeping stdin/stdout dups)
1767                    for (r, w) in &pipes {
1768                        unsafe {
1769                            libc::close(*r);
1770                            libc::close(*w);
1771                        }
1772                    }
1773
1774                    // Run this stage's bytecode on a fresh VM
1775                    crate::fusevm_disasm::maybe_print_stdout(
1776                        &format!("pipeline:child:stage:{i}"),
1777                        chunk,
1778                    );
1779                    let mut stage_vm = fusevm::VM::new(chunk.clone());
1780                    register_builtins(&mut stage_vm);
1781                    let _ = stage_vm.run();
1782                    // Flush any buffered output before exiting
1783                    let _ = std::io::stdout().flush();
1784                    let _ = std::io::stderr().flush();
1785                    std::process::exit(stage_vm.last_status);
1786                }
1787                pid => {
1788                    child_pids.push(pid);
1789                }
1790            }
1791        }
1792
1793        // Parent runs the LAST stage inline. Save stdin, dup the last
1794        // pipe's read end onto fd 0, run the chunk, restore stdin.
1795        // Close every other pipe fd so the producer side gets EOF
1796        // when the last upstream stage exits.
1797        let saved_stdin = unsafe { libc::dup(libc::STDIN_FILENO) };
1798        if last_idx > 0 {
1799            let read_fd = pipes[last_idx - 1].0;
1800            unsafe {
1801                libc::dup2(read_fd, libc::STDIN_FILENO);
1802            }
1803        }
1804        // Close all pipe fds in the parent now that stdin is wired.
1805        // (Children already have their own copies. The dup2 above
1806        // already gave us a fresh fd 0 if needed.)
1807        for (r, w) in &pipes {
1808            unsafe {
1809                libc::close(*r);
1810                libc::close(*w);
1811            }
1812        }
1813
1814        // Run the last stage's bytecode on a sub-VM with the host
1815        // wired up. The host points back at the executor so reads
1816        // (`read x`) update the parent's variables directly.
1817        let last_stage_status = {
1818            let last_chunk = stages_vec.into_iter().last().unwrap();
1819            crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
1820            let mut stage_vm = fusevm::VM::new(last_chunk);
1821            register_builtins(&mut stage_vm);
1822            stage_vm.set_shell_host(Box::new(ZshrsHost));
1823            let _ = stage_vm.run();
1824            let _ = std::io::stdout().flush();
1825            let _ = std::io::stderr().flush();
1826            stage_vm.last_status
1827        };
1828
1829        // Restore stdin
1830        if saved_stdin >= 0 {
1831            unsafe {
1832                libc::dup2(saved_stdin, libc::STDIN_FILENO);
1833                libc::close(saved_stdin);
1834            }
1835        }
1836
1837        // Wait for all forked stages, capture per-stage statuses for PIPESTATUS.
1838        let mut pipestatus: Vec<i32> = Vec::with_capacity(n);
1839        for pid in child_pids {
1840            let mut status: i32 = 0;
1841            unsafe {
1842                libc::waitpid(pid, &mut status, 0);
1843            }
1844            let s = if libc::WIFEXITED(status) {
1845                libc::WEXITSTATUS(status)
1846            } else if libc::WIFSIGNALED(status) {
1847                128 + libc::WTERMSIG(status)
1848            } else {
1849                1
1850            };
1851            pipestatus.push(s);
1852        }
1853        // Append the in-parent last-stage status so `pipestatus` ends
1854        // with N entries (one per stage).
1855        pipestatus.push(last_stage_status);
1856        // Pipeline exit status: by default, the LAST stage's status.
1857        // With `setopt pipefail` (or `set -o pipefail`), use the
1858        // first non-zero stage status (so failures earlier in the
1859        // pipeline propagate even if the last stage succeeded).
1860        let pipefail_on = with_executor(|exec| opt_state_get("pipefail").unwrap_or(false));
1861        let last_status = if pipefail_on {
1862            pipestatus
1863                .iter()
1864                .copied()
1865                .rfind(|&s| s != 0)
1866                .or_else(|| pipestatus.last().copied())
1867                .unwrap_or(0)
1868        } else {
1869            *pipestatus.last().unwrap_or(&0)
1870        };
1871
1872        // c:Src/params.c:265,438 — only `pipestatus` (lowercase) is the
1873        // zsh special parameter; bash's `PIPESTATUS` doesn't exist in
1874        // zsh's special-params table. Prior port also populated
1875        // `PIPESTATUS` "for portability" — but that's a real divergence
1876        // from zsh: a script doing `[[ -z $PIPESTATUS ]]` to detect
1877        // zsh-vs-bash would mis-classify. Bug #64 in docs/BUGS.md.
1878        with_executor(|exec| {
1879            let strs: Vec<String> = pipestatus.iter().map(|s| s.to_string()).collect();
1880            exec.set_array("pipestatus".to_string(), strs);
1881        });
1882
1883        Value::Status(last_status)
1884    });
1885
1886    // Array→String join. Pops one value; if it's an Array (e.g. from Op::Glob),
1887    // joins string-coerced elements with a single space. Pass-through for
1888    // non-arrays so the op is safe to chain after any String-or-Array producer.
1889    vm.register_builtin(BUILTIN_ARRAY_JOIN, |vm, _argc| {
1890        let val = vm.pop();
1891        match val {
1892            Value::Array(items) => {
1893                let parts: Vec<String> = items.iter().map(|v| v.to_str()).collect();
1894                Value::str(parts.join(" "))
1895            }
1896            other => other,
1897        }
1898    });
1899
1900    // `cmd &` background execution. Compile_list emits this for any item
1901    // followed by ListOp::Amp: the cmd is compiled into a sub-chunk, its index
1902    // pushed, then this builtin pops the index, looks up the chunk, forks. The
1903    // child detaches via setsid (so SIGINT to the foreground job doesn't kill
1904    // it), runs the bytecode on a fresh VM with builtins re-registered, exits
1905    // with the last status. The parent returns Status(0) immediately. Job
1906    // tracking via JobTable is deferred to Phase G6 — JobTable::add_job
1907    // currently requires a std::process::Child, which a libc::fork doesn't
1908    // produce. Until then, `jobs`/`fg`/`wait` can't see these pids.
1909    //WARNING FAKE AND MUST BE DELETED
1910    vm.register_builtin(BUILTIN_RUN_BG, |vm, _argc| {
1911        let sub_idx = vm.pop().to_int() as usize;
1912        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
1913            Some(c) => c,
1914            None => return Value::Status(1),
1915        };
1916
1917        match unsafe { libc::fork() } {
1918            -1 => Value::Status(1),
1919            0 => {
1920                // Child: detach and run.
1921                unsafe { libc::setsid() };
1922                crate::fusevm_disasm::maybe_print_stdout("background_job", &chunk);
1923                let mut bg_vm = fusevm::VM::new(chunk);
1924                register_builtins(&mut bg_vm);
1925                let _ = bg_vm.run();
1926                let _ = std::io::stdout().flush();
1927                let _ = std::io::stderr().flush();
1928                std::process::exit(bg_vm.last_status);
1929            }
1930            pid => {
1931                // Parent: record the PID into `$!` (most recent
1932                // backgrounded job's pid). zsh exposes this for any
1933                // script that needs `wait $!`. Also register the
1934                // bare-pid job so a no-args `wait` can synchronize.
1935                // c:Src/jobs.c:73 — `lastpid = pid;` after a
1936                // background fork. zshrs's `$!` getter
1937                // (params.rs::lookup_special_var "!") reads from
1938                // the same atomic, so a single store here is the
1939                // canonical writer.
1940                crate::ported::modules::clone::lastpid
1941                    .store(pid, std::sync::atomic::Ordering::Relaxed);
1942                with_executor(|exec| {
1943                    exec.jobs.add_pid_job(pid, String::new(), JobState::Running);
1944                });
1945                Value::Status(0)
1946            }
1947        }
1948    });
1949
1950    // ── Indexed-array storage ─────────────────────────────────────────────
1951    //
1952    // Stack: pushed values then name (LAST). `arr=(a b c)` → 4 args
1953    // (a, b, c, arr). `arr=($(cmd))` → 2 args (FlatArray, arr).
1954    //
1955    // PURE PASSTHRU: pop name + values, dispatch to canonical
1956    // `setaparam` / `sethparam` (C port of `Src/params.c:3595/3602`).
1957    // assignaparam already handles PM_UNIQUE dedupe, type-flag flip,
1958    // PM_NAMEREF rejection, ASSPM_AUGMENT prepend, and createparam
1959    // for fresh names.
1960    vm.register_builtin(BUILTIN_SET_ARRAY, |vm, argc| {
1961        let n = argc as usize;
1962        let mut popped: Vec<Value> = Vec::with_capacity(n);
1963        for _ in 0..n {
1964            popped.push(vm.pop());
1965        }
1966        popped.reverse();
1967        if popped.is_empty() {
1968            return Value::Status(1);
1969        }
1970        let name = popped.pop().unwrap().to_str();
1971        let mut values: Vec<String> = Vec::new();
1972        for v in popped {
1973            match v {
1974                Value::Array(items) => {
1975                    for it in items {
1976                        values.push(it.to_str());
1977                    }
1978                }
1979                other => values.push(other.to_str()),
1980            }
1981        }
1982        let blocked = with_executor(|exec| {
1983            // Assoc init `typeset -A m; m=(k v k v ...)` — route to
1984            // canonical sethparam (Src/params.c:3602) which parses the
1985            // flat (k,v) pair list internally.
1986            if exec.assoc(&name).is_some() {
1987                if !values.len().is_multiple_of(2) {
1988                    eprintln!(
1989                        "{}:1: bad set of key/value pairs for associative array",
1990                        shname()
1991                    );
1992                    return true;
1993                }
1994                let _ = crate::ported::params::sethparam(&name, values.clone());
1995                #[cfg(feature = "recorder")]
1996                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
1997                    let ctx = exec.recorder_ctx();
1998                    let attrs = exec.recorder_attrs_for(&name);
1999                    let mut pairs: Vec<(String, String)> = Vec::with_capacity(values.len() / 2);
2000                    let mut iter = values.iter().cloned();
2001                    while let Some(k) = iter.next() {
2002                        if let Some(v) = iter.next() {
2003                            pairs.push((k, v));
2004                        }
2005                    }
2006                    crate::recorder::emit_assoc_assign(&name, pairs, attrs, false, ctx);
2007                }
2008                return false;
2009            }
2010            // Indexed-array: setaparam (Src/params.c:3766) wraps
2011            // assignaparam with ASSPM_WARN — handles PM_UNIQUE dedupe,
2012            // type-flag flip, PM_READONLY rejection.
2013            //
2014            // The tied-array mirror to a PM_TIED scalar
2015            // (`typeset -T PATH path`) lives canonically in
2016            // setarrvalue's dispatch in C zsh; until that wires
2017            // through assignaparam, mirror here so PATH stays in sync
2018            // after `path=(/x)`.
2019            if let Some((scalar_name, sep)) = exec.tied_array_to_scalar.get(&name).cloned() {
2020                let joined = values.join(&sep);
2021                exec.set_scalar(scalar_name, joined);
2022            }
2023            let _ = crate::ported::params::setaparam(&name, values.clone());
2024            #[cfg(feature = "recorder")]
2025            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2026                let ctx = exec.recorder_ctx();
2027                let attrs = exec.recorder_attrs_for(&name);
2028                emit_path_or_assign(&name, &values, attrs, false, &ctx);
2029            }
2030            false
2031        });
2032        Value::Status(if blocked { 1 } else { 0 })
2033    });
2034    // `arr+=(d e f)` — array append. Same calling conventions as SET_ARRAY.
2035    //
2036    // PURE PASSTHRU shape: pop name + values, dispatch through the
2037    // canonical assoc / array setter. assignaparam's ASSPM_AUGMENT
2038    // flag handles the C-source-equivalent "preserve prior value"
2039    // semantics; for now we read the current array, extend with
2040    // new values, write through set_array (which routes to
2041    // setaparam → assignaparam where PM_UNIQUE dedupe lands).
2042    vm.register_builtin(BUILTIN_APPEND_ARRAY, |vm, argc| {
2043        let n = argc as usize;
2044        let mut popped: Vec<Value> = Vec::with_capacity(n);
2045        for _ in 0..n {
2046            popped.push(vm.pop());
2047        }
2048        popped.reverse();
2049        if popped.is_empty() {
2050            return Value::Status(1);
2051        }
2052        let name = popped.pop().unwrap().to_str();
2053        let mut values: Vec<String> = Vec::new();
2054        for v in popped {
2055            match v {
2056                Value::Array(items) => {
2057                    for it in items {
2058                        values.push(it.to_str());
2059                    }
2060                }
2061                other => values.push(other.to_str()),
2062            }
2063        }
2064        with_executor(|exec| {
2065            // Assoc append `m+=(k1 v1 ...)`: merge the (k,v) pairs into
2066            // the existing map and write back via canonical sethparam
2067            // (Src/params.c:3602). The canonical C path would go
2068            // assignaparam(ASSPM_AUGMENT) → arrhashsetfn(ASSPM_AUGMENT)
2069            // at Src/params.c:3850, but the zshrs port of
2070            // arrhashsetfn doesn't yet implement value-storage
2071            // (pending Param.u_hash backend wireup) — until that
2072            // lands, do the augment + write here so the storage
2073            // actually mutates.
2074            if exec.assoc(&name).is_some() {
2075                let mut map = exec.assoc(&name).unwrap_or_default();
2076                let mut it = values.into_iter();
2077                while let Some(k) = it.next() {
2078                    if let Some(v) = it.next() {
2079                        map.insert(k, v);
2080                    }
2081                }
2082                exec.set_assoc(name, map);
2083                return;
2084            }
2085            // Indexed-array append `arr+=(d e f)` — route directly
2086            // through canonical assignaparam with ASSPM_AUGMENT
2087            // (`Src/params.c:3570-3585` append-on-array branch).
2088            // assignaparam reads the prior array internally and
2089            // appends the new values, so the bridge no longer needs
2090            // to pre-concat manually.
2091            let _ = crate::ported::params::assignaparam(
2092                &name,
2093                values.clone(),
2094                crate::ported::zsh_h::ASSPM_AUGMENT,
2095            );
2096            #[cfg(feature = "recorder")]
2097            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2098                let ctx = exec.recorder_ctx();
2099                let attrs = exec.recorder_attrs_for(&name);
2100                emit_path_or_assign(&name, &values, attrs, true, &ctx);
2101            }
2102            // Tied-scalar mirror — TODO faithful: should live in
2103            // setarrvalue's gsu dispatch once boot_ paramtab wiring
2104            // lands (Task #16). Re-read the canonical post-augment
2105            // array so the joined scalar matches.
2106            let tied_scalar = exec.tied_array_to_scalar.get(&name).cloned();
2107            if let Some((scalar_name, sep)) = tied_scalar {
2108                let merged = exec.array(&name).unwrap_or_default();
2109                let joined = merged.join(&sep);
2110                exec.set_scalar(scalar_name.clone(), joined.clone());
2111                env::set_var(&scalar_name, &joined);
2112            }
2113        });
2114        Value::Status(0)
2115    });
2116    vm.register_builtin(BUILTIN_RUN_SELECT, |vm, argc| {
2117        if argc < 2 {
2118            return Value::Status(1);
2119        }
2120        let n = argc as usize;
2121        let mut popped: Vec<Value> = Vec::with_capacity(n);
2122        for _ in 0..n {
2123            popped.push(vm.pop());
2124        }
2125        // popped: [sub_idx, name, word_N, ..., word_1] (popping from top)
2126        let sub_idx_val = popped.remove(0);
2127        let name_val = popped.remove(0);
2128        // c:Src/loop.c — `select` flattens Array values (from `$@`,
2129        // `${arr[@]}`, etc.) into the menu. Without per-element
2130        // splice, `select x do ... done` (bare, iterating $@)
2131        // collapsed all positionals into one joined entry.
2132        let mut words: Vec<String> = Vec::new();
2133        for v in popped.into_iter().rev() {
2134            match v {
2135                Value::Array(items) => {
2136                    for item in items {
2137                        words.push(item.to_str());
2138                    }
2139                }
2140                other => words.push(other.to_str()),
2141            }
2142        }
2143
2144        let sub_idx = sub_idx_val.to_int() as usize;
2145        let name = name_val.to_str();
2146
2147        // c:Src/loop.c:248-252 — `if (!args || empty(args)) {
2148        // state->pc = end; ... return 0; }`. An empty option list
2149        // skips the body entirely; without this gate the prompt loop
2150        // runs indefinitely (or twice on the EOF stdin case before
2151        // exiting). Bug #401.
2152        if words.is_empty() {
2153            return Value::Status(0);
2154        }
2155
2156        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
2157            Some(c) => c,
2158            None => return Value::Status(1),
2159        };
2160
2161        let prompt =
2162            with_executor(|exec| exec.scalar("PROMPT3").unwrap_or_else(|| "?# ".to_string()));
2163
2164        let stdin = std::io::stdin();
2165        let mut reader = stdin.lock();
2166        let mut last_status: i32 = 0;
2167
2168        loop {
2169            // Direct port of zsh's selectlist from
2170            // src/zsh/Src/loop.c:347-409. Layout is column-major
2171            // ("down columns, then across") — NOT row-major. With
2172            // 6 items in 3 cols zsh produces:
2173            //   1  3  5
2174            //   2  4  6
2175            // The previous Rust impl walked row-major which
2176            // produced 1 2 3 / 4 5 6 (visually similar but wrong
2177            // for prompts that mention ordering and breaks scripts
2178            // that rely on column count == ceil(N/rows)).
2179            //
2180            // C variable mapping:
2181            //   ct      -> word count (n)
2182            //   longest -> max item width + 1, then plus digits-of-ct
2183            //   fct     -> column count
2184            //   fw      -> per-column width
2185            //   colsz   -> row count = ceil(ct / fct)
2186            //   t1      -> row index, walks 0..colsz
2187            //   ap      -> item pointer; advances by colsz to step
2188            //              DOWN a column.
2189            let term_width: usize = env::var("COLUMNS")
2190                .ok()
2191                .and_then(|v| v.parse().ok())
2192                .unwrap_or(80);
2193            let ct = words.len();
2194            // loop.c:354-363 — find longest item width.
2195            let mut longest = 1usize;
2196            for w in &words {
2197                let aplen = w.chars().count();
2198                if aplen > longest {
2199                    longest = aplen;
2200                }
2201            }
2202            // loop.c:365-367 — `longest++` then add digits of `ct`.
2203            longest += 1;
2204            let mut t0 = ct;
2205            while t0 > 0 {
2206                t0 /= 10;
2207                longest += 1;
2208            }
2209            // loop.c:369-373 — fct = (cols - 1) / (longest + 3); if
2210            // 0, fct = 1; else fw = (cols - 1) / fct.
2211            let raw_fct = (term_width.saturating_sub(1)) / (longest + 3);
2212            let (fct, fw) = if raw_fct == 0 {
2213                (1, longest + 3)
2214            } else {
2215                (raw_fct, (term_width.saturating_sub(1)) / raw_fct)
2216            };
2217            // loop.c:374 — colsz = (ct + fct - 1) / fct.
2218            let colsz = ct.div_ceil(fct);
2219            // loop.c:375-395 — for each row t1, walk down columns.
2220            for t1 in 0..colsz {
2221                let mut ap_idx = t1;
2222                while ap_idx < ct {
2223                    let w = &words[ap_idx];
2224                    let n = ap_idx + 1;
2225                    let _ = write!(std::io::stderr(), "{}) {}", n, w);
2226                    let mut t2 = w.chars().count() + 2;
2227                    let mut t3 = n;
2228                    while t3 > 0 {
2229                        t2 += 1;
2230                        t3 /= 10;
2231                    }
2232                    // Pad to fw (loop.c:389-390).
2233                    while t2 < fw {
2234                        let _ = write!(std::io::stderr(), " ");
2235                        t2 += 1;
2236                    }
2237                    ap_idx += colsz;
2238                }
2239                let _ = writeln!(std::io::stderr());
2240            }
2241            let _ = write!(std::io::stderr(), "{}", prompt);
2242            let _ = std::io::stderr().flush();
2243
2244            let mut line = String::new();
2245            match reader.read_line(&mut line) {
2246                Ok(0) => {
2247                    // EOF — emit the final newline that zsh prints
2248                    // after the prompt-then-EOF sequence (c:Src/loop.c
2249                    // selectlist falls through to fputc('\n', stderr)
2250                    // at the end of the read failure path). Without
2251                    // this the next process's output runs directly
2252                    // after `-->>>> ` on the same line.
2253                    let _ = writeln!(std::io::stderr());
2254                    let _ = std::io::stderr().flush();
2255                    break;
2256                }
2257                Ok(_) => {}
2258                Err(_) => break,
2259            }
2260            let trimmed = line.trim_end_matches(['\n', '\r'][..].as_ref()).to_string();
2261
2262            with_executor(|exec| {
2263                exec.set_scalar("REPLY".to_string(), trimmed.clone());
2264            });
2265
2266            if trimmed.is_empty() {
2267                // Empty input → redraw menu without running body.
2268                continue;
2269            }
2270
2271            let chosen = match trimmed.parse::<usize>() {
2272                Ok(n) if n >= 1 && n <= words.len() => words[n - 1].clone(),
2273                _ => String::new(),
2274            };
2275
2276            with_executor(|exec| {
2277                exec.set_scalar(name.clone(), chosen);
2278            });
2279
2280            // Reset canonical BREAKS/CONTFLAG before running the body
2281            // so a stale value from a sibling construct doesn't leak in.
2282            crate::ported::builtin::BREAKS.store(0, SeqCst);
2283            crate::ported::builtin::CONTFLAG.store(0, SeqCst);
2284
2285            // c:Src/loop.c — `select` increments LOOPS for the body so
2286            // `break` / `continue` inside the body see loops > 0 and
2287            // don't emit `not in while, until, select, or repeat loop`.
2288            // Mirrors execwhile/execrepeat's `LOOPS.fetch_add` pattern.
2289            // The decrement happens after the body call so a body that
2290            // explicitly returns / errors still leaves the counter
2291            // balanced for the next iteration.
2292            crate::ported::builtin::LOOPS.fetch_add(1, SeqCst);
2293
2294            crate::fusevm_disasm::maybe_print_stdout("select:body", &chunk);
2295            let mut body_vm = fusevm::VM::new(chunk.clone());
2296            register_builtins(&mut body_vm);
2297            let _ = body_vm.run();
2298            last_status = body_vm.last_status;
2299
2300            crate::ported::builtin::LOOPS.fetch_sub(1, SeqCst);
2301
2302            // Drain the canonical BREAKS/CONTFLAG counters. Mirrors
2303            // loop.c:529-534's `if (breaks) { breaks--; if (breaks ||
2304            // !contflag) break; contflag = 0; }` drain pattern.
2305            // The legacy `BREAK_SELECT=1` env-var sentinel is still
2306            // honored for backward compat.
2307            let break_legacy = with_executor(|exec| {
2308                let v = exec.scalar("BREAK_SELECT");
2309                exec.unset_scalar("BREAK_SELECT");
2310                v.map(|s| s != "0" && !s.is_empty()).unwrap_or(false)
2311            });
2312            use std::sync::atomic::Ordering::SeqCst;
2313            let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
2314            if breaks > 0 {
2315                let cont = crate::ported::builtin::CONTFLAG.load(SeqCst);
2316                crate::ported::builtin::BREAKS.fetch_sub(1, SeqCst);
2317                if breaks - 1 > 0 || cont == 0 {
2318                    break;
2319                }
2320                crate::ported::builtin::CONTFLAG.store(0, SeqCst);
2321                continue;
2322            }
2323            if break_legacy {
2324                break;
2325            }
2326        }
2327
2328        Value::Status(last_status)
2329    });
2330
2331    // Magic special-parameter assoc lookup. Synthesizes values from
2332    // shell state for zsh's shell-introspection assocs:
2333    //   commands, aliases, galiases, saliases, dis_aliases, dis_galiases,
2334    //   dis_saliases, functions, dis_functions, builtins, dis_builtins,
2335    //   reswords, options, parameters, jobtexts, jobdirs, jobstates,
2336    //   nameddirs, userdirs, modules.
2337    // Returns None if `name` isn't a recognized magic name.
2338
2339    // `${arr[idx]}` — pop name, then idx_str. zsh is 1-based for positive
2340    // indices; we honor that. `@`/`*` return the whole array as Value::Array
2341    // so Op::Exec splice produces N argv slots. For `${foo[key]}` where foo
2342    // is an assoc, the idx is a string key — we check assoc_arrays first
2343    // when the idx isn't `@`/`*` and the name has an assoc binding.
2344    // BUILTIN_ARRAY_INDEX — `${name[idx]}` paramsubst dispatch.
2345    // PURE PASSTHRU: pops the idx + name, hands the canonical
2346    // `${name[idx]}` form to `subst::paramsubst` (C port of
2347    // `Src/subst.c::paramsubst`). All subscript-flag dispatch
2348    // ((I)pat / (R)pat / (i)/(r)/(K)/(k), range slices `[N,M]`,
2349    // negative indices, magic-assoc shape lookup, DQ-join collapse)
2350    // lives inside paramsubst → fetchvalue → getarg in params.rs.
2351    //
2352    // Outer-flag dispatch (`(@)` / `(@k)` / `(v)NAME[(I)pat]` / etc.)
2353    // routes through BUILTIN_BRIDGE_BRACE_ARRAY at the compile path
2354    // (canonical paramsubst flag parser owns dispatch at Src/subst.c:2147+),
2355    // so BUILTIN_ARRAY_INDEX receives clean name+key with no sentinel
2356    // prefixes.
2357    vm.register_builtin(BUILTIN_ARRAY_INDEX, |vm, _argc| {
2358        let idx = vm.pop().to_str();
2359        let name = vm.pop().to_str();
2360        // c:Src/subst.c subscript parsing — when paramsubst re-parses
2361        // the synthesized `${name[idx]}` body, characters like `'`
2362        // `"` `\` `$` etc. are LEXER-active inside the `[…]` and get
2363        // reinterpreted (quote-strip, paramsubst recursion, …). For
2364        // PRE-EVALUATED key strings (the dynamic-key fast path at
2365        // compile_zsh.rs:3234 already expanded `$k` via EXPAND_TEXT),
2366        // the idx is a literal string that must match the stored key
2367        // byte-for-byte — no further reinterpretation. Direct assoc
2368        // lookup bypasses the lexer for this case, avoiding the
2369        // quote-strip bug where `h[a'b]` failed to resolve because
2370        // paramsubst's subscript lexer treated the `'` as a quote.
2371        // Bug #338. Only fires for simple assoc-name + non-flag idx
2372        // (no outer-flag sentinels, no `(…)` flag prefix on idx, no
2373        // splat operator). Other paths (slice, splat, flag-based
2374        // search, magic-assoc) still flow through paramsubst.
2375        let idx_is_simple = !idx.starts_with('(')
2376            && idx != "@"
2377            && idx != "*"
2378            && !idx.contains(',');
2379        if idx_is_simple {
2380            if let Some(v) = with_executor(|exec| {
2381                exec.assoc(&name).and_then(|m| m.get(&idx).cloned())
2382            }) {
2383                return Value::str(v);
2384            }
2385        }
2386        let body = format!("${{{}[{}]}}", name, idx);
2387        paramsubst_to_value(&body)
2388    });
2389    // BUILTIN_ASSOC_HAS_KEY — `${(k)assoc[name]}` key-existence query.
2390    // Pops [assoc_name, key]; returns key (Str) if present in the
2391    // assoc, empty Str otherwise. Mirrors zsh's `${(k)h[name]}`
2392    // documented semantics in zshparam(1) "Parameter Expansion Flags".
2393    // Distinct from BUILTIN_ARRAY_INDEX (which returns the VALUE) and
2394    // from `${+h[name]}` (which returns "0"/"1"). Bug #145.
2395    vm.register_builtin(BUILTIN_ASSOC_HAS_KEY, |vm, _argc| {
2396        let key = vm.pop().to_str();
2397        let name = vm.pop().to_str();
2398        let exists = crate::ported::params::gethkparam(&name)
2399            .map(|keys| keys.iter().any(|k| k == &key))
2400            .unwrap_or(false);
2401        if exists {
2402            Value::str(key)
2403        } else {
2404            Value::str("")
2405        }
2406    });
2407    vm.register_builtin(BUILTIN_BRIDGE_BRACE_ARRAY, |vm, _argc| {
2408        // Inner body of `${(...)...}` (already stripped of `${`/`}` by
2409        // the caller). The compiler optionally prefixes Qstring
2410        // (\u{8c}) to signal "expanded in DQ context" — strip it
2411        // here and bump in_dq_context for the paramsubst call so the
2412        // SUB_ZIP and other qt-aware paths fire.
2413        let body = vm.pop().to_str();
2414        let (dq, inner) = if let Some(rest) = body.strip_prefix('\u{8c}') {
2415            (true, rest.to_string())
2416        } else {
2417            (false, body)
2418        };
2419        if dq {
2420            with_executor(|exec| exec.in_dq_context += 1);
2421        }
2422        let v = paramsubst_to_value(&format!("${{{}}}", inner));
2423        if dq {
2424            with_executor(|exec| exec.in_dq_context -= 1);
2425        }
2426        v
2427    });
2428
2429    // BUILTIN_PARAM_FLAG — `${(flags)name}` paramsubst dispatch.
2430    // PURE PASSTHRU: pops sentinel-tagged flags + name, hands the
2431    // canonical `${(flags)name}` form to `subst::paramsubst` (C port
2432    // of `Src/subst.c::paramsubst`). The bridge does no flag
2433    // walking, no DQ-context branching, no array/scalar shape
2434    // selection — all of that lives inside paramsubst. Compile-time
2435    // context (DQ / scalar-assign-RHS) flows through executor cells
2436    // (in_dq_context, in_scalar_assign) bumped by BUILTIN_EXPAND_TEXT.
2437    vm.register_builtin(BUILTIN_PARAM_FLAG, |vm, _argc| {
2438        let flags = vm.pop().to_str();
2439        let name = vm.pop().to_str();
2440        let body = format!("${{({}){}}}", flags, name);
2441        paramsubst_to_value(&body)
2442    });
2443
2444    // `foo[key]=val` — single-key set on an assoc array. Stack: [name, key, value].
2445    // PURE PASSTHRU: assignsparam with `name[key]` form (C port of
2446    // `Src/params.c::assignsparam` subscript path at c:3210-3231)
2447    // already does the indexed-array vs assoc decision, PM_HASHED
2448    // auto-vivification, numeric-subscript bounds handling, and
2449    // PM_READONLY rejection.
2450    vm.register_builtin(BUILTIN_SET_ASSOC, |vm, _argc| {
2451        let value = vm.pop().to_str();
2452        let key = vm.pop().to_str();
2453        let name = vm.pop().to_str();
2454        with_executor(|exec| {
2455            #[cfg(feature = "recorder")]
2456            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2457                let ctx = exec.recorder_ctx();
2458                let attrs = exec.recorder_attrs_for(&name);
2459                crate::recorder::emit_assoc_assign(
2460                    &name,
2461                    vec![(key.clone(), value.clone())],
2462                    attrs,
2463                    true,
2464                    ctx,
2465                );
2466            }
2467            let _ = exec;
2468        });
2469        // Build `name[key]=value` shape for assignsparam's subscript
2470        // dispatch. Arith-evaluate numeric subscripts on an existing
2471        // indexed array (`a[i+1]=v` form) before handing off — the
2472        // canonical port currently only handles literal int / string
2473        // keys, so pre-resolve here.
2474        let resolved_key = with_executor(|exec| {
2475            let is_indexed = exec.array(&name).is_some();
2476            let is_assoc = exec.assoc(&name).is_some();
2477            let is_scalar = !is_indexed && !is_assoc && exec.scalar(&name).is_some();
2478            // c:Src/params.c::getindex — `(i)pat` / `(I)pat` / `(R)pat`
2479            // / `(r)pat` subscript flags on an indexed array LHS resolve
2480            // to a numeric index (first / last match of pat). On a
2481            // SCALAR LHS the same flags resolve to a CHAR position
2482            // (1-based first/last match of pat in the scalar string)
2483            // for the c:2748+ char-splice assignment. zshrs's
2484            // read-form `${a[(i)pat]}` already implements both shapes;
2485            // the LHS assignment path silently stored the literal
2486            // "(i)pat" as an assoc key (for scalar: auto-vivified to
2487            // PM_HASHED via the assignsparam unknown-subscript
2488            // fallback). Bug #293 (array) / scalar sibling.
2489            //
2490            // Detect the `(flags)pat` shape and resolve to a numeric
2491            // index before assignsparam.
2492            if is_indexed || is_scalar {
2493                if let Some(rest) = key.strip_prefix('(') {
2494                    if let Some(close) = rest.find(')') {
2495                        let flags = &rest[..close];
2496                        let pat = &rest[close + 1..];
2497                        if !flags.is_empty()
2498                            && flags
2499                                .chars()
2500                                .all(|c| matches!(c, 'I' | 'R' | 'i' | 'r' | 'n' | 'e'))
2501                        {
2502                            // Resolve via the array's contents.
2503                            if let Some(arr) = exec.array(&name) {
2504                                let return_index = true; // LHS write — index needed
2505                                let down = flags.contains('I') || flags.contains('R');
2506                                let exact = flags.contains('e');
2507                                let iter: Box<dyn Iterator<Item = (usize, &String)>> = if down {
2508                                    Box::new(arr.iter().enumerate().rev())
2509                                } else {
2510                                    Box::new(arr.iter().enumerate())
2511                                };
2512                                let mut found: Option<usize> = None;
2513                                for (idx, elem) in iter {
2514                                    let matched = if exact {
2515                                        elem == pat
2516                                    } else {
2517                                        crate::ported::pattern::patcompile(
2518                                            pat,
2519                                            crate::ported::zsh_h::PAT_HEAPDUP as i32,
2520                                            None,
2521                                        )
2522                                        .map_or(false, |p| {
2523                                            crate::ported::pattern::pattry(&p, elem)
2524                                        })
2525                                    };
2526                                    if matched {
2527                                        found = Some(idx);
2528                                        break;
2529                                    }
2530                                }
2531                                let _ = return_index;
2532                                // (i)/(r) return 1-based index of match,
2533                                // arr.len()+1 (or 1 for I/R) on miss
2534                                // per zsh docs. We mirror the read-form
2535                                // semantics from subst.rs.
2536                                let idx_1based = match found {
2537                                    Some(i) => (i + 1) as i64,
2538                                    None => (arr.len() + 1) as i64,
2539                                };
2540                                return idx_1based.to_string();
2541                            }
2542                            // Scalar LHS — resolve to a CHAR position
2543                            // (1-based first/last match of pat in the
2544                            // string). c:Src/params.c:1411-1418 — the
2545                            // scalar path returns the char index from
2546                            // sliding-window pattern match against
2547                            // pm.u_str. Same algorithm as the read-form
2548                            // at subst.rs:5283-5306. Bug (scalar
2549                            // sibling of #293): `a=hello; a[(I)l]=X`
2550                            // previously auto-vivified `a` into
2551                            // PM_HASHED with key "(I)l" instead of
2552                            // splicing X at the last 'l' position
2553                            // (yielding "helXo").
2554                            if is_scalar {
2555                                let s = exec.scalar(&name).unwrap_or_default();
2556                                let s_chars: Vec<char> = s.chars().collect();
2557                                let n = s_chars.len();
2558                                let want_last = flags.contains('I') || flags.contains('R');
2559                                let exact = flags.contains('e');
2560                                let mut found: Option<usize> = None;
2561                                'outer: for start in 0..=n {
2562                                    let lengths: Box<dyn Iterator<Item = usize>> = if want_last {
2563                                        Box::new((1..=(n - start)).rev())
2564                                    } else {
2565                                        Box::new(1..=(n - start))
2566                                    };
2567                                    for len in lengths {
2568                                        let cand: String =
2569                                            s_chars[start..start + len].iter().collect();
2570                                        let matched = if exact {
2571                                            cand == pat
2572                                        } else {
2573                                            crate::ported::pattern::patcompile(
2574                                                pat,
2575                                                crate::ported::zsh_h::PAT_HEAPDUP as i32,
2576                                                None,
2577                                            )
2578                                            .map_or(false, |p| {
2579                                                crate::ported::pattern::pattry(&p, &cand)
2580                                            })
2581                                        };
2582                                        if matched {
2583                                            found = Some(start);
2584                                            if !want_last {
2585                                                break 'outer;
2586                                            }
2587                                            break;
2588                                        }
2589                                    }
2590                                }
2591                                // (I)/(R): scan again to find LAST.
2592                                if want_last {
2593                                    let mut last_found: Option<usize> = found;
2594                                    for start in (0..=n).rev() {
2595                                        for len in 1..=(n - start) {
2596                                            let cand: String =
2597                                                s_chars[start..start + len].iter().collect();
2598                                            let matched = if exact {
2599                                                cand == pat
2600                                            } else {
2601                                                crate::ported::pattern::patcompile(
2602                                                    pat,
2603                                                    crate::ported::zsh_h::PAT_HEAPDUP as i32,
2604                                                    None,
2605                                                )
2606                                                .map_or(false, |p| {
2607                                                    crate::ported::pattern::pattry(&p, &cand)
2608                                                })
2609                                            };
2610                                            if matched {
2611                                                last_found = Some(start);
2612                                                break;
2613                                            }
2614                                        }
2615                                        if last_found.is_some()
2616                                            && last_found.unwrap() >= start
2617                                        {
2618                                            break;
2619                                        }
2620                                    }
2621                                    found = last_found;
2622                                }
2623                                let idx_1based = match found {
2624                                    Some(i) => (i + 1) as i64,
2625                                    // (i) miss → len+1 (one past end).
2626                                    None => (n + 1) as i64,
2627                                };
2628                                return idx_1based.to_string();
2629                            }
2630                        }
2631                    }
2632                }
2633            }
2634            if is_indexed && key.trim().parse::<i64>().is_err() {
2635                crate::ported::math::mathevali(&crate::ported::subst::singsub(&key))
2636                    .map(|n| n.to_string())
2637                    .unwrap_or(key.clone())
2638            } else {
2639                key.clone()
2640            }
2641        });
2642        let subscripted = format!("{}[{}]", name, resolved_key);
2643        crate::ported::params::assignsparam(&subscripted, &value, crate::ported::zsh_h::ASSPM_WARN);
2644        Value::Status(0)
2645    });
2646
2647    // Brace expansion. Routes through executor.xpandbraces (already
2648    // implemented for the pre-fusevm executor). Returns Value::Array.
2649    // BUILTIN_ARRAY_DROP_EMPTY — filter out empty Value::Str entries
2650    // from a Value::Array on the stack. Used by `for x in $@` /
2651    // `for x in $*` unquoted forms which drop empty positionals
2652    // (POSIX-like) but do NOT IFS-split each element internally
2653    // (zsh-specific — scalar word splitting is off by default).
2654    // Distinct from BUILTIN_WORD_SPLIT which routes through
2655    // multsub PREFORK_SPLIT (full IFS-split). Bug #166.
2656    vm.register_builtin(BUILTIN_ARRAY_DROP_EMPTY, |vm, _argc| {
2657        let v = vm.pop();
2658        match v {
2659            Value::Array(items) => {
2660                let filtered: Vec<Value> = items
2661                    .into_iter()
2662                    .filter(|x| !x.to_str().is_empty())
2663                    .collect();
2664                Value::Array(filtered)
2665            }
2666            Value::Str(s) if s.is_empty() => Value::Array(Vec::new()),
2667            other => other,
2668        }
2669    });
2670
2671    // BUILTIN_QUOTEDZPUTS — re-wrap top-of-stack scalar via the
2672    // canonical quotedzputs (Src/utils.c:6464). Non-printable bytes
2673    // come back as `$'…'` C-string form so the cond xtrace prefix
2674    // line preserves the source-quoting form for `[[ -n $'\C-[OP' ]]`
2675    // instead of leaking raw ESC + "OP" bytes through the terminal.
2676    vm.register_builtin(BUILTIN_QUOTEDZPUTS, |vm, _argc| {
2677        let s = vm.pop().to_str();
2678        Value::str(crate::ported::utils::quotedzputs(&s))
2679    });
2680
2681    // BUILTIN_QUOTE_TOKENIZED_OUTPUT — char-aware mirror of
2682    // c:Src/exec.c:2114 `quote_tokenized_output`. The canonical
2683    // port at exec::quote_tokenized_output operates on bytes
2684    // (zsh's metafied encoding); zshrs strings are UTF-8 so
2685    // `\u{87}` Star is `[0xC2, 0x87]`, and a byte walk writes
2686    // 0xC2 raw (invalid UTF-8 lead → U+FFFD on lossy decode).
2687    // Walk by char and dispatch the same switch the byte port
2688    // uses, but with the token chars matching the UTF-8 form.
2689    vm.register_builtin(BUILTIN_QUOTE_TOKENIZED_OUTPUT, |vm, _argc| {
2690        let s = vm.pop().to_str();
2691        let mut out = String::with_capacity(s.len());
2692        let chars: Vec<char> = s.chars().collect();
2693        let mut i = 0;
2694        while i < chars.len() {
2695            let c = chars[i];
2696            // c:2120 — Meta-quoted byte: emit `*++s ^ 32`.
2697            // In UTF-8 strings Meta is `\u{83}`; the next char is
2698            // the metafied payload.
2699            if c == '\u{83}' {
2700                if let Some(&n) = chars.get(i + 1) {
2701                    if (n as u32) < 0x80 {
2702                        out.push(((n as u8) ^ 32) as char);
2703                    } else {
2704                        out.push(n);
2705                    }
2706                    i += 2;
2707                    continue;
2708                }
2709                i += 1;
2710                continue;
2711            }
2712            // c:2124 — Nularg: skip.
2713            if c == '\u{a1}' {
2714                i += 1;
2715                continue;
2716            }
2717            // c:2128-2143 — ASCII specials get backslash-prefixed
2718            // then fall through to emit the literal char.
2719            match c {
2720                '\\' | '<' | '>' | '(' | '|' | ')' | '^' | '#' | '~'
2721                | '[' | ']' | '*' | '?' | '$' | ' ' => {
2722                    out.push('\\');
2723                    out.push(c);
2724                    i += 1;
2725                    continue;
2726                }
2727                '\t' => {
2728                    out.push_str("$'\\t'");
2729                    i += 1;
2730                    continue;
2731                }
2732                '\n' => {
2733                    out.push_str("$'\\n'");
2734                    i += 1;
2735                    continue;
2736                }
2737                '\r' => {
2738                    out.push_str("$'\\r'");
2739                    i += 1;
2740                    continue;
2741                }
2742                '=' => {
2743                    if i == 0 {
2744                        out.push('\\');
2745                    }
2746                    out.push(c);
2747                    i += 1;
2748                    continue;
2749                }
2750                _ => {}
2751            }
2752            // c:2163 — `if (itok(*s)) putc(ztokens[*s - Pound]);`
2753            // Map zsh token chars (`\u{84}`..`\u{a1}` range, the
2754            // ones the lexer emits for `#$^*()…`) back to their
2755            // source ASCII via the `ztokens` table.
2756            let cp = c as u32;
2757            if (0x84..=0xa1).contains(&cp) {
2758                let idx = (cp - 0x84) as usize;
2759                let ztokens = crate::ported::lex::ztokens.as_bytes();
2760                if idx < ztokens.len() {
2761                    out.push(ztokens[idx] as char);
2762                    i += 1;
2763                    continue;
2764                }
2765            }
2766            out.push(c);
2767            i += 1;
2768        }
2769        Value::str(out)
2770    });
2771
2772    // BUILTIN_WORD_SPLIT — `${=var}` IFS-split runtime.
2773    // PURE PASSTHRU: route through canonical `subst::multsub` with
2774    // PREFORK_SPLIT flag (C port of `Src/subst.c::multsub` at c:544
2775    // — the IFS-split walker with whitespace-vs-non-whitespace
2776    // gating, quote-aware parsing, and empty-field handling).
2777    vm.register_builtin(BUILTIN_WORD_SPLIT, |vm, _argc| {
2778        let s = vm.pop().to_str();
2779        let (_joined, parts, _isarr, _flags) =
2780            crate::ported::subst::multsub(&s, crate::ported::zsh_h::PREFORK_SPLIT);
2781        // Empty single-string special case → empty Array (drop empty arg).
2782        if parts.len() == 1 && parts[0].is_empty() {
2783            return Value::Array(Vec::new());
2784        }
2785        nodes_to_value(parts)
2786    });
2787
2788    vm.register_builtin(BUILTIN_BRACE_EXPAND, |vm, _argc| {
2789        // c:Src/glob.c::xpandbraces — brace expansion runs per word.
2790        // When the upstream produced an array (e.g. `${a:e}` splat),
2791        // expand braces on each element separately so the splat
2792        // survives. `pop().to_str()` would join with space and lose
2793        // the array shape. Parity bug #28 cousin: the BRACE_EXPAND
2794        // emit always fires for any word containing `{` (including
2795        // `${...}` param-expansion braces), so its collapse hit even
2796        // pure-paramsubst args.
2797        let raw = vm.pop();
2798        // c:Src/options.c — `no_brace_expand` (negated braceexpand)
2799        // disables brace expansion entirely. When set, `{a,b}` stays
2800        // literal. Mirror by short-circuiting xpandbraces; pass the
2801        // input through unchanged.
2802        let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
2803        let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
2804        let inputs: Vec<String> = match raw {
2805            Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
2806            other => vec![other.to_str()],
2807        };
2808        if !brace_expand {
2809            return nodes_to_value(inputs);
2810        }
2811        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
2812        for s in inputs {
2813            for w in crate::ported::glob::xpandbraces(&s, brace_ccl) {
2814                out.push(w);
2815            }
2816        }
2817        nodes_to_value(out)
2818    });
2819
2820    // `*(qual)` glob qualifier filter. Stack: [pattern, qualifier].
2821    // Pattern is glob-expanded normally, then each result is filtered by the
2822    // qualifier predicate. Common qualifiers:
2823    //   .  — regular files only
2824    //   /  — directories only
2825    //   @  — symlinks
2826    //   x  — executable
2827    //   r/w/x — readable/writable/executable
2828    //   N  — nullglob (no error if no match)
2829    //   L+N / L-N — size > N / size < N (in bytes)
2830    //   mh-N / mh+N — modified within N hours / older than N hours
2831    //   md-N / md+N — modified within N days / older than N days
2832    //   on/On — sort by name asc/desc (default)
2833    //   oL/OL — sort by length
2834    //   om/Om — sort by mtime
2835    // Pop a scalar pattern, run expand_glob, push Value::Array. Used
2836    // by the segment-concat compile path for `$D/*`-style words.
2837    vm.register_builtin(BUILTIN_GLOB_EXPAND, |vm, _argc| {
2838        // c:Src/glob.c:1872 — `zglob` runs per-word in the argv
2839        // pipeline. When the upstream EXPAND_TEXT returned an array
2840        // (e.g. `${a:e}` splat → ["txt","md"]), we must glob each
2841        // element separately, not collapse to a sepjoin'd scalar.
2842        // Without this, `print -l ${a:e}` saw the array stringified
2843        // by `pop().to_str()` and emitted one joined arg.
2844        let raw = vm.pop();
2845        let patterns: Vec<String> = match raw {
2846            Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
2847            other => vec![other.to_str()],
2848        };
2849        // c:Src/glob.c:1872 — honour `setopt noglob` / `noglob CMD`
2850        // precommand. When the option is on, the word stays literal
2851        // (zsh skips the glob expansion entirely). Without this, the
2852        // segment-fast-path BUILTIN_GLOB_EXPAND fired even after
2853        // `noglob` set the option, so `noglob echo *.xyz` saw the
2854        // NOMATCH error instead of the literal pass-through.
2855        let noglob =
2856            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
2857        if noglob {
2858            return if patterns.is_empty() {
2859                Value::Array(Vec::new())
2860            } else if patterns.len() == 1 {
2861                Value::str(patterns.into_iter().next().unwrap())
2862            } else {
2863                Value::Array(patterns.into_iter().map(Value::str).collect())
2864            };
2865        }
2866        let mut out: Vec<String> = Vec::with_capacity(patterns.len());
2867        for pattern in &patterns {
2868            let matches = with_executor(|exec| exec.expand_glob(pattern));
2869            if matches.is_empty() {
2870                // c:1872 nullglob — drop this word, don't emit a hole
2871                continue;
2872            }
2873            for m in matches {
2874                out.push(m);
2875            }
2876        }
2877        if out.is_empty() {
2878            return Value::Array(Vec::new());
2879        }
2880        if patterns.len() == 1 && out.len() == 1 && out[0] == patterns[0] {
2881            // No real matches; expand_glob returned the literal. Pass
2882            // back as scalar so downstream ops don't re-flatten.
2883            return Value::str(out.into_iter().next().unwrap());
2884        }
2885        Value::Array(out.into_iter().map(Value::str).collect())
2886    });
2887
2888    // `break`/`continue` from a sub-VM body. The compile path emits
2889    // these when the keyword appears at chunk top-level (no enclosing
2890    // for/while in the current chunk's patch lists). Outer-loop
2891    // builtins (BUILTIN_RUN_SELECT and any future loop-via-builtin
2892    // construct) drain canonical BREAKS/CONTFLAG after each iteration.
2893    //
2894    // Writes match `bin_break`'s c:5836+ pattern:
2895    //   continue: contflag = 1; breaks++   (Src/builtin.c::bin_break)
2896    //   break:    breaks++
2897    vm.register_builtin(BUILTIN_SET_BREAK, |_vm, _argc| {
2898        use std::sync::atomic::Ordering::SeqCst;
2899        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
2900        Value::Status(0)
2901    });
2902    vm.register_builtin(BUILTIN_SET_CONTINUE, |_vm, _argc| {
2903        use std::sync::atomic::Ordering::SeqCst;
2904        crate::ported::builtin::CONTFLAG.store(1, SeqCst);
2905        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
2906        Value::Status(0)
2907    });
2908
2909    // `${arr[*]}` — join array elements with the first IFS char into
2910    // a single string. Matches zsh: in DQ context this preserves the
2911    // join; in array context too the result is one Value::Str.
2912    // Set or clear a shell option directly. Used by `noglob CMD ...`
2913    // precommand wrapping — the compiler emits SET_RAW_OPT to flip the
2914    // option ON before compiling the inner words and OFF after, so glob
2915    // expansion of the inner args sees the temporary state.
2916    vm.register_builtin(BUILTIN_SET_RAW_OPT, |vm, _argc| {
2917        let on = vm.pop().to_int() != 0;
2918        let opt = vm.pop().to_str();
2919        // Pure passthru: canonical port lives in
2920        // src/ported/options.rs::opt_state_set_via_alias and
2921        // handles negation-alias resolution per c:Src/options.c.
2922        crate::ported::options::opt_state_set_via_alias(&opt, on);
2923        Value::Status(0)
2924    });
2925
2926    // c:Src/options.c GLOB_SUBST — runtime glob expansion of
2927    // substituted words. Pop a Value (Str or Array); when
2928    // GLOB_SUBST is ON, run expand_glob on each string element;
2929    // when OFF, pass through unchanged. Bug #119 in docs/BUGS.md.
2930    vm.register_builtin(BUILTIN_GLOB_SUBST_EXPAND, |vm, _argc| {
2931        let raw = vm.pop();
2932        let glob_subst =
2933            crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
2934        if !glob_subst {
2935            return raw;
2936        }
2937        // Collect input strings (Str → vec![s]; Array → multiple).
2938        let inputs: Vec<String> = match raw {
2939            Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
2940            other => vec![other.to_str()],
2941        };
2942        // Run expand_glob on each. Empty matches collapse to a
2943        // single literal pass-through to mirror nullglob-off default.
2944        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
2945        for pattern in inputs {
2946            let matches = with_executor(|exec| exec.expand_glob(&pattern));
2947            if matches.is_empty() {
2948                // No match: keep the literal (like nullglob off).
2949                out.push(pattern);
2950            } else {
2951                for m in matches {
2952                    out.push(m);
2953                }
2954            }
2955        }
2956        if out.len() == 1 {
2957            Value::str(out.into_iter().next().unwrap())
2958        } else {
2959            Value::Array(out.into_iter().map(Value::str).collect())
2960        }
2961    });
2962
2963    // c:Src/math.c:337 — `getmathparam` for ArithCompiler pre-load.
2964    // Pop a variable name, return its math-coerced value. Mirrors
2965    // the routing in math::getmathparam: try i64, then f64, then
2966    // recursive arith-eval, else 0. Bug #118 in docs/BUGS.md.
2967    vm.register_builtin(BUILTIN_GET_MATH_VAR, |vm, _argc| {
2968        let name = vm.pop().to_str();
2969        let raw = crate::ported::params::getsparam(&name).unwrap_or_default();
2970        // Empty / unset → 0.
2971        if raw.is_empty() {
2972            return Value::Int(0);
2973        }
2974        // Direct int / float parse.
2975        if let Ok(n) = raw.parse::<i64>() {
2976            return Value::Int(n);
2977        }
2978        if let Ok(f) = raw.parse::<f64>() {
2979            return Value::Float(f);
2980        }
2981        // Recursive arith eval (matches getmathparam fallback at
2982        // Src/math.c:337). If that fails too, return 0 — C's
2983        // mathevall returns 0 with errflag set on parse failure.
2984        match crate::ported::math::mathevali(&raw) {
2985            Ok(n) => Value::Int(n),
2986            Err(_) => Value::Int(0),
2987        }
2988    });
2989
2990    // c:Src/options.c GLOB_SUBST + Src/cond.c:552 cond_match.
2991    // Pop pattern string; when GLOB_SUBST is OFF, escape every glob
2992    // metachar with `\` so the downstream StrMatch + patcompile
2993    // treat them as literals (matching C's tokenization-based
2994    // gate). When GLOB_SUBST is ON, pass through unchanged.
2995    // See BUILTIN_GLOB_SUBST_GUARD docs above for full rationale.
2996    vm.register_builtin(BUILTIN_GLOB_SUBST_GUARD, |vm, _argc| {
2997        let p = vm.pop().to_str();
2998        let glob_subst =
2999            crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
3000        if glob_subst {
3001            return Value::str(p);
3002        }
3003        let mut out = String::with_capacity(p.len() * 2);
3004        for c in p.chars() {
3005            match c {
3006                '*' | '?' | '[' | ']' | '(' | ')' | '|' | '<' | '>' | '#' | '^'
3007                | '~' | '\\' => {
3008                    out.push('\\');
3009                    out.push(c);
3010                }
3011                _ => out.push(c),
3012            }
3013        }
3014        Value::str(out)
3015    });
3016
3017    vm.register_builtin(BUILTIN_ARRAY_JOIN_STAR, |vm, _argc| {
3018        let name = vm.pop().to_str();
3019        let (joined, ifs_full, in_dq) = with_executor(|exec| {
3020            // c:Src/params.c — `"$*"` joins by IFS[0]. zsh
3021            // distinguishes IFS=unset (→ default `" "`) from
3022            // IFS="" (→ EMPTY separator → fields concatenate).
3023            // chars().next() collapsed both into the default, so
3024            // IFS="" was treated as IFS=" ".
3025            let ifs_full = exec
3026                .scalar("IFS")
3027                .unwrap_or_else(|| " \t\n".to_string());
3028            let sep = ifs_full
3029                .chars()
3030                .next()
3031                .map(|c| c.to_string())
3032                .unwrap_or_default();
3033            let in_dq = exec.in_dq_context > 0;
3034            let joined = if name == "@" || name == "*" || name == "argv" {
3035                exec.pparams().join(&sep)
3036            } else if let Some(assoc_map) = exec.assoc(&name) {
3037                // c:Src/params.c — assoc-splat values for
3038                // `"${h[@]}"` / `"${h[*]}"`. Bug #109 in
3039                // docs/BUGS.md.
3040                assoc_map.values().cloned().collect::<Vec<_>>().join(&sep)
3041            } else if let Some(arr) = exec.array(&name) {
3042                arr.join(&sep)
3043            } else {
3044                exec.get_variable(&name)
3045            };
3046            (joined, ifs_full, in_dq)
3047        });
3048        // c:Src/subst.c — UNQUOTED `${name[*]}` (or `$*`) goes
3049        // through the canonical "join via IFS[0], then word-split
3050        // via IFS" pipeline. The fast-path bypassed paramsubst
3051        // entirely so it never word-split, producing one joined
3052        // string instead of N argv entries. Bug #428.
3053        //
3054        // In QUOTED (`"${name[*]}"`) context, the result IS a
3055        // single scalar — return it as Str without splitting.
3056        if in_dq {
3057            return Value::str(joined);
3058        }
3059        if joined.is_empty() {
3060            return Value::Array(Vec::new());
3061        }
3062        // IFS word-split — every IFS char is a separator. Empty
3063        // resulting fields are dropped (the canonical
3064        // "remove empty unquoted words" pass from
3065        // Src/subst.c::prefork c:184-187).
3066        let parts: Vec<String> = joined
3067            .split(|c: char| ifs_full.contains(c))
3068            .filter(|s| !s.is_empty())
3069            .map(String::from)
3070            .collect();
3071        if parts.is_empty() {
3072            Value::Array(Vec::new())
3073        } else if parts.len() == 1 {
3074            Value::str(parts.into_iter().next().unwrap())
3075        } else {
3076            Value::Array(parts.into_iter().map(Value::str).collect())
3077        }
3078    });
3079
3080    vm.register_builtin(BUILTIN_ARRAY_ALL, |vm, _argc| {
3081        let name = vm.pop().to_str();
3082        with_executor(|exec| {
3083            // Special positional names — splice the positional list.
3084            if name == "@" || name == "*" || name == "argv" {
3085                return Value::Array(exec.pparams().iter().map(Value::str).collect());
3086            }
3087            // c:Src/Modules/parameter.c — funcstack/funcfiletrace/
3088            // funcsourcetrace/functrace are PM_ARRAY|PM_READONLY
3089            // specials backed by the canonical FUNCSTACK Vec.
3090            // `${funcstack[@]}` inside a function call should splat
3091            // the innermost-first names; without this branch the
3092            // runtime fell to the scalar fallback (get_variable
3093            // returns empty for these specials) and `[@]` came out
3094            // empty. Bug #276 in docs/BUGS.md. Mirrors the parallel
3095            // arrays_get handler at src/ported/subst.rs ~10685.
3096            // c:Src/Modules/datetime.c:256 — `epochtime` PM_ARRAY|
3097            // PM_READONLY backed by getcurrenttime(). Same parallel
3098            // arrangement as the FUNCSTACK-backed specials below.
3099            if name == "epochtime" {
3100                let arr = crate::ported::modules::datetime::getcurrenttime();
3101                return Value::Array(arr.into_iter().map(Value::str).collect());
3102            }
3103            if matches!(
3104                name.as_str(),
3105                "funcstack" | "funcfiletrace" | "funcsourcetrace" | "functrace"
3106            ) {
3107                if let Ok(f) = crate::ported::modules::parameter::FUNCSTACK.lock() {
3108                    let vals: Vec<Value> = f
3109                        .iter()
3110                        .rev()
3111                        .map(|fs| {
3112                            let s = match name.as_str() {
3113                                "funcstack" => fs.name.clone(),
3114                                "funcfiletrace" => fs.filename.clone().unwrap_or_default(),
3115                                // funcsourcetrace / functrace
3116                                _ => format!("{}:{}", fs.name, fs.lineno),
3117                            };
3118                            Value::str(s)
3119                        })
3120                        .collect();
3121                    return Value::Array(vals);
3122                }
3123            }
3124            // c:Src/params.c — `${assoc[@]}` enumerates VALUES (per
3125            // params.c:1696-1750 hashparam splat). Check assoc
3126            // storage BEFORE the scalar fallback so an associative
3127            // array named X resolves `${X[@]}` to the values, not
3128            // empty. Bug #109 in docs/BUGS.md: `${h[@]}` on an
3129            // assoc routed through BUILTIN_ARRAY_ALL, which only
3130            // consulted `exec.array(name)` (the indexed-array map)
3131            // — that lookup missed for assocs, fell through to
3132            // `get_variable("h")` (also empty for an assoc-only
3133            // name), and returned `Array(vec![])`. zsh's expected
3134            // behavior is to enumerate values.
3135            if let Some(assoc_map) = exec.assoc(&name) {
3136                return Value::Array(
3137                    assoc_map.values().cloned().map(Value::str).collect(),
3138                );
3139            }
3140            match exec.array(&name) {
3141                Some(v) => Value::Array(v.iter().map(Value::str).collect()),
3142                None => {
3143                    // Fall back to scalar lookup. zsh (unlike bash)
3144                    // does NOT IFS-split a scalar variable in a for
3145                    // list — `for w in $scalar` iterates ONCE with the
3146                    // scalar value. Word-splitting requires either
3147                    // sh_word_split option or explicit `${(s.,.)scalar}`.
3148                    let val = exec.get_variable(&name);
3149                    if val.is_empty() && !exec.has_scalar(&name) && env::var(&name).is_err() {
3150                        Value::Array(vec![])
3151                    } else if opt_state_get("shwordsplit").unwrap_or(false) {
3152                        // bash-compat: under setopt sh_word_split, do
3153                        // split scalars on IFS chars.
3154                        let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
3155                        let parts: Vec<Value> = val
3156                            .split(|c: char| ifs.contains(c))
3157                            .filter(|s| !s.is_empty())
3158                            .map(Value::str)
3159                            .collect();
3160                        Value::Array(parts)
3161                    } else {
3162                        Value::Array(vec![Value::str(val)])
3163                    }
3164                }
3165            }
3166        })
3167    });
3168
3169    // BUILTIN_ARRAY_FLATTEN(N): pops N values, flattens one level of Array
3170    // nesting, pushes the resulting Array AND its length as a separate Int.
3171    // The two-value return shape lets the caller (for-loop compile path)
3172    // SetSlot the length before SetSlot'ing the array, without re-deriving
3173    // the length from the array via a second builtin call.
3174    // `coproc [name] { body }` — bidirectional pipe to backgrounded body.
3175    // Stack discipline (top first): [name (str, "" for default), sub_idx (int)].
3176    // On success: parent's `executor.arrays[name]` becomes [write_fd, read_fd]
3177    // and Status(0) is returned. The caller writes to the child's stdin via
3178    // write_fd, reads its stdout via read_fd, and closes both when done.
3179    //
3180    // Bash's coproc convention is `${NAME[0]}` = read_fd, `${NAME[1]}` =
3181    // write_fd. We follow that: arrays[name] = [read_fd_str, write_fd_str].
3182    vm.register_builtin(BUILTIN_RUN_COPROC, |vm, _argc| {
3183        let sub_idx = vm.pop().to_int() as usize;
3184        let raw_name = vm.pop().to_str();
3185        let name = if raw_name.is_empty() {
3186            "COPROC".to_string()
3187        } else {
3188            raw_name
3189        };
3190        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
3191            Some(c) => c,
3192            None => return Value::Status(1),
3193        };
3194
3195        // (parent_read ← child_stdout)
3196        let mut p2c = [0i32; 2]; // parent writes, child reads
3197        let mut c2p = [0i32; 2]; // child writes, parent reads
3198        if unsafe { libc::pipe(p2c.as_mut_ptr()) } < 0 {
3199            return Value::Status(1);
3200        }
3201        if unsafe { libc::pipe(c2p.as_mut_ptr()) } < 0 {
3202            unsafe {
3203                libc::close(p2c[0]);
3204                libc::close(p2c[1]);
3205            }
3206            return Value::Status(1);
3207        }
3208
3209        match unsafe { libc::fork() } {
3210            -1 => {
3211                unsafe {
3212                    libc::close(p2c[0]);
3213                    libc::close(p2c[1]);
3214                    libc::close(c2p[0]);
3215                    libc::close(c2p[1]);
3216                }
3217                Value::Status(1)
3218            }
3219            0 => {
3220                // Child: stdin from p2c[0], stdout to c2p[1]. Close all
3221                // unused fds. setsid so SIGINT to fg doesn't hit us.
3222                unsafe {
3223                    libc::dup2(p2c[0], libc::STDIN_FILENO);
3224                    libc::dup2(c2p[1], libc::STDOUT_FILENO);
3225                    libc::close(p2c[0]);
3226                    libc::close(p2c[1]);
3227                    libc::close(c2p[0]);
3228                    libc::close(c2p[1]);
3229                    libc::setsid();
3230                }
3231                crate::fusevm_disasm::maybe_print_stdout("coproc:child", &chunk);
3232                let mut co_vm = fusevm::VM::new(chunk);
3233                register_builtins(&mut co_vm);
3234                let _ = co_vm.run();
3235                let _ = std::io::stdout().flush();
3236                let _ = std::io::stderr().flush();
3237                std::process::exit(co_vm.last_status);
3238            }
3239            pid => {
3240                // Parent: close child ends, store [read_fd, write_fd] in NAME.
3241                unsafe {
3242                    libc::close(p2c[0]);
3243                    libc::close(c2p[1]);
3244                }
3245                let read_fd = c2p[0];
3246                let write_fd = p2c[1];
3247                with_executor(|exec| {
3248                    exec.unset_scalar(&name);
3249                    exec.set_array(name, vec![read_fd.to_string(), write_fd.to_string()]);
3250                });
3251                // c:Src/exec.c — `coprocin`/`coprocout` are the
3252                // canonical globals that bin_read's `-p` arm
3253                // (Src/builtin.c:6510) and bin_print's `-p` arm
3254                // (Src/builtin.c:4827) read to find the
3255                // coprocess fds. The Rust port has the atomic
3256                // declarations at src/ported/modules/clone.rs:262
3257                // but the coproc-launch path never updated them,
3258                // so `read -p` / `print -p` always errored with
3259                // "-p: no coprocess" even when a coproc was
3260                // running. Bug #388 in docs/BUGS.md. Update them
3261                // here so the canonical builtins find the live
3262                // pipe.
3263                crate::ported::modules::clone::coprocin
3264                    .store(read_fd, std::sync::atomic::Ordering::Relaxed);
3265                crate::ported::modules::clone::coprocout
3266                    .store(write_fd, std::sync::atomic::Ordering::Relaxed);
3267                // c:Src/exec.c:2837 — `lastpid = (zlong) pid;`. zsh
3268                // sets the `$!` global to the coproc child's PID so
3269                // subsequent `$!` reads return it. The Rust port at
3270                // exec.rs:6773 mirrors this for regular background
3271                // jobs but the coproc launch path was missing the
3272                // assignment, leaving `$!` at 0 after `coproc cmd`.
3273                crate::ported::modules::clone::lastpid
3274                    .store(pid, std::sync::atomic::Ordering::Relaxed);
3275                Value::Status(0)
3276            }
3277        }
3278    });
3279
3280    vm.register_builtin(BUILTIN_ARRAY_FLATTEN, |vm, argc| {
3281        let n = argc as usize;
3282        let start = vm.stack.len().saturating_sub(n);
3283        let raw: Vec<Value> = vm.stack.drain(start..).collect();
3284        let mut flat: Vec<Value> = Vec::with_capacity(raw.len());
3285        for v in raw {
3286            match v {
3287                Value::Array(items) => flat.extend(items),
3288                other => flat.push(other),
3289            }
3290        }
3291        let len = flat.len() as i64;
3292        // Push the array first; the Int(len) becomes the builtin's return
3293        // value (which CallBuiltin already pushes). Caller consumes in
3294        // reverse: SetSlot(len_slot) pops Int, SetSlot(arr_slot) pops Array.
3295        vm.push(Value::Array(flat));
3296        Value::Int(len)
3297    });
3298
3299    // Shell variable get/set — routes through executor.variables so nested
3300    // VMs (function calls) and tree-walker callers see the same storage.
3301    vm.register_builtin(BUILTIN_GET_VAR, |vm, argc| {
3302        let args = pop_args(vm, argc);
3303        let name = args.into_iter().next().unwrap_or_default();
3304        let live_status = vm.last_status;
3305        // Suppress sync when a deferred subshell exit just landed:
3306        // LASTVAL holds the correct deferred status, vm.last_status
3307        // is stale (post-subshell vm doesn't propagate status). See
3308        // SUBSHELL_EXIT_STATUS_PENDING TLS declaration for rationale.
3309        let suppress_sync = SUBSHELL_EXIT_STATUS_PENDING.with(|c| {
3310            let prev = c.get();
3311            c.set(false);
3312            prev
3313        });
3314        // `$@` and `$*` need splice semantics — return Value::Array of
3315        // positional params so for-loop's BUILTIN_ARRAY_FLATTEN spreads them
3316        // and pop_args splits them into argv slots. zsh's `"$@"` bslashquote-each-
3317        // word semantics matches: each pos-param becomes its own arg.
3318        // Same for arrays accessed by name (e.g. `$arr` in some contexts).
3319        let sync_status = |exec: &mut ShellExecutor| {
3320            if !suppress_sync {
3321                exec.set_last_status(live_status);
3322            }
3323        };
3324        if name == "@" || name == "*" {
3325            return with_executor(|exec| {
3326                sync_status(exec);
3327                Value::Array(exec.pparams().iter().map(Value::str).collect())
3328            });
3329        }
3330        // RC_EXPAND_PARAM: when the option is set and `name` refers to
3331        // an array, return Value::Array so the enclosing word's
3332        // BUILTIN_CONCAT_DISTRIBUTE distributes element-wise. Without
3333        // the option, arrays still join to a space-separated scalar
3334        // (zsh's default unquoted-array-as-scalar semantics).
3335        let rc_expand = with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
3336        if rc_expand {
3337            let arr_val = with_executor(|exec| {
3338                sync_status(exec);
3339                exec.array(&name)
3340            });
3341            if let Some(arr) = arr_val {
3342                return Value::Array(arr.into_iter().map(Value::str).collect());
3343            }
3344        }
3345        // Magic-assoc fallback FIRST — `${aliases}` / `${functions}`
3346        // / `${commands}` / etc. should return the value list per
3347        // zsh's bare-assoc semantics. Without this, those names fell
3348        // through to `get_variable` which is empty (they live in
3349        // separate executor tables, not `assoc_arrays`). Return as
3350        // a Value::Array so `arr=(${aliases})` distributes into
3351        // multiple elements, matching zsh's array-context word
3352        // splitting for assoc-bare references.
3353        let magic_vals = with_executor(|exec| {
3354            sync_status(exec);
3355            // Canonical PARTAB dispatch (Src/Modules/parameter.c:2235-
3356            // 2298 + SPECIALPMDEFs in mapfile/terminfo/termcap/system/
3357            // zleparameter): PARTAB_ARRAY entries → whole-array getfn;
3358            // PARTAB entries → scan keys + per-key getpm/scanpm fn
3359            // pointers.
3360            let _ = exec;
3361            if let Some(values) = partab_array_get(&name) {
3362                Some(values)
3363            } else if let Some(keys) = partab_scan_keys(&name) {
3364                Some(
3365                    keys.iter()
3366                        .map(|k| partab_get(&name, k).unwrap_or_default())
3367                        .collect::<Vec<_>>(),
3368                )
3369            } else {
3370                None
3371            }
3372        });
3373        if let Some(vals) = magic_vals {
3374            // Distinguish "name IS a magic-assoc with no entries"
3375            // (return Array(empty)) from "name is unknown — fall
3376            // through to get_variable".
3377            return Value::Array(vals.into_iter().map(Value::str).collect());
3378        }
3379        // Indexed-array path: return Value::Array so pop_args splats
3380        // each element into its own argv slot. Direct port of zsh's
3381        // unquoted `$arr` semantics — each element becomes a separate
3382        // word in command-arg position.
3383        //
3384        // DQ context exception: inside `"...$arr..."`, zsh joins with
3385        // the first char of $IFS (default space) so the DQ word stays
3386        // a single argv slot. Detect via in_dq_context (bumped by
3387        // BUILTIN_EXPAND_TEXT mode 1) and return the joined scalar.
3388        // Direct port of Src/subst.c:1759-1813 nojoin/sepjoin: in DQ
3389        // (qt=1) without explicit `(@)`, sepjoin runs and the result
3390        // is one word.
3391        let arr_assoc_data = with_executor(|exec| {
3392            sync_status(exec);
3393            let in_dq = exec.in_dq_context > 0;
3394            // KSH_ARRAYS: bare `$arr` returns ONLY arr[0] (zero-
3395            // based first-element-only semantics). Direct port of
3396            // Src/params.c getstrvalue's KSH_ARRAYS gate which
3397            // returns aval[0] instead of the whole array.
3398            let ksh_arrays = opt_state_get("ksharrays").unwrap_or(false);
3399            if let Some(arr) = exec.array(&name) {
3400                if ksh_arrays {
3401                    return Some((vec![arr.first().cloned().unwrap_or_default()], in_dq));
3402                }
3403                return Some((arr.clone(), in_dq));
3404            }
3405            if let Some(map) = exec.assoc(&name) {
3406                let mut keys: Vec<&String> = map.keys().collect();
3407                keys.sort();
3408                let values: Vec<String> =
3409                    keys.iter().filter_map(|k| map.get(*k).cloned()).collect();
3410                if ksh_arrays {
3411                    return Some((vec![values.into_iter().next().unwrap_or_default()], in_dq));
3412                }
3413                return Some((values, in_dq));
3414            }
3415            None
3416        });
3417        if let Some((items, in_dq)) = arr_assoc_data {
3418            if in_dq {
3419                let sep = with_executor(|exec| {
3420                    exec.get_variable("IFS")
3421                        .chars()
3422                        .next()
3423                        .map(|c| c.to_string())
3424                        .unwrap_or_else(|| " ".to_string())
3425                });
3426                return Value::str(items.join(&sep));
3427            }
3428            return Value::Array(items.into_iter().map(Value::str).collect());
3429        }
3430        let (val, in_dq, is_known) = with_executor(|exec| {
3431            sync_status(exec);
3432            let v = exec.get_variable(&name);
3433            // For nounset detection: a name is "known" when it has a
3434            // paramtab/array/assoc/env entry. Special chars ($?, $#,
3435            // $@, $*, $-, $$, $!, $_, $0) always count as known
3436            // regardless of value. Pure-digit positional params
3437            // count as known iff index <= $# (set -- has populated
3438            // that slot). c:Src/subst.c:1689 — NOUNSET fires on
3439            // unset positional param too: `set --; echo "$1"` with
3440            // nounset must diagnose.
3441            let is_special_single = name.len() == 1
3442                && matches!(
3443                    name.chars().next().unwrap(),
3444                    '?' | '#' | '@' | '*' | '-' | '$' | '!' | '_' | '0'
3445                );
3446            let is_pure_digit = !name.is_empty() && name.chars().all(|c| c.is_ascii_digit());
3447            let positional_known = if is_pure_digit {
3448                let idx: usize = name.parse().unwrap_or(0);
3449                if idx == 0 {
3450                    true // $0 always set
3451                } else {
3452                    idx <= exec.pparams().len()
3453                }
3454            } else {
3455                false
3456            };
3457            let known = !v.is_empty()
3458                || name.is_empty()
3459                || is_special_single
3460                || positional_known
3461                || crate::ported::params::paramtab()
3462                    .read()
3463                    .ok()
3464                    .map(|t| t.contains_key(&name))
3465                    .unwrap_or(false)
3466                || env::var(&name).is_ok();
3467            (v, exec.in_dq_context > 0, known)
3468        });
3469        // c:Src/subst.c:1689 — NO_UNSET / nounset: reading an unset
3470        // parameter fires "parameter not set" diagnostic and aborts
3471        // the substitution. Direct port of the noerrs gate at c:1689
3472        // (zerr + errflag). Matches `set -u` POSIX semantics.
3473        if !is_known && opt_state_get("nounset").unwrap_or(false) {
3474            crate::ported::utils::zerr(&format!("{}: parameter not set", name));
3475            crate::ported::utils::errflag.fetch_or(
3476                crate::ported::zsh_h::ERRFLAG_ERROR,
3477                std::sync::atomic::Ordering::Relaxed,
3478            );
3479            with_executor(|exec| exec.set_last_status(1));
3480            return Value::str("");
3481        }
3482        // Empty unquoted scalar → drop the arg (zsh "remove empty
3483        // unquoted words" rule). Returning empty Value::Array makes
3484        // pop_args contribute zero items. DQ context keeps the empty
3485        // string so "$a" stays a single empty arg. Direct port of
3486        // subst.c's elide-empty pass.
3487        if val.is_empty() && !in_dq {
3488            return Value::Array(Vec::new());
3489        }
3490        // c:Src/subst.c:1759 SH_WORD_SPLIT — when shwordsplit is set and
3491        // we're in unquoted command-arg position (not DQ), split scalar
3492        // value on IFS into multiple words. Matches BUILTIN_ARRAY_ALL's
3493        // shwordsplit arm (fusevm_bridge.rs:2200). Without this, bare
3494        // `$s` in `print $s` stayed a single arg even with the option
3495        // set, breaking POSIX-style scalar word-splitting.
3496        if !in_dq && opt_state_get("shwordsplit").unwrap_or(false) {
3497            let ifs =
3498                with_executor(|exec| exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string()));
3499            let parts: Vec<Value> = val
3500                .split(|c: char| ifs.contains(c))
3501                .filter(|s| !s.is_empty())
3502                .map(|s| Value::str(s.to_string()))
3503                .collect();
3504            if parts.is_empty() {
3505                return Value::Array(Vec::new());
3506            } else if parts.len() == 1 {
3507                return parts.into_iter().next().unwrap();
3508            } else {
3509                return Value::Array(parts);
3510            }
3511        }
3512        Value::str(val)
3513    });
3514
3515    // `name+=val` (no parens) — runtime dispatch:
3516    //   - if `name` is in `arrays` → push `val` as new element
3517    //   - if `name` is in `assoc_arrays` → refuse (zsh errors here)
3518    //   - else → scalar concat (existing behavior)
3519    // Stack: [name, value].
3520    vm.register_builtin(BUILTIN_APPEND_SCALAR_OR_PUSH, |vm, argc| {
3521        let args = pop_args(vm, argc);
3522        let mut iter = args.into_iter();
3523        let name = iter.next().unwrap_or_default();
3524        let value = iter.next().unwrap_or_default();
3525        with_executor(|exec| {
3526            // Array form: `arr+=elem` pushes a single element.
3527            // Routes through canonical assignaparam(name, [value],
3528            // ASSPM_AUGMENT) — Src/params.c:3357 c:3402-3412 augment
3529            // path prepends prior scalar / appends to existing array.
3530            if exec.array(&name).is_some() {
3531                let _ = crate::ported::params::assignaparam(
3532                    &name,
3533                    vec![value.clone()],
3534                    crate::ported::zsh_h::ASSPM_AUGMENT,
3535                );
3536                #[cfg(feature = "recorder")]
3537                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3538                    let ctx = exec.recorder_ctx();
3539                    let attrs = exec.recorder_attrs_for(&name);
3540                    emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
3541                }
3542                return;
3543            }
3544            if exec.assoc(&name).is_some() {
3545                eprintln!("zshrs: {}: cannot use += on assoc without (key val)", name);
3546                return;
3547            }
3548            // Scalar / integer / float form: route through canonical
3549            // assignsparam(name, value, ASSPM_AUGMENT) which
3550            // dispatches PM_TYPE — PM_SCALAR concats, PM_INTEGER
3551            // arith-adds (c:2775-2778), PM_FLOAT float-adds.
3552            let _ = crate::ported::params::assignsparam(
3553                &name,
3554                &value,
3555                crate::ported::zsh_h::ASSPM_AUGMENT,
3556            );
3557            #[cfg(feature = "recorder")]
3558            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3559                let ctx = exec.recorder_ctx();
3560                let attrs = exec.recorder_attrs_for(&name);
3561                // Re-read the canonical value via get_variable for the
3562                // recorder bundle (assignsparam may have transformed it
3563                // through integer/float arithmetic).
3564                let final_val = exec.get_variable(&name);
3565                let lower = name.to_ascii_lowercase();
3566                if matches!(
3567                    lower.as_str(),
3568                    "path" | "fpath" | "manpath" | "module_path" | "cdpath"
3569                ) {
3570                    emit_path_or_assign(&name, std::slice::from_ref(&final_val), attrs, true, &ctx);
3571                } else {
3572                    crate::recorder::emit_assign_typed(&name, &final_val, attrs, ctx);
3573                }
3574            }
3575        });
3576        Value::Status(0)
3577    });
3578
3579    // BUILTIN_SET_VAR — `name=value` runtime scalar assignment.
3580    // PURE PASSTHRU: hand to canonical `setsparam` (C port of
3581    // `Src/params.c::setsparam`). That walks assignsparam →
3582    // assignstrvalue which already does:
3583    //   - readonly rejection (zerr + errflag at c:2701)
3584    //   - PM_INTEGER math evaluation (mathevali at c:3590)
3585    //   - PM_EFLOAT / PM_FFLOAT float coercion (c:3608)
3586    //   - PM_LOWER / PM_UPPER case fold (via setstrvalue)
3587    //   - GSU special-param dispatch (homesetfn / ifssetfn / etc.)
3588    //   - allexport env mirror via the PM_EXPORTED setfn
3589    //
3590    // Bridge-only concerns kept here:
3591    //   - inline_env_stack (zsh `X=foo cmd` scoped env)
3592    //   - recorder emission (PFA-SMR)
3593    //   - vm.last_status propagation for `a=$(cmd)` exit-code chaining
3594    vm.register_builtin(BUILTIN_SET_VAR, |vm, argc| {
3595        // Snapshot the raw Values BEFORE pop_args's to_str
3596        // flattening — needed to distinguish Int (arith assignment,
3597        // integer-typed param) from Str (scalar assignment).
3598        let mut raw_values: Vec<fusevm::Value> = Vec::with_capacity(argc as usize);
3599        for _ in 0..argc {
3600            raw_values.push(vm.pop());
3601        }
3602        raw_values.reverse();
3603        let name = raw_values.first().map(|v| v.to_str()).unwrap_or_default();
3604        let value_raw = raw_values.get(1).cloned();
3605        let value = value_raw.as_ref().map(|v| v.to_str()).unwrap_or_default();
3606        // c:Src/params.c — when the bytecode hands us an Int value
3607        // (only the arith assignment paths emit this — `(( X = N ))`
3608        // is the canonical site), route through setiparam so the
3609        // param ends up PM_INTEGER + inherits the math layer's
3610        // `lastbase` for display formatting (`(( X = 16#ff ));
3611        // echo \$X` → `16#FF`). Scalar `X=val` and `$((expr))`
3612        // assignments still take the setsparam path below.
3613        let int_assign = matches!(value_raw, Some(fusevm::Value::Int(_)));
3614        let float_assign = matches!(value_raw, Some(fusevm::Value::Float(_)));
3615        with_executor(|exec| {
3616            // c:Src/params.c assignsparam — PM_READONLY rejection
3617            // BEFORE any env mutation. The inline-env-prefix path
3618            // (`X=2 env`) called env::set_var unconditionally before
3619            // the readonly check fired in setsparam, so the OS env
3620            // got X=2 even though the assignment errored. env then
3621            // inherited the polluted env from fork, leaking the
3622            // attempted override past the readonly guard. Mirror
3623            // C's order: readonly check → zerr → bail; only mutate
3624            // env when the assignment is admissible. Bug #551
3625            // (security-relevant).
3626            if exec.is_readonly_param(&name) {
3627                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
3628                return;
3629            }
3630            // Inline-assignment frame tracking (`X=foo cmd` reverts on
3631            // command return).
3632            if !exec.inline_env_stack.is_empty() {
3633                let prev_var = crate::ported::params::getsparam(&name);
3634                let prev_env = env::var(&name).ok();
3635                exec.inline_env_stack
3636                    .last_mut()
3637                    .unwrap()
3638                    .push((name.clone(), prev_var, prev_env));
3639                env::set_var(&name, &value);
3640            }
3641            // Canonical setsparam handles readonly, integer math, case
3642            // fold, GSU dispatch. For Int values (arith assigns) route
3643            // through setiparam so the param is PM_INTEGER + inherits
3644            // the math layer's lastbase for display formatting. For
3645            // Float (arith assigns producing MN_FLOAT) route through
3646            // setnparam so the param is PM_FFLOAT — `(( b = a * 2 ))`
3647            // with scalar `a="3.14"` should create b as typeset -F,
3648            // not a scalar holding "6.28".
3649            if int_assign {
3650                if let Some(fusevm::Value::Int(i)) = value_raw {
3651                    crate::ported::params::setiparam(&name, i);
3652                } else {
3653                    crate::ported::params::setsparam(&name, &value);
3654                }
3655            } else if float_assign {
3656                if let Some(fusevm::Value::Float(f)) = value_raw {
3657                    // ArithCompiler returns Value::Float whenever any
3658                    // operand came through Str (BUILTIN_GET_VAR yields
3659                    // Value::Str even for integer-shaped scalars). To
3660                    // avoid forcing every `(( b = a + 3 ))` to PM_FFLOAT
3661                    // when `a="5"` (integer-shaped), detect integer-
3662                    // valued floats and route through setiparam instead.
3663                    // True floats (non-integral) reach setnparam →
3664                    // PM_FFLOAT so `typeset -p b` shows `typeset -F …`.
3665                    if f.fract() == 0.0 && f.is_finite() && f.abs() <= i64::MAX as f64 {
3666                        crate::ported::params::setiparam(&name, f as i64);
3667                    } else {
3668                        let mnval = crate::ported::math::mnumber {
3669                            l: 0,
3670                            d: f,
3671                            type_: crate::ported::math::MN_FLOAT,
3672                        };
3673                        crate::ported::params::setnparam(&name, mnval);
3674                    }
3675                } else {
3676                    crate::ported::params::setsparam(&name, &value);
3677                }
3678            } else {
3679                crate::ported::params::setsparam(&name, &value);
3680            }
3681            // PM_EXPORTED / allexport env mirror — read AFTER setsparam
3682            // so the flag bit reflects any GSU setfn side-effects.
3683            let allexport = opt_state_get("allexport").unwrap_or(false);
3684            let already_exported =
3685                (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
3686            if allexport || already_exported {
3687                env::set_var(&name, &value);
3688            }
3689            #[cfg(feature = "recorder")]
3690            if crate::recorder::is_enabled()
3691                && exec.local_scope_depth == 0
3692                && !matches!(
3693                    name.as_str(),
3694                    "PPID" | "LINENO" | "ZSH_ARGZERO" | "argv0" | "ARGC" | "?" | "_" | "RANDOM"
3695                )
3696            {
3697                let ctx = exec.recorder_ctx();
3698                let attrs = exec.recorder_attrs_for(&name);
3699                crate::recorder::emit_assign_typed(&name, &value, attrs, ctx);
3700            }
3701        });
3702        Value::Status(vm.last_status)
3703    });
3704
3705    // Pre-compiled function registration — used by compile_zsh.rs's
3706    // FuncDef path. Stack: [name, base64-bincode-of-Chunk]. We decode
3707    // the base64, deserialize the Chunk, and store directly in
3708    // executor.functions_compiled. Bypasses the ShellCommand JSON layer.
3709    // BUILTIN_VAR_EXISTS — `[[ -v name ]]` set-test.
3710    // PURE PASSTHRU: build `${+name}` and route through canonical
3711    // `subst::paramsubst` which returns "1" for set / "0" for unset
3712    // (C port of `Src/subst.c::paramsubst` plus-prefix arm).
3713    // paramsubst handles all the shapes the 48-line hand-roll did:
3714    //   - bare scalar / array / assoc
3715    //   - subscripted `a[N]` / `h[key]`
3716    //   - positional params (any digit-only name)
3717    //   - env-var fallback (`HOME` set via getsparam → lookup_special_var)
3718    vm.register_builtin(BUILTIN_VAR_EXISTS, |vm, _argc| {
3719        let name = vm.pop().to_str();
3720        let body = format!("${{+{}}}", name);
3721        let mut ret_flags: i32 = 0;
3722        let (_full, _pos, nodes) =
3723            crate::ported::subst::paramsubst(&body, 0, false, 0i32, &mut ret_flags);
3724        let result = nodes.into_iter().next().unwrap_or_default();
3725        Value::Bool(result == "1")
3726    });
3727
3728    // `time { compound; ... }` — runs the sub-chunk and prints elapsed
3729    // wall-clock time. zsh's full `time` also tracks user/system CPU via
3730    // getrusage on the *child*; we approximate via wall-time only since
3731    // the sub-chunk runs in-process (no fork). Output format matches
3732    // `time simple-cmd` (already implemented elsewhere via exectime).
3733    vm.register_builtin(BUILTIN_TIME_SUBLIST, |vm, argc| {
3734        let sub_idx = vm.pop().to_int() as usize;
3735        // c:Src/jobs.c:1028-1029 — `pn->text` arg to printtime. argc==2
3736        // means the compiler also pushed a desc string (bug #66 fix);
3737        // older callers with argc==1 push only sub_idx and we synthesize
3738        // an empty desc for backward compat with cached bytecode that
3739        // predates the desc-threading patch.
3740        let desc = if argc >= 2 {
3741            vm.pop().to_str().to_string()
3742        } else {
3743            String::new()
3744        };
3745        let chunk_opt = vm.chunk.sub_chunks.get(sub_idx).cloned();
3746        let Some(chunk) = chunk_opt else {
3747            return Value::Status(0);
3748        };
3749        // c:Src/jobs.c:1968 — `getrusage(RUSAGE_CHILDREN, &ti)` before
3750        // and after the timed sublist gives accurate per-stage user/sys
3751        // CPU. Wall-time-only approximation (0.7×/0.1× fudge factors)
3752        // produced bogus user/sys columns and ignored TIMEFMT. Bug #66
3753        // in docs/BUGS.md.
3754        let ru_before: libc::rusage = unsafe {
3755            let mut r: libc::rusage = std::mem::zeroed();
3756            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
3757            r
3758        };
3759        let start = Instant::now();
3760        crate::fusevm_disasm::maybe_print_stdout("time_sublist", &chunk);
3761        let mut sub_vm = fusevm::VM::new(chunk);
3762        register_builtins(&mut sub_vm);
3763        let _ = sub_vm.run();
3764        let status = sub_vm.last_status;
3765        let elapsed = start.elapsed();
3766        let ru_after: libc::rusage = unsafe {
3767            let mut r: libc::rusage = std::mem::zeroed();
3768            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
3769            r
3770        };
3771        // Delta children rusage = timed work's CPU.
3772        let mut delta = ru_after;
3773        let sub = |a: libc::timeval, b: libc::timeval| -> libc::timeval {
3774            let mut sec = a.tv_sec - b.tv_sec;
3775            let mut usec = a.tv_usec as i64 - b.tv_usec as i64;
3776            if usec < 0 {
3777                sec -= 1;
3778                usec += 1_000_000;
3779            }
3780            libc::timeval {
3781                tv_sec: sec,
3782                tv_usec: usec as libc::suseconds_t,
3783            }
3784        };
3785        delta.ru_utime = sub(ru_after.ru_utime, ru_before.ru_utime);
3786        delta.ru_stime = sub(ru_after.ru_stime, ru_before.ru_stime);
3787        let ti = crate::ported::zsh_h::timeinfo::from_rusage(&delta);
3788        // c:Src/jobs.c:808-809 — `s = getsparam("TIMEFMT"); s ||
3789        // DEFAULT_TIMEFMT`. Honor user-set TIMEFMT, fall back to the
3790        // canonical default.
3791        let fmt = crate::ported::params::getsparam("TIMEFMT")
3792            .unwrap_or_else(|| crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string());
3793        // c:Src/jobs.c:768 `desc` arg — for the `time { sublist }` /
3794        // `time simple-cmd` keyword path, zsh passes the sublist's
3795        // source text (used by %J via printtime). The compiler now
3796        // threads the rendered source text through as the desc operand
3797        // (compile_zsh.rs Time arm, argc==2 form). Bug #66.
3798        let line = crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
3799        eprintln!("{}", line);
3800        Value::Status(status)
3801    });
3802
3803    // `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocator.
3804    // Stack: [path, varid, op_byte]. Opens path with the appropriate mode
3805    // and stores the resulting fd number in $varid as a string. We use
3806    // a high starting fd (10+) by allocating then dup'ing — matches zsh's
3807    // "fresh fd >= 10" promise so subsequent commands don't collide on
3808    // stdin/out/err.
3809    vm.register_builtin(BUILTIN_OPEN_NAMED_FD, |vm, _argc| {
3810        let op_byte = vm.pop().to_int() as u8;
3811        let varid = vm.pop().to_str();
3812        let path = vm.pop().to_str();
3813        let path_c = match CString::new(path.clone()) {
3814            Ok(c) => c,
3815            Err(_) => return Value::Status(1),
3816        };
3817        let flags = match op_byte {
3818            b if b == fusevm::op::redirect_op::READ => libc::O_RDONLY,
3819            b if b == fusevm::op::redirect_op::WRITE || b == fusevm::op::redirect_op::CLOBBER => {
3820                libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC
3821            }
3822            b if b == fusevm::op::redirect_op::APPEND => {
3823                libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND
3824            }
3825            b if b == fusevm::op::redirect_op::READ_WRITE => libc::O_RDWR | libc::O_CREAT,
3826            _ => return Value::Status(1),
3827        };
3828        let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o644) };
3829        if fd < 0 {
3830            return Value::Status(1);
3831        }
3832        // Re-dup to fd >= 10 so positional fds (0/1/2/etc.) stay free.
3833        let new_fd = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 10) };
3834        let final_fd = if new_fd >= 10 {
3835            unsafe { libc::close(fd) };
3836            new_fd
3837        } else {
3838            fd
3839        };
3840        with_executor(|exec| {
3841            exec.set_scalar(varid, final_fd.to_string());
3842        });
3843        Value::Status(0)
3844    });
3845
3846    // BUILTIN_SET_TRY_BLOCK_ERROR — capture the try-block's exit
3847    // status into `__zshrs_try_block_saved_status` (a scratch
3848    // scalar) so the always-arm can later restore it. Also set
3849    // `TRY_BLOCK_ERROR` per zsh semantics: it stays at -1 unless
3850    // the try-block fired an explicit error (errflag), per
3851    // c:Src/exec.c execlist's WC_TRYBLOCK arm.
3852    vm.register_builtin(BUILTIN_SET_TRY_BLOCK_ERROR, |vm, _argc| {
3853        use std::sync::atomic::Ordering;
3854        let vm_status = vm.last_status;
3855        let errored = (crate::ported::utils::errflag.load(Ordering::Relaxed)
3856            & crate::ported::zsh_h::ERRFLAG_ERROR)
3857            != 0;
3858        // c:Src/exec.c WC_TRYBLOCK — the always-arm runs with a
3859        // clean escape state. Snapshot RETFLAG / BREAKS / CONTFLAG /
3860        // EXIT_PENDING here and clear them; RESTORE_TRY_BLOCK_STATUS
3861        // re-applies them at always-arm exit so the propagation jump
3862        // emitted by compile_zsh fires correctly.
3863        let ret_save = crate::ported::builtin::RETFLAG.swap(0, Ordering::Relaxed);
3864        let brk_save = crate::ported::builtin::BREAKS.swap(0, Ordering::Relaxed);
3865        let cont_save = crate::ported::builtin::CONTFLAG.swap(0, Ordering::Relaxed);
3866        let exit_save = crate::ported::builtin::EXIT_PENDING.swap(0, Ordering::Relaxed);
3867        TRY_ESCAPE_SAVE.with(|s| {
3868            s.borrow_mut()
3869                .push((ret_save, brk_save, cont_save, exit_save));
3870        });
3871        with_executor(|exec| {
3872            exec.set_scalar(
3873                "__zshrs_try_block_saved_status".to_string(),
3874                vm_status.to_string(),
3875            );
3876            // c:Src/exec.c WC_TRYBLOCK — TRY_BLOCK_ERROR reflects
3877            // the errflag state at try-block exit. zsh leaves it
3878            // at -1 (sentinel) when the block completed normally,
3879            // and sets to last_status when errflag triggered the
3880            // unwind. The always-arm can reset it to 0 to
3881            // SWALLOW the error.
3882            if errored {
3883                exec.set_scalar("TRY_BLOCK_ERROR".to_string(), vm_status.to_string());
3884                // Clear errflag so always-arm runs cleanly.
3885                crate::ported::utils::errflag
3886                    .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
3887            } else {
3888                // c:Src/Modules/parameter.c — TRY_BLOCK_ERROR reads
3889                // as 0 inside an always-arm when no error fired
3890                // (per zsh's PM_INTEGER default-zero). The previous
3891                // port set -1 (the C internal "no try yet" sentinel)
3892                // which leaked to user-visible reads.
3893                exec.set_scalar("TRY_BLOCK_ERROR".to_string(), "0".to_string());
3894            }
3895        });
3896        Value::Status(0)
3897    });
3898
3899    // BUILTIN_BEGIN_INLINE_ENV / END_INLINE_ENV — wrap an
3900    // inline-assignment-prefixed command (`X=foo Y=bar cmd`):
3901    // BEGIN pushes a save frame; SET_VAR fires for each assign and
3902    // ALSO env::set_var's the value (visible to cmd's child); the
3903    // command runs; END pops the frame and restores both shell-var
3904    // and process-env state. Direct port of zsh's addvars() →
3905    // execute_simple → restore-after-exec contract.
3906    vm.register_builtin(BUILTIN_BEGIN_INLINE_ENV, |_vm, _argc| {
3907        with_executor(|exec| {
3908            exec.inline_env_stack.push(Vec::new());
3909        });
3910        Value::Status(0)
3911    });
3912    vm.register_builtin(BUILTIN_END_INLINE_ENV, |_vm, _argc| {
3913        with_executor(|exec| {
3914            if let Some(frame) = exec.inline_env_stack.pop() {
3915                for (name, prev_var, prev_env) in frame.into_iter().rev() {
3916                    match prev_var {
3917                        Some(v) => {
3918                            exec.set_scalar(name.clone(), v);
3919                        }
3920                        None => {
3921                            exec.unset_scalar(&name);
3922                        }
3923                    }
3924                    match prev_env {
3925                        Some(v) => env::set_var(&name, &v),
3926                        None => env::remove_var(&name),
3927                    }
3928                }
3929            }
3930        });
3931        Value::Status(0)
3932    });
3933
3934    // BUILTIN_RESTORE_TRY_BLOCK_STATUS — emitted at the end of an
3935    // `always` arm. Per zshmisc, the exit status of the entire
3936    // `{ try } always { finally }` construct is the try-list's
3937    // status, regardless of what happens in the always-list (the
3938    // exception is `return`/`exit` inside always, which short-
3939    // circuits and the cleanup is the only thing that runs). So
3940    // restore TRY_BLOCK_ERROR unconditionally — the always-list's
3941    // exit status is discarded for the construct.
3942    vm.register_builtin(BUILTIN_RESTORE_TRY_BLOCK_STATUS, |_vm, _argc| {
3943        use std::sync::atomic::Ordering;
3944        // c:Src/exec.c — the entire `{try} always {…}` construct's
3945        // exit status is the try-block's last status. Per zsh
3946        // semantics this carries through regardless of what the
3947        // always-arm did (including reads/writes of TRY_BLOCK_ERROR
3948        // — those affect later commands' visible value but don't
3949        // override the construct's exit). The "swallow" idiom in
3950        // C is gated on errflag state at always-arm exit, not on
3951        // TBE's literal value; full fidelity needs more state and
3952        // is deferred.
3953        let saved = with_executor(|exec| {
3954            exec.scalar("__zshrs_try_block_saved_status")
3955                .and_then(|s| s.parse::<i32>().ok())
3956                .unwrap_or(0)
3957        });
3958        // Re-apply the escape flags captured by SET_TRY_BLOCK_ERROR.
3959        // If the always-arm itself fired return/break/continue/exit,
3960        // its handler already overwrote the canonical atomics; let
3961        // those win — the always-arm's own escape always takes
3962        // priority over the try-block's deferred one.
3963        if let Some((ret, brk, cont, exit_p)) = TRY_ESCAPE_SAVE.with(|s| s.borrow_mut().pop()) {
3964            if crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) == 0 {
3965                crate::ported::builtin::RETFLAG.store(ret, Ordering::Relaxed);
3966            }
3967            if crate::ported::builtin::BREAKS.load(Ordering::Relaxed) == 0 {
3968                crate::ported::builtin::BREAKS.store(brk, Ordering::Relaxed);
3969            }
3970            if crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed) == 0 {
3971                crate::ported::builtin::CONTFLAG.store(cont, Ordering::Relaxed);
3972            }
3973            if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) == 0 {
3974                crate::ported::builtin::EXIT_PENDING.store(exit_p, Ordering::Relaxed);
3975            }
3976        }
3977        Value::Status(saved)
3978    });
3979
3980    vm.register_builtin(BUILTIN_IS_TTY, |vm, _argc| {
3981        let fd_str = vm.pop().to_str();
3982        let fd: i32 = fd_str.trim().parse().unwrap_or(-1);
3983        let is_tty = if fd < 0 {
3984            false
3985        } else {
3986            unsafe { libc::isatty(fd) != 0 }
3987        };
3988        Value::Bool(is_tty)
3989    });
3990
3991    // Set $LINENO before executing the next statement. Direct
3992    // port of zsh's `lineno` global tracking from Src/input.c
3993    // (`if ((inbufflags & INP_LINENO) || !strin) && c == '\n')
3994    // lineno++;`). The compiler emits one of these before each
3995    // top-level pipe in `compile_sublist`, carrying the line
3996    // number captured by the parser at `ZshPipe.lineno`. Pops
3997    // [n], updates `$LINENO` in the variable table.
3998    vm.register_builtin(BUILTIN_SET_LINENO, |vm, _argc| {
3999        let n = vm.pop().to_int();
4000        // c:Src/exec.c:lineno = N — direct write to the param's
4001        // u_val. Cannot go through setsparam because LINENO carries
4002        // PM_READONLY (so `(t)LINENO` reads `integer-readonly-special`
4003        // per zsh); setsparam → assignstrvalue's PM_READONLY guard
4004        // would reject the internal write. C zsh handles this via the
4005        // PM_SPECIAL GSU vtable's setfn callback which bypasses the
4006        // generic readonly check; the Rust port writes the canonical
4007        // field directly instead.
4008        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
4009            if let Some(pm) = tab.get_mut("LINENO") {
4010                pm.u_val = n;
4011                pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
4012            }
4013        }
4014        // Mirror to the file-static `lineno` (utils.c:121) that
4015        // zerrmsg reads at utils.c:301 for the `:N: msg` prefix.
4016        crate::ported::utils::set_lineno(n as i32);
4017        // DAP hook — checks breakpoints / step mode / pause-request
4018        // for the line we just landed on. O(1) no-op when DAP is off
4019        // (single atomic load on a OnceLock). Inside `--dap` mode
4020        // this is the call that blocks the executor on a Condvar
4021        // until the IDE sends `continue`. Mirrors strykelang's
4022        // `debugger.should_stop(line) → debugger.prompt(...)` flow.
4023        crate::extensions::dap::check_line(n as u32);
4024        Value::Status(0)
4025    });
4026
4027    // Direct port of Src/prompt.c:1623 cmdpush. Token is a `CS_*`
4028    // value (zsh.h:2775-2806) emitted by compile_zsh around each
4029    // compound command (if/while/[[…]]/((…))/$(…)) and consumed by
4030    // `%_` in PS4 / prompt expansion.
4031    vm.register_builtin(BUILTIN_CMD_PUSH, |vm, _argc| {
4032        let token = vm.pop().to_int() as u8;
4033        // Route through canonical cmdpush (Src/prompt.c:1623). The
4034        // prompt expander reads from the file-static `CMDSTACK` at
4035        // `prompt.rs:2006`, not `exec.cmd_stack` — without this,
4036        // `%_` in PS4 saw an empty stack during xtrace.
4037        if (token as i32) < crate::ported::zsh_h::CS_COUNT {
4038            crate::ported::prompt::cmdpush(token);
4039        }
4040        Value::Status(0)
4041    });
4042
4043    // Direct port of Src/prompt.c:1631 cmdpop.
4044    vm.register_builtin(BUILTIN_CMD_POP, |_vm, _argc| {
4045        crate::ported::prompt::cmdpop();
4046        Value::Status(0)
4047    });
4048
4049    vm.register_builtin(BUILTIN_OPTION_SET, |vm, _argc| {
4050        let name = vm.pop().to_str();
4051        // Direct port of `optison(char *name, char *s)` at Src/cond.c:502 — `[[ -o NAME ]]`
4052        // reads through the same `opts[]` array that `setopt NAME`
4053        // writes via `dosetopt`. Earlier code read a duplicate Executor
4054        // HashMap which never saw `bin_setopt`'s writes (those land in
4055        // `OPTS_LIVE` via `opt_state_set`). Routing through the canonical
4056        // C port restores the single-store invariant: one `opts[]`,
4057        // shared between setopt/unsetopt and `[[ -o ]]`.
4058        let r = crate::ported::cond::optison("test", &name); // c:cond.c:502
4059        match r {
4060            0 => Value::Bool(true),  // c:cond.c:520 set
4061            1 => Value::Bool(false), // c:cond.c:518/520 unset
4062            _ => {
4063                // c:cond.c:514 — unknown option: zwarnnam emitted by
4064                // optison itself when POSIXBUILTINS is unset; mirror to
4065                // stderr here for parity with the earlier diagnostic.
4066                eprintln!("{}:1: no such option: {}", shname(), name);
4067                Value::Bool(false)
4068            }
4069        }
4070    });
4071    // Tri-state `-o` for compile_cond's direct status path. Returns
4072    // 0 / 1 / 3 as a Value::Int that compile_cond consumes via
4073    // Op::SetStatus. Mirrors zsh's `[[ -o invalid ]]` returning $?=3.
4074    vm.register_builtin(BUILTIN_OPTION_CHECK_TRISTATE, |vm, _argc| {
4075        let name = vm.pop().to_str();
4076        let r = crate::ported::cond::optison("test", &name); // c:cond.c:502
4077                                                             // optison itself prints the diagnostic via zwarnnam when r=3
4078                                                             // and POSIXBUILTINS is unset (the canonical path). Don't
4079                                                             // double-emit here. r is already 0/1/3.
4080        Value::Int(r as i64)
4081    });
4082
4083    // BUILTIN_PARAM_FILTER — `${var:#pat}` / `${var:|name}` etc.
4084    // PURE PASSTHRU: rebuild `${name:#pat}` and route to paramsubst.
4085    vm.register_builtin(BUILTIN_PARAM_FILTER, |vm, _argc| {
4086        let pattern = vm.pop().to_str();
4087        let name = vm.pop().to_str();
4088        let body = format!("${{{}:#{}}}", name, pattern);
4089        paramsubst_to_value(&body)
4090    });
4091
4092    // `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()`
4093    // — subscripted-array assign with array RHS. Stack pushed by
4094    // compile_assign as: [elem0, elem1, …, elemN-1, name, key].
4095    vm.register_builtin(BUILTIN_SET_SUBSCRIPT_RANGE, |vm, argc| {
4096        let n = argc as usize;
4097        let mut popped: Vec<Value> = Vec::with_capacity(n);
4098        for _ in 0..n {
4099            popped.push(vm.pop());
4100        }
4101        popped.reverse();
4102        if popped.len() < 2 {
4103            return Value::Status(1);
4104        }
4105        let key = popped.pop().unwrap().to_str();
4106        let name = popped.pop().unwrap().to_str();
4107        let mut values: Vec<String> = Vec::new();
4108        for v in popped {
4109            match v {
4110                Value::Array(items) => {
4111                    for it in items {
4112                        values.push(it.to_str());
4113                    }
4114                }
4115                other => values.push(other.to_str()),
4116            }
4117        }
4118        with_executor(|exec| {
4119            // Parse subscript: slice `lo,hi` or single index `i`.
4120            // setarrvalue (Src/params.c:2895) expects 1-based start/
4121            // end inclusive where start==end means replace one
4122            // element. Negative bounds translate to len+n+1 (1-based).
4123            //
4124            // c:Src/params.c — the END side accepts 0 as a valid value
4125            // that signals "insert BEFORE start position" (the canonical
4126            // `a[N,N-1]=val` prepend / mid-insert idiom). Bug #275 in
4127            // docs/BUGS.md: the previous Rust port clamped end up to 1,
4128            // collapsing `a[1,0]=(X Y)` into `a[1,1]=(X Y)` which
4129            // OVERWRITES position 1 instead of prepending. Provide two
4130            // translators — start_translate clamps to 1 (1-based);
4131            // end_translate keeps 0 intact so the splice in
4132            // setarrvalue (start_idx=0..end_idx=0) inserts at the front.
4133            // Bug #589: for scalars (no array), use the scalar's char
4134            // count as `len` so negative-index translation (`a[2,-1]`)
4135            // computes against the actual string length, not 0.
4136            let len = exec
4137                .array(&name)
4138                .map(|a| a.len() as i64)
4139                .or_else(|| {
4140                    crate::ported::params::paramtab()
4141                        .read()
4142                        .ok()
4143                        .and_then(|t| {
4144                            t.get(&name).and_then(|pm| {
4145                                if crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
4146                                    == crate::ported::zsh_h::PM_SCALAR
4147                                {
4148                                    pm.u_str
4149                                        .as_ref()
4150                                        .map(|s| s.chars().count() as i64)
4151                                } else {
4152                                    None
4153                                }
4154                            })
4155                        })
4156                })
4157                .unwrap_or(0);
4158            let start_translate = |raw: i64| -> i32 {
4159                if raw < 0 {
4160                    (len + raw + 1).max(1) as i32
4161                } else {
4162                    raw.max(1) as i32
4163                }
4164            };
4165            let end_translate = |raw: i64| -> i32 {
4166                if raw < 0 {
4167                    (len + raw + 1).max(0) as i32
4168                } else {
4169                    raw.max(0) as i32
4170                }
4171            };
4172            // c:Src/params.c — KSH_ARRAYS option flips array subscripts
4173            // from 1-based to 0-based. setarrvalue expects 1-based
4174            // inclusive bounds, so under KSH_ARRAYS we shift positive
4175            // inputs by +1 before translation. Negative bounds left
4176            // alone (count from end). Sibling of #610/#611/#612.
4177            // Bug #613.
4178            let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
4179            let ksh_shift = |raw: i64| -> i64 {
4180                if ksh_arrays && raw >= 0 { raw + 1 } else { raw }
4181            };
4182            let (start, end) = if let Some((s_str, e_str)) = key.split_once(',') {
4183                let s = ksh_shift(s_str.trim().parse::<i64>().unwrap_or(0));
4184                let e = ksh_shift(e_str.trim().parse::<i64>().unwrap_or(0));
4185                (start_translate(s), end_translate(e))
4186            } else {
4187                let i = ksh_shift(key.trim().parse::<i64>().unwrap_or(0));
4188                if i == 0 {
4189                    return;
4190                }
4191                let n = start_translate(i);
4192                (n, n)
4193            };
4194            // Route through canonical setarrvalue (Src/params.c:2895).
4195            // It handles PM_READONLY rejection, PM_HASHED slice-error,
4196            // PM_ARRAY splice + bounds clamp + padding (c:2980+).
4197            let taken = match crate::ported::params::paramtab().write() {
4198                Ok(mut tab) => tab.remove(&name),
4199                Err(_) => None,
4200            };
4201            // c:Src/params.c:2748+ — PM_SCALAR with subscript range
4202            // SPLICES the value into the scalar's char string. Bug
4203            // #589: zshrs's slice handler always called setarrvalue,
4204            // erroring "attempt to assign array value to non-array"
4205            // for `a=hello; a[2,3]=XYZ`. Detect PM_SCALAR and route
4206            // through assignstrvalue (which does scalar splice via
4207            // the PM_SCALAR arm at params.rs:3709-3789).
4208            let is_scalar = taken.as_ref().map_or(false, |pm| {
4209                crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
4210                    == crate::ported::zsh_h::PM_SCALAR
4211            });
4212            let mut v = crate::ported::zsh_h::value {
4213                pm: taken,
4214                arr: Vec::new(),
4215                scanflags: 0,
4216                valflags: 0,
4217                start,
4218                end,
4219            };
4220            if is_scalar {
4221                // Scalar splice — concat values, route through
4222                // assignstrvalue which dispatches by PM_TYPE.
4223                // start_translate returns 1-based positions; assignstrvalue's
4224                // PM_SCALAR arm at params.rs:3735+ expects 0-based start
4225                // (chars before start are kept) and 0-based end-exclusive
4226                // (chars from end are kept). Convert: start-=1.
4227                if v.start > 0 {
4228                    v.start -= 1;
4229                }
4230                let val: String = values.join("");
4231                crate::ported::params::assignstrvalue(Some(&mut v), Some(val), 0);
4232            } else {
4233                crate::ported::params::setarrvalue(&mut v, values);
4234            }
4235            // Write the mutated Param back to paramtab — setarrvalue
4236            // mutated v.pm in-place; the prior `tab.remove(&name)` at
4237            // the top of this handler took ownership, so we re-insert
4238            // here. setarrvalue + this re-insert IS the canonical
4239            // store (Src/params.c:2895). No further mirror needed.
4240            if let Some(pm) = v.pm {
4241                if let Ok(mut tab) = crate::ported::params::paramtab().write() {
4242                    tab.insert(name, pm);
4243                }
4244            }
4245        });
4246        Value::Status(0)
4247    });
4248
4249    // BUILTIN_CONCAT_SPLICE — word-segment concat with first/last
4250    // sticking (default zsh splice semantics for `${arr[@]}`, `$@`).
4251    vm.register_builtin(BUILTIN_CONCAT_SPLICE, |vm, _argc| {
4252        let rhs = vm.pop();
4253        let lhs = vm.pop();
4254        match (lhs, rhs) {
4255            (Value::Array(mut la), Value::Array(ra)) => {
4256                if la.is_empty() {
4257                    return Value::Array(ra);
4258                }
4259                if ra.is_empty() {
4260                    return Value::Array(la);
4261                }
4262                // Last of la merges with first of ra; rest unchanged.
4263                let last_l = la.pop().unwrap();
4264                let mut ra_iter = ra.into_iter();
4265                let first_r = ra_iter.next().unwrap();
4266                let l_s = last_l.as_str_cow();
4267                let r_s = first_r.as_str_cow();
4268                let mut merged = String::with_capacity(l_s.len() + r_s.len());
4269                merged.push_str(&l_s);
4270                merged.push_str(&r_s);
4271                la.push(Value::str(merged));
4272                la.extend(ra_iter);
4273                Value::Array(la)
4274            }
4275            (Value::Array(mut la), rhs_scalar) => {
4276                // c:Src/subst.c paramsubst splice — empty array on
4277                // either side preserves the empty (zero words),
4278                // doesn't collapse into a single-empty-string scalar.
4279                // Bug #120 in docs/BUGS.md: empty array slice
4280                // concatenated with empty literal returned
4281                // Value::str("") which surfaced as one empty arg
4282                // instead of zero args.
4283                let rhs_s = rhs_scalar.as_str_cow();
4284                if la.is_empty() {
4285                    if rhs_s.is_empty() {
4286                        return Value::Array(Vec::new());
4287                    }
4288                    return Value::str(rhs_s.to_string());
4289                }
4290                let last = la.pop().unwrap();
4291                let l_s = last.as_str_cow();
4292                let mut s = String::with_capacity(l_s.len() + rhs_s.len());
4293                s.push_str(&l_s);
4294                s.push_str(&rhs_s);
4295                la.push(Value::str(s));
4296                Value::Array(la)
4297            }
4298            (lhs_scalar, Value::Array(mut ra)) => {
4299                let lhs_s = lhs_scalar.as_str_cow();
4300                if ra.is_empty() {
4301                    // Empty-array RHS — preserve emptiness when the
4302                    // LHS is also empty (no prefix to attach). Bug
4303                    // #120 in docs/BUGS.md.
4304                    if lhs_s.is_empty() {
4305                        return Value::Array(Vec::new());
4306                    }
4307                    return Value::str(lhs_s.to_string());
4308                }
4309                let first = ra.remove(0);
4310                let r_s = first.as_str_cow();
4311                let mut s = String::with_capacity(lhs_s.len() + r_s.len());
4312                s.push_str(&lhs_s);
4313                s.push_str(&r_s);
4314                let mut out = Vec::with_capacity(ra.len() + 1);
4315                out.push(Value::str(s));
4316                out.extend(ra);
4317                Value::Array(out)
4318            }
4319            (lhs_s, rhs_s) => {
4320                let l = lhs_s.as_str_cow();
4321                let r = rhs_s.as_str_cow();
4322                let mut s = String::with_capacity(l.len() + r.len());
4323                s.push_str(&l);
4324                s.push_str(&r);
4325                Value::str(s)
4326            }
4327        }
4328    });
4329
4330    // BUILTIN_CONCAT_DISTRIBUTE — word-segment concat. With
4331    // rcexpandparam (zsh option), distributes element-wise (cartesian
4332    // product). Default mode: joins arrays with IFS first char to a
4333    // single scalar before concat, matching zsh's default unquoted
4334    // and DQ semantics. Direct port of Src/subst.c sepjoin path
4335    // (line ~1813) which gates element-vs-join on the rc_expand_param
4336    // option, defaulting to join.
4337    // BUILTIN_CONCAT_DISTRIBUTE_FORCED — same shape as
4338    // CONCAT_DISTRIBUTE, but always cartesian-distributes when one
4339    // side is Array. Used for compile-time-detected explicit
4340    // distribution forms (`${^arr}` etc.) where the source flag
4341    // overrides the rcexpandparam option default.
4342    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE_FORCED, |vm, _argc| {
4343        let rhs = vm.pop();
4344        let lhs = vm.pop();
4345        match (lhs, rhs) {
4346            (Value::Array(la), Value::Array(ra)) => {
4347                if ra.is_empty() {
4348                    return Value::Array(la);
4349                }
4350                if la.is_empty() {
4351                    return Value::Array(ra);
4352                }
4353                let mut out = Vec::with_capacity(la.len() * ra.len());
4354                for a in &la {
4355                    let a_s = a.as_str_cow();
4356                    for b in &ra {
4357                        let b_s = b.as_str_cow();
4358                        let mut s = String::with_capacity(a_s.len() + b_s.len());
4359                        s.push_str(&a_s);
4360                        s.push_str(&b_s);
4361                        out.push(Value::str(s));
4362                    }
4363                }
4364                Value::Array(out)
4365            }
4366            (Value::Array(la), rhs_scalar) => {
4367                let r = rhs_scalar.as_str_cow();
4368                let out: Vec<Value> = la
4369                    .into_iter()
4370                    .map(|a| {
4371                        let a_s = a.as_str_cow();
4372                        let mut s = String::with_capacity(a_s.len() + r.len());
4373                        s.push_str(&a_s);
4374                        s.push_str(&r);
4375                        Value::str(s)
4376                    })
4377                    .collect();
4378                Value::Array(out)
4379            }
4380            (lhs_scalar, Value::Array(ra)) => {
4381                let l = lhs_scalar.as_str_cow();
4382                let out: Vec<Value> = ra
4383                    .into_iter()
4384                    .map(|b| {
4385                        let b_s = b.as_str_cow();
4386                        let mut s = String::with_capacity(l.len() + b_s.len());
4387                        s.push_str(&l);
4388                        s.push_str(&b_s);
4389                        Value::str(s)
4390                    })
4391                    .collect();
4392                Value::Array(out)
4393            }
4394            (lhs_s, rhs_s) => {
4395                let l = lhs_s.as_str_cow();
4396                let r = rhs_s.as_str_cow();
4397                let mut s = String::with_capacity(l.len() + r.len());
4398                s.push_str(&l);
4399                s.push_str(&r);
4400                Value::str(s)
4401            }
4402        }
4403    });
4404
4405    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE, |vm, argc| {
4406        let rhs = vm.pop();
4407        let lhs = vm.pop();
4408        // c:Src/options.c — RC_EXPAND_PARAM applies to UNQUOTED
4409        // expansions only; inside DQ `"$foo${arr}bar"` joins via
4410        // $IFS[0] regardless of the option. The compiler emits
4411        // CallBuiltin(BUILTIN_CONCAT_DISTRIBUTE, 1) when the parent
4412        // word is DQ-wrapped (compile_zsh.rs parent_is_dq); the
4413        // default UNQUOTED path emits argc=2 (lhs + rhs). Treat
4414        // argc==1 as "force rc_expand off." Bug #246 in docs/BUGS.md.
4415        let dq_suppress = argc == 1;
4416        let rc_expand = !dq_suppress
4417            && with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
4418        let ifs_first = || -> String {
4419            with_executor(|exec| {
4420                exec.get_variable("IFS")
4421                    .chars()
4422                    .next()
4423                    .map(|c| c.to_string())
4424                    .unwrap_or_else(|| " ".to_string())
4425            })
4426        };
4427        // Helper: join an Array to scalar via IFS-first.
4428        let join_arr = |arr: Vec<Value>| -> String {
4429            let sep = ifs_first();
4430            arr.iter()
4431                .map(|v| v.as_str_cow().into_owned())
4432                .collect::<Vec<_>>()
4433                .join(&sep)
4434        };
4435        if !rc_expand {
4436            // Default: join any Array side to scalar, then concat.
4437            let l = match lhs {
4438                Value::Array(a) => join_arr(a),
4439                other => other.as_str_cow().into_owned(),
4440            };
4441            let r = match rhs {
4442                Value::Array(a) => join_arr(a),
4443                other => other.as_str_cow().into_owned(),
4444            };
4445            let mut s = String::with_capacity(l.len() + r.len());
4446            s.push_str(&l);
4447            s.push_str(&r);
4448            return Value::str(s);
4449        }
4450        match (lhs, rhs) {
4451            (Value::Array(la), Value::Array(ra)) => {
4452                // Cartesian product: [a + b for a in la for b in ra].
4453                let mut out = Vec::with_capacity(la.len() * ra.len().max(1));
4454                if ra.is_empty() {
4455                    return Value::Array(la);
4456                }
4457                if la.is_empty() {
4458                    return Value::Array(ra);
4459                }
4460                for a in &la {
4461                    let a_s = a.as_str_cow();
4462                    for b in &ra {
4463                        let b_s = b.as_str_cow();
4464                        let mut s = String::with_capacity(a_s.len() + b_s.len());
4465                        s.push_str(&a_s);
4466                        s.push_str(&b_s);
4467                        out.push(Value::str(s));
4468                    }
4469                }
4470                Value::Array(out)
4471            }
4472            (Value::Array(la), rhs_scalar) => {
4473                let r = rhs_scalar.as_str_cow();
4474                let out: Vec<Value> = la
4475                    .into_iter()
4476                    .map(|a| {
4477                        let a_s = a.as_str_cow();
4478                        let mut s = String::with_capacity(a_s.len() + r.len());
4479                        s.push_str(&a_s);
4480                        s.push_str(&r);
4481                        Value::str(s)
4482                    })
4483                    .collect();
4484                Value::Array(out)
4485            }
4486            (lhs_scalar, Value::Array(ra)) => {
4487                let l = lhs_scalar.as_str_cow();
4488                let out: Vec<Value> = ra
4489                    .into_iter()
4490                    .map(|b| {
4491                        let b_s = b.as_str_cow();
4492                        let mut s = String::with_capacity(l.len() + b_s.len());
4493                        s.push_str(&l);
4494                        s.push_str(&b_s);
4495                        Value::str(s)
4496                    })
4497                    .collect();
4498                Value::Array(out)
4499            }
4500            (lhs_s, rhs_s) => {
4501                // Fast path: both scalar → identical to Op::Concat.
4502                let l = lhs_s.as_str_cow();
4503                let r = rhs_s.as_str_cow();
4504                let mut s = String::with_capacity(l.len() + r.len());
4505                s.push_str(&l);
4506                s.push_str(&r);
4507                Value::str(s)
4508            }
4509        }
4510    });
4511
4512    // `[[ a -ef b ]]` — same-inode test. Resolves both paths via fs::metadata
4513    // (follows symlinks the way zsh's -ef does) and compares (dev, inode).
4514    // Returns false on any I/O error (path missing, permission denied, etc.).
4515    vm.register_builtin(BUILTIN_SAME_FILE, |vm, _argc| {
4516        let b = vm.pop().to_str();
4517        let a = vm.pop().to_str();
4518        let same = match (fs::metadata(&a), fs::metadata(&b)) {
4519            (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
4520            _ => false,
4521        };
4522        Value::Bool(same)
4523    });
4524
4525    // `[[ -c path ]]` — character device.
4526    vm.register_builtin(BUILTIN_IS_CHARDEV, |vm, _argc| {
4527        let path = vm.pop().to_str();
4528        let result = fs::metadata(&path)
4529            .map(|m| m.file_type().is_char_device())
4530            .unwrap_or(false);
4531        Value::Bool(result)
4532    });
4533    // `[[ -b path ]]` — block device.
4534    vm.register_builtin(BUILTIN_IS_BLOCKDEV, |vm, _argc| {
4535        let path = vm.pop().to_str();
4536        let result = fs::metadata(&path)
4537            .map(|m| m.file_type().is_block_device())
4538            .unwrap_or(false);
4539        Value::Bool(result)
4540    });
4541    // `[[ -p path ]]` — FIFO (named pipe).
4542    vm.register_builtin(BUILTIN_IS_FIFO, |vm, _argc| {
4543        let path = vm.pop().to_str();
4544        let result = fs::metadata(&path)
4545            .map(|m| m.file_type().is_fifo())
4546            .unwrap_or(false);
4547        Value::Bool(result)
4548    });
4549    // `[[ -S path ]]` — socket.
4550    vm.register_builtin(BUILTIN_IS_SOCKET, |vm, _argc| {
4551        let path = vm.pop().to_str();
4552        let result = fs::symlink_metadata(&path)
4553            .map(|m| m.file_type().is_socket())
4554            .unwrap_or(false);
4555        Value::Bool(result)
4556    });
4557
4558    // `[[ -k path ]]` / `-u` / `-g` — sticky / setuid / setgid bit.
4559    vm.register_builtin(BUILTIN_HAS_STICKY, |vm, _argc| {
4560        let path = vm.pop().to_str();
4561        let result = fs::metadata(&path)
4562            .map(|m| m.permissions().mode() & libc::S_ISVTX as u32 != 0)
4563            .unwrap_or(false);
4564        Value::Bool(result)
4565    });
4566    vm.register_builtin(BUILTIN_HAS_SETUID, |vm, _argc| {
4567        let path = vm.pop().to_str();
4568        let result = fs::metadata(&path)
4569            .map(|m| m.permissions().mode() & libc::S_ISUID as u32 != 0)
4570            .unwrap_or(false);
4571        Value::Bool(result)
4572    });
4573    vm.register_builtin(BUILTIN_HAS_SETGID, |vm, _argc| {
4574        let path = vm.pop().to_str();
4575        let result = fs::metadata(&path)
4576            .map(|m| m.permissions().mode() & libc::S_ISGID as u32 != 0)
4577            .unwrap_or(false);
4578        Value::Bool(result)
4579    });
4580    vm.register_builtin(BUILTIN_OWNED_BY_USER, |vm, _argc| {
4581        let path = vm.pop().to_str();
4582        let euid = unsafe { libc::geteuid() };
4583        let result = fs::metadata(&path)
4584            .map(|m| m.uid() == euid)
4585            .unwrap_or(false);
4586        Value::Bool(result)
4587    });
4588    vm.register_builtin(BUILTIN_OWNED_BY_GROUP, |vm, _argc| {
4589        let path = vm.pop().to_str();
4590        let egid = unsafe { libc::getegid() };
4591        let result = fs::metadata(&path)
4592            .map(|m| m.gid() == egid)
4593            .unwrap_or(false);
4594        Value::Bool(result)
4595    });
4596
4597    // `[[ -N path ]]` — file's access time is NOT newer than its
4598    // modification time (zsh man: "true if file exists and its
4599    // access time is not newer than its modification time"). Used
4600    // by zsh's mailbox-watching code. The semantic is `atime <=
4601    // mtime` (equivalent to `mtime >= atime`) — equal counts as
4602    // true, which a strict `mtime > atime` check missed for newly
4603    // created files where both stamps are identical.
4604    vm.register_builtin(BUILTIN_FILE_MODIFIED_SINCE_ACCESS, |vm, _argc| {
4605        let path = vm.pop().to_str();
4606        let result = fs::metadata(&path)
4607            .map(|m| m.atime() <= m.mtime())
4608            .unwrap_or(false);
4609        Value::Bool(result)
4610    });
4611
4612    // `[[ a -nt b ]]` — true if `a`'s mtime is strictly later than `b`'s.
4613    // BOTH files must exist; if either is missing the result is false.
4614    // (Earlier behavior was bash's "missing == infinitely-old"; zsh
4615    // strictly requires both files to exist.)
4616    vm.register_builtin(BUILTIN_FILE_NEWER, |vm, _argc| {
4617        let b = vm.pop().to_str();
4618        let a = vm.pop().to_str();
4619        // Use SystemTime modified() for nanosecond precision —
4620        // MetadataExt::mtime() returns seconds only, so two files
4621        // touched within the same second compared equal even when
4622        // 500ms apart. zsh tracks ns and uses `>=` for ties (touching
4623        // a then b in quick succession should still report b newer).
4624        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
4625        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
4626        let result = match (ta, tb) {
4627            (Some(ta), Some(tb)) => ta > tb,
4628            _ => false,
4629        };
4630        Value::Bool(result)
4631    });
4632
4633    // `[[ a -ot b ]]` — mirror of -nt. Same both-must-exist contract.
4634    vm.register_builtin(BUILTIN_FILE_OLDER, |vm, _argc| {
4635        let b = vm.pop().to_str();
4636        let a = vm.pop().to_str();
4637        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
4638        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
4639        let result = match (ta, tb) {
4640            (Some(ta), Some(tb)) => ta < tb,
4641            _ => false,
4642        };
4643        Value::Bool(result)
4644    });
4645
4646    // `set -e` / `setopt errexit` post-command check. Compiler emits
4647    // this after each top-level command's SetStatus (skipped inside
4648    // conditionals/pipelines/&&||/`!`). If errexit is on AND the last
4649    // command exited non-zero AND it's not a `return` from a function,
4650    // exit the shell with that status.
4651    // `set -x` / `setopt xtrace` — print each command before it runs.
4652    // The compiler emits this BEFORE the actual builtin/external call
4653    // with the command's literal text as a single string arg. We
4654    // print to stderr if xtrace is on. Honors `$PS4` (default `+ `).
4655    //
4656    // ── XTRACE flow control ────────────────────────────────────────
4657    // Mirror of C zsh's `doneps4` flag in execcmd_exec (Src/exec.c).
4658    // When an assignment trace fires (XTRACE_ASSIGN), it emits PS4
4659    // and sets this flag so the subsequent XTRACE_ARGS skips its own
4660    // PS4 emission — the assignment + command end up on the SAME
4661    // line: `<PS4>a=1 echo hello\n`. XTRACE_ARGS / XTRACE_NEWLINE
4662    // reset the flag after emitting the trailing `\n`.
4663    vm.register_builtin(BUILTIN_XTRACE_IS_ON, |_vm, _argc| {
4664        // Push live xtrace state. Caller pairs this with JumpIfFalse
4665        // to skip the trace-string-building block when xtrace is off,
4666        // avoiding side-effectful operand re-evaluation. Bug #159 in
4667        // docs/BUGS.md.
4668        let on = with_executor(|_| opt_state_get("xtrace").unwrap_or(false));
4669        Value::Int(if on { 1 } else { 0 })
4670    });
4671
4672    vm.register_builtin(BUILTIN_XTRACE_LINE, |vm, _argc| {
4673        let cmd_text = vm.pop().to_str();
4674        // Sync exec.last_status with the live vm.last_status BEFORE
4675        // the next command runs. Direct port of the zsh exec.c
4676        // contract — `$?` reads the exit status of the *most recent*
4677        // command. XTRACE_LINE is emitted by the compiler BEFORE
4678        // every simple command, so it's the natural sync point.
4679        let live = vm.last_status;
4680        with_executor(|exec| {
4681            exec.set_last_status(live);
4682        });
4683        // C zsh emits xtrace for `(( … ))` / `[[ … ]]` / `case` /
4684        // `if/while/until/for/repeat` head expressions via
4685        // `printprompt4(); fprintf(xtrerr, "%s\n", expr)` at
4686        // Src/exec.c:5240 (math), c:5286 (cond), c:4117 (for), etc.
4687        // The compiler emits BUILTIN_XTRACE_LINE only at those
4688        // construct boundaries (compile_arith / compile_cond /
4689        // compile_if / compile_while / compile_for / compile_case);
4690        // simple commands route to BUILTIN_XTRACE_ARGS instead. So
4691        // this handler always emits when xtrace is on — no prefix-
4692        // string heuristic.
4693        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
4694        if on {
4695            let already = XTRACE_DONE_PS4.with(|f| f.get());
4696            if !already {
4697                printprompt4();
4698            }
4699            eprintln!("{}", cmd_text);
4700            XTRACE_DONE_PS4.with(|f| f.set(false));
4701        }
4702        Value::Status(0)
4703    });
4704
4705    // Like XTRACE_LINE but reads the top `argc - 1` values from the
4706    // VM stack WITHOUT consuming them (peek), then pops a prefix
4707    // string at the top. Joins prefix + peeked args with spaces using
4708    // zsh's quotedzputs-equivalent quoting. Direct port of
4709    // Src/exec.c:2055-2066 — emit AFTER expansion, with each arg
4710    // shell-quoted, so `for i in a b; echo for $i` traces as
4711    // `echo for a` / `echo for b`, not `echo for $i`.
4712    //
4713    // Stack contract on entry: [arg1, arg2, ..., argN, prefix].
4714    // Pops prefix; peeks argN..arg1 below. argc = N + 1.
4715    vm.register_builtin(BUILTIN_XTRACE_ARGS, |vm, argc| {
4716        let prefix = vm.pop().to_str();
4717        let live = vm.last_status;
4718        with_executor(|exec| {
4719            exec.set_last_status(live);
4720        });
4721        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
4722        if on {
4723            let n_args = argc.saturating_sub(1) as usize;
4724            let len = vm.stack.len();
4725            // c:Src/exec.c:2055 — argv is the POST-expansion word
4726            // list, so an arg that expanded to multiple words splats
4727            // into multiple trace tokens AND an arg that expanded to
4728            // zero words (empty unquoted `${UNSET}`) emits nothing.
4729            // pop_args (line 6243) already does this splat for the
4730            // real handler; mirror the same Array → splat / empty →
4731            // drop logic here so xtrace renders `echo ${UNSET}` as
4732            // `echo` (zsh) instead of `echo ''` (the previous
4733            // single-arg stringify path returned "" and then
4734            // quotedzputs wrapped it in `''`).
4735            let arg_strs: Vec<String> = if n_args > 0 && len >= n_args {
4736                let mut out = Vec::new();
4737                for v in &vm.stack[len - n_args..] {
4738                    match v {
4739                        Value::Array(items) => {
4740                            for item in items {
4741                                out.push(quotedzputs(&item.to_str()));
4742                            }
4743                        }
4744                        other => out.push(quotedzputs(&other.to_str())),
4745                    }
4746                }
4747                out
4748            } else {
4749                Vec::new()
4750            };
4751            // Builtins dispatch through `execbuiltin` (Src/builtin.c:442)
4752            // which emits its own PS4 + name + args xtrace. To avoid
4753            // double-emission, skip our emission here when the first
4754            // arg is a known builtin with a registered HandlerFunc —
4755            // those go through execbuiltin and will trace themselves.
4756            // Externals + builtins-not-yet-routed-through-execbuiltin
4757            // keep our emission as a stand-in.
4758            let goes_through_execbuiltin = crate::ported::builtin::BUILTINS
4759                .iter()
4760                .any(|b| b.node.nam == prefix && b.handlerfunc.is_some());
4761            if !goes_through_execbuiltin {
4762                let line = if arg_strs.is_empty() {
4763                    prefix
4764                } else {
4765                    format!("{} {}", prefix, arg_strs.join(" "))
4766                };
4767                // Mirrors Src/exec.c:2055 xtrace emission. C does:
4768                //   if (!doneps4) printprompt4();
4769                //   ... emit args + spaces ...
4770                //   fputc('\n', xtrerr); fflush(xtrerr);
4771                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
4772                if !already_ps4 {
4773                    printprompt4();
4774                }
4775                eprintln!("{}", line);
4776            }
4777            XTRACE_DONE_PS4.with(|f| f.set(false));
4778        }
4779        Value::Status(0)
4780    });
4781
4782    // BUILTIN_XTRACE_ASSIGN — direct port of the per-assignment
4783    // trace block at Src/exec.c:2517-2582. C body excerpt:
4784    //   xtr = isset(XTRACE);
4785    //   if (xtr) { printprompt4(); doneps4 = 1; }
4786    //   while (assign) {
4787    //       if (xtr) fprintf(xtrerr, "%s+=" or "%s=", name);
4788    //       ... eval value into `val` ...
4789    //       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
4790    //       ...
4791    //   }
4792    //
4793    // Stack on entry: [..., name, value]. PEEKS both (they're left
4794    // on stack for SET_VAR to pop). Emits `name=<quoted-val> ` with
4795    // no newline; trailing `\n` comes from XTRACE_ARGS (cmd path)
4796    // or XTRACE_NEWLINE (assignment-only path).
4797    vm.register_builtin(BUILTIN_XTRACE_ASSIGN, |vm, _argc| {
4798        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
4799        if on {
4800            // PEEK [..., name, value] — argc==2 by contract.
4801            let len = vm.stack.len();
4802            if len >= 2 {
4803                let name = vm.stack[len - 2].to_str();
4804                let value = vm.stack[len - 1].to_str();
4805                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
4806                if !already_ps4 {
4807                    printprompt4();
4808                    XTRACE_DONE_PS4.with(|f| f.set(true));
4809                }
4810                // C: `fprintf(xtrerr, "%s=", name)` then `quotedzputs
4811                // (val); fputc(' ', xtrerr);`. Emit no newline.
4812                eprint!("{}={} ", name, quotedzputs(&value));
4813            }
4814        }
4815        Value::Status(0)
4816    });
4817
4818    // BUILTIN_XTRACE_NEWLINE — emit trailing `\n` + flush iff a
4819    // prior XTRACE_ASSIGN this line already emitted PS4. Mirrors
4820    // C's `fputc('\n', xtrerr); fflush(xtrerr);` at exec.c:3398
4821    // (the assignment-only path through execcmd_exec).
4822    vm.register_builtin(BUILTIN_XTRACE_NEWLINE, |_vm, _argc| {
4823        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
4824        if on {
4825            let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
4826            if already_ps4 {
4827                eprintln!();
4828                XTRACE_DONE_PS4.with(|f| f.set(false));
4829            }
4830        }
4831        Value::Status(0)
4832    });
4833
4834    // c:Src/exec.c WC_TRYBLOCK — post-always re-jump probes. Each
4835    // returns 1 + consumes the atomic when the corresponding
4836    // escape flag is set; the try-block compile pairs each with
4837    // a JumpIfFalse + Jump → outer scope's return / break /
4838    // continue patches.
4839    vm.register_builtin(BUILTIN_RETFLAG_CHECK, |_vm, _argc| {
4840        use std::sync::atomic::Ordering;
4841        let r = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
4842        if r != 0 {
4843            // Don't clear here — doshfunc owns the clear at c:6047
4844            // when the function unwinds. Leaving it set propagates
4845            // through nested `eval`/`source` callers correctly.
4846            Value::Int(1)
4847        } else {
4848            Value::Int(0)
4849        }
4850    });
4851    vm.register_builtin(BUILTIN_BREAKS_CHECK, |_vm, _argc| {
4852        use std::sync::atomic::Ordering;
4853        let b = crate::ported::builtin::BREAKS.load(Ordering::Relaxed);
4854        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
4855        // `break` sets BREAKS but NOT CONTFLAG; `continue` sets both.
4856        // Filter out the continue path here so the two checks are
4857        // mutually exclusive.
4858        if b != 0 && c == 0 {
4859            // Consume BREAKS so the outer loop's break_patches
4860            // landing doesn't double-decrement.
4861            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
4862            Value::Int(1)
4863        } else {
4864            Value::Int(0)
4865        }
4866    });
4867    vm.register_builtin(BUILTIN_CONTFLAG_CHECK, |_vm, _argc| {
4868        use std::sync::atomic::Ordering;
4869        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
4870        if c != 0 {
4871            crate::ported::builtin::CONTFLAG.store(0, Ordering::Relaxed);
4872            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
4873            Value::Int(1)
4874        } else {
4875            Value::Int(0)
4876        }
4877    });
4878    vm.register_builtin(BUILTIN_NOEXEC_CHECK, |_vm, _argc| {
4879        // c:Src/exec.c:1390 — `set -n` / `noexec` option: parse but
4880        // don't execute. Returns Int(1) when noexec is set so the
4881        // emit-side JumpIfTrue skips the statement body.
4882        if opt_state_get("noexec").unwrap_or(false) {
4883            Value::Int(1)
4884        } else {
4885            Value::Int(0)
4886        }
4887    });
4888    vm.register_builtin(BUILTIN_DONETRAP_RESET, |_vm, _argc| {
4889        // c:Src/exec.c:1455 — `donetrap = 0;` at sublist start.
4890        // Reset before each top-level statement so the next
4891        // sublist's ERREXIT_CHECK fires the ZERR trap on its FIRST
4892        // non-zero command. Carries the "already fired" state
4893        // across function-call returns within the SAME outer
4894        // sublist (per C semantics — donetrap is process-global).
4895        // Bug #303 in docs/BUGS.md.
4896        crate::ported::exec::DONETRAP.store(0, std::sync::atomic::Ordering::Relaxed);
4897        Value::Status(0)
4898    });
4899
4900    // `[[ -z X ]]` / `[[ -n X ]]` — pop one Value, route through
4901    // canonical `src/ported/cond.rs::evalcond` so the actual
4902    // empty/non-empty test reuses the C-port at `cond.rs:270-271`
4903    // (`'n' => !arg.is_empty()`, `'z' => arg.is_empty()`).
4904    //
4905    // The Array→args conversion lives at the bridge because cond.rs
4906    // expects `&[&str]` (C `cond_str` signature equivalent). For
4907    // `"${arr[@]}"` in DQ context the splice yields `Value::Array`
4908    // — an empty array still expands to one implicit empty word
4909    // (per zsh's "${arr[@]}" splat preserving at least one slot
4910    // in cond context), so:
4911    //   - Array(0)   → ["-z", ""]            → evalcond → 0 (true)
4912    //   - Array(1)   → ["-z", word]          → evalcond → 0/1
4913    //   - Array(2+)  → ["-z", w1, w2, ...]   → evalcond → 2 (parse
4914    //                                          error: too many ops)
4915    //                                          → coerced to false
4916    //   - Str(s)     → ["-z", s]             → evalcond → 0/1
4917    //
4918    // Bug #185 in docs/BUGS.md.
4919    fn run_cond_str_empty(v: Value, op: &str) -> Value {
4920        let words: Vec<String> = match v {
4921            Value::Array(arr) => arr.into_iter().map(|x| x.to_str()).collect(),
4922            Value::Str(s) => vec![s.to_string()],
4923            other => vec![other.to_str()],
4924        };
4925        let mut args: Vec<&str> = vec![op];
4926        if words.is_empty() {
4927            args.push("");
4928        } else {
4929            args.extend(words.iter().map(|s| s.as_str()));
4930        }
4931        let opts: std::collections::HashMap<String, bool> =
4932            std::collections::HashMap::new();
4933        let vars: std::collections::HashMap<String, String> =
4934            std::collections::HashMap::new();
4935        // c:Src/cond.c:62-66 — `evalcond` returns 0=true, 1=false,
4936        // 2=syntax-error. Coerce error to false (observable behavior
4937        // in zsh: `[[ -z a b ]]` errors and the test as a whole
4938        // returns non-zero).
4939        // `[[ ]]` dispatch — C's `evalcond(state, NULL)` calling convention.
4940        // `None` for from_test → mathevali integer-compare coercion path.
4941        let ret = crate::ported::cond::evalcond(&args, &opts, &vars, false, None);
4942        Value::Int(if ret == 0 { 1 } else { 0 })
4943    }
4944    vm.register_builtin(BUILTIN_COND_STR_EMPTY, |vm, _argc| {
4945        let v = vm.pop();
4946        run_cond_str_empty(v, "-z")
4947    });
4948    vm.register_builtin(BUILTIN_COND_STR_NONEMPTY, |vm, _argc| {
4949        let v = vm.pop();
4950        run_cond_str_empty(v, "-n")
4951    });
4952
4953    // `exec N<<<"str"` — herestring redirect to explicit fd, applied
4954    // permanently. Direct port of `Src/exec.c:4655 getherestr` +
4955    // `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)` at c:3766-
4956    // 3780 for the nullexec=1 bare-exec-redir path. Bug #205 in
4957    // docs/BUGS.md.
4958    vm.register_builtin(BUILTIN_EXEC_HERESTR_FD, |vm, _argc| {
4959        let fd = vm.pop().to_int() as i32;
4960        let content = vm.pop().to_str();
4961        // c:4671-4672 — append `\n` for "real" herestrings (not
4962        // heredoc-derived). zshrs's bare-exec path only fires for
4963        // the `<<<` syntax (REDIR_HERESTR), so always append.
4964        let body = format!("{}\n", content);
4965        // c:4673-4679 — gettempfile → write_loop → close → reopen
4966        // read-only → unlink. Rust equivalent via tempfile crate or
4967        // explicit O_TMPFILE; use mkstemp + unlink-immediately to
4968        // mirror C exactly.
4969        use std::ffi::CString;
4970        let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
4971        let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
4972        if write_fd < 0 {
4973            crate::ported::utils::zwarn(&format!(
4974                "can't create temp file for here document: {}",
4975                std::io::Error::last_os_error()
4976            ));
4977            return Value::Status(1);
4978        }
4979        // c:4675 — write_loop(fd, t, len)
4980        let bytes = body.as_bytes();
4981        let mut off = 0;
4982        while off < bytes.len() {
4983            let n = unsafe {
4984                libc::write(
4985                    write_fd,
4986                    bytes[off..].as_ptr() as *const libc::c_void,
4987                    bytes.len() - off,
4988                )
4989            };
4990            if n <= 0 {
4991                unsafe { libc::close(write_fd) };
4992                return Value::Status(1);
4993            }
4994            off += n as usize;
4995        }
4996        unsafe { libc::close(write_fd) }; // c:4676
4997        // Path null-terminated by mkstemp; reopen for reading.
4998        let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
4999        // c:4678 — unlink immediately so the file disappears on
5000        // close, leaving only the fd reference.
5001        unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
5002        if read_fd < 0 {
5003            return Value::Status(1);
5004        }
5005        // c:3779 addfd → dup2 to target fd, close intermediate.
5006        let r = unsafe { libc::dup2(read_fd, fd) };
5007        unsafe { libc::close(read_fd) };
5008        if r < 0 {
5009            return Value::Status(1);
5010        }
5011        Value::Status(0)
5012    });
5013    // c:Src/exec.c:2418 + addfd splice — MULTIOS fan-out. Stack
5014    // layout pushed by compile_zsh's coalescing pass:
5015    //   [target_1, op_byte_1, target_2, op_byte_2, …, target_N,
5016    //    op_byte_N, fd]
5017    // argc = 2N + 1. Pops, opens every target, sets up a pipe +
5018    // splitter thread that reads pipe → writes every chunk to
5019    // every opened target, dup2's pipe-write-end onto fd. The
5020    // splitter is closed + joined by host_redirect_scope_end.
5021    // Bug #36 in docs/BUGS.md.
5022    vm.register_builtin(BUILTIN_MULTIOS_REDIRECT, |vm, argc| {
5023        if argc < 3 || argc % 2 == 0 {
5024            // Bad shape — bail.
5025            return Value::Status(1);
5026        }
5027        // Pop fd first (top of stack).
5028        let fd = vm.pop().to_int() as i32;
5029        // Then pop (op, target) pairs in reverse compile order.
5030        let n_targets = ((argc - 1) / 2) as usize;
5031        let mut pairs: Vec<(u8, String)> = Vec::with_capacity(n_targets);
5032        for _ in 0..n_targets {
5033            let op_byte = vm.pop().to_int() as u8;
5034            let target = vm.pop().to_str();
5035            pairs.push((op_byte, target));
5036        }
5037        // Restore compile order (target_1 first).
5038        pairs.reverse();
5039
5040        // Open every target per its op_byte.
5041        let mut target_fds: Vec<i32> = Vec::with_capacity(pairs.len());
5042        for (op_byte, target) in &pairs {
5043            let open_result = match *op_byte {
5044                r::WRITE | r::CLOBBER => fs::OpenOptions::new()
5045                    .write(true)
5046                    .create(true)
5047                    .truncate(true)
5048                    .open(target),
5049                r::APPEND => fs::OpenOptions::new()
5050                    .write(true)
5051                    .create(true)
5052                    .append(true)
5053                    .open(target),
5054                _ => fs::OpenOptions::new()
5055                    .write(true)
5056                    .create(true)
5057                    .truncate(true)
5058                    .open(target),
5059            };
5060            match open_result {
5061                Ok(file) => target_fds.push(file.into_raw_fd()),
5062                Err(e) => {
5063                    let msg = match e.kind() {
5064                        std::io::ErrorKind::PermissionDenied => "permission denied",
5065                        std::io::ErrorKind::NotFound => "no such file or directory",
5066                        std::io::ErrorKind::IsADirectory => "is a directory",
5067                        _ => "redirect failed",
5068                    };
5069                    eprintln!("{}:1: {}: {}", shname(), msg, target);
5070                    // Close already-opened fds to avoid leaks.
5071                    for prev in &target_fds {
5072                        unsafe {
5073                            libc::close(*prev);
5074                        }
5075                    }
5076                    with_executor(|exec| {
5077                        exec.redirect_failed = true;
5078                    });
5079                    return Value::Status(1);
5080                }
5081            }
5082        }
5083
5084        // Save current fd state for scope-end restoration.
5085        let saved = unsafe { libc::dup(fd) };
5086        if saved >= 0 {
5087            with_executor(|exec| {
5088                if let Some(top) = exec.redirect_scope_stack.last_mut() {
5089                    top.push((fd, saved));
5090                } else {
5091                    unsafe { libc::close(saved) };
5092                }
5093            });
5094        }
5095
5096        // Create the splitter pipe.
5097        let (read_end, write_end) = match os_pipe::pipe() {
5098            Ok(p) => p,
5099            Err(_) => {
5100                for f in &target_fds {
5101                    unsafe {
5102                        libc::close(*f);
5103                    }
5104                }
5105                return Value::Status(1);
5106            }
5107        };
5108        let pipe_write_raw = AsRawFd::as_raw_fd(&write_end);
5109        // Spawn the splitter thread: read pipe → write every chunk
5110        // to every target fd. Each write inside the thread uses
5111        // libc::write directly on the raw fd (no Rust File ownership
5112        // so the splitter can close after EOF without racing main).
5113        let target_fds_for_thread = target_fds.clone();
5114        let handle = std::thread::spawn(move || {
5115            let mut r = read_end;
5116            let mut buf = [0u8; 8192];
5117            loop {
5118                match std::io::Read::read(&mut r, &mut buf) {
5119                    Ok(0) => break,
5120                    Ok(n) => {
5121                        for &tfd in &target_fds_for_thread {
5122                            let mut off = 0;
5123                            while off < n {
5124                                let w = unsafe {
5125                                    libc::write(
5126                                        tfd,
5127                                        buf[off..n].as_ptr() as *const libc::c_void,
5128                                        n - off,
5129                                    )
5130                                };
5131                                if w <= 0 {
5132                                    break;
5133                                }
5134                                off += w as usize;
5135                            }
5136                        }
5137                    }
5138                    Err(_) => break,
5139                }
5140            }
5141            // Close every target so file contents flush.
5142            for tfd in target_fds_for_thread {
5143                unsafe {
5144                    libc::close(tfd);
5145                }
5146            }
5147        });
5148
5149        // Dup the pipe write-end onto the target fd; close the
5150        // original write_end so EOF arrives when host_redirect_scope_end
5151        // closes our tracked pipe_write_fd.
5152        let write_dup = unsafe { libc::dup(pipe_write_raw) };
5153        drop(write_end);
5154        if write_dup < 0 {
5155            return Value::Status(1);
5156        }
5157        unsafe {
5158            libc::dup2(write_dup, fd);
5159            libc::close(write_dup);
5160        }
5161        // Track the running splitter so scope-end can drain + join.
5162        // The "write_fd" we store is the user-visible fd (e.g. 1).
5163        // Closing that fd at scope-end isn't quite right; we need a
5164        // way to send EOF. Solution: track the write_dup we just
5165        // closed; instead keep a second dup for the close-on-end.
5166        let close_on_end = unsafe { libc::dup(fd) };
5167        with_executor(|exec| {
5168            if let Some(top) = exec.multios_scope_stack.last_mut() {
5169                top.push((close_on_end, handle));
5170            } else {
5171                // No scope — leak the dup; thread will keep running
5172                // until process exit. Should not happen because
5173                // host_redirect_scope_begin pushed a frame.
5174                unsafe { libc::close(close_on_end) };
5175            }
5176        });
5177        Value::Status(0)
5178    });
5179    // c:Src/exec.c:2418 input-arm — MULTIOS read fan-in. Stack
5180    // layout pushed by compile_zsh:
5181    //   [source_1, source_2, …, source_N, fd]
5182    // argc = N + 1. Opens every source, sets up a pipe + producer
5183    // thread that reads each source in order and writes to the
5184    // pipe write-end, then closes its write-end so the consumer
5185    // gets EOF. dup2 the pipe read-end onto fd. Bug #36 input
5186    // side in docs/BUGS.md.
5187    vm.register_builtin(BUILTIN_MULTIOS_READ, |vm, argc| {
5188        if argc < 2 {
5189            return Value::Status(1);
5190        }
5191        let fd = vm.pop().to_int() as i32;
5192        let n_sources = (argc - 1) as usize;
5193        let mut sources: Vec<String> = Vec::with_capacity(n_sources);
5194        for _ in 0..n_sources {
5195            sources.push(vm.pop().to_str());
5196        }
5197        sources.reverse();
5198
5199        // Open every source.
5200        let mut source_fds: Vec<i32> = Vec::with_capacity(sources.len());
5201        for path in &sources {
5202            match fs::File::open(path) {
5203                Ok(f) => source_fds.push(f.into_raw_fd()),
5204                Err(e) => {
5205                    let msg = match e.kind() {
5206                        std::io::ErrorKind::PermissionDenied => "permission denied",
5207                        std::io::ErrorKind::NotFound => "no such file or directory",
5208                        _ => "open failed",
5209                    };
5210                    eprintln!("{}:1: {}: {}", shname(), msg, path);
5211                    for prev in &source_fds {
5212                        unsafe {
5213                            libc::close(*prev);
5214                        }
5215                    }
5216                    with_executor(|exec| {
5217                        exec.redirect_failed = true;
5218                    });
5219                    return Value::Status(1);
5220                }
5221            }
5222        }
5223
5224        // Save current fd state for scope-end restoration.
5225        let saved = unsafe { libc::dup(fd) };
5226        if saved >= 0 {
5227            with_executor(|exec| {
5228                if let Some(top) = exec.redirect_scope_stack.last_mut() {
5229                    top.push((fd, saved));
5230                } else {
5231                    unsafe { libc::close(saved) };
5232                }
5233            });
5234        }
5235
5236        // Create the concatenator pipe.
5237        let (read_end, write_end) = match os_pipe::pipe() {
5238            Ok(p) => p,
5239            Err(_) => {
5240                for f in &source_fds {
5241                    unsafe {
5242                        libc::close(*f);
5243                    }
5244                }
5245                return Value::Status(1);
5246            }
5247        };
5248        // dup the pipe read-end onto fd before spawning the
5249        // producer; close the original read_end so the consumer
5250        // (reading via fd) is the sole reference until scope-end.
5251        let read_dup = unsafe { libc::dup(AsRawFd::as_raw_fd(&read_end)) };
5252        drop(read_end);
5253        if read_dup < 0 {
5254            for f in &source_fds {
5255                unsafe {
5256                    libc::close(*f);
5257                }
5258            }
5259            return Value::Status(1);
5260        }
5261        unsafe {
5262            libc::dup2(read_dup, fd);
5263            libc::close(read_dup);
5264        }
5265        // Spawn the producer.
5266        let source_fds_for_thread = source_fds.clone();
5267        let handle = std::thread::spawn(move || {
5268            let mut w = write_end;
5269            let mut buf = [0u8; 8192];
5270            for sfd in source_fds_for_thread {
5271                loop {
5272                    let n = unsafe {
5273                        libc::read(
5274                            sfd,
5275                            buf.as_mut_ptr() as *mut libc::c_void,
5276                            buf.len(),
5277                        )
5278                    };
5279                    if n <= 0 {
5280                        break;
5281                    }
5282                    let n = n as usize;
5283                    if std::io::Write::write_all(&mut w, &buf[..n]).is_err() {
5284                        break;
5285                    }
5286                }
5287                unsafe {
5288                    libc::close(sfd);
5289                }
5290            }
5291            // Closing w (the write_end) at scope drop signals EOF
5292            // to the consumer.
5293        });
5294        with_executor(|exec| {
5295            // Track using a closed-write sentinel — the producer
5296            // owns write_end so we just need to join. Use -1 fd
5297            // marker meaning "no fd to close".
5298            if let Some(top) = exec.multios_scope_stack.last_mut() {
5299                top.push((-1, handle));
5300            } else {
5301                let _ = handle.join();
5302            }
5303        });
5304        Value::Status(0)
5305    });
5306    // c:Src/exec.c — block-level redirect-failure gate. When a
5307    // compound command (`{ … } < file`, `( … ) > file`, etc.) has a
5308    // failing redirect (e.g. `< /nonexistent`), zsh skips the entire
5309    // body AND sets lastval to 1. The simple-command path's
5310    // redirect_failed check (line 215-221 above) only catches the
5311    // failure when a builtin dispatches and is consumed by that
5312    // single builtin call — so a multi-statement block kept running
5313    // its remaining statements after the redir error. Emit-side at
5314    // compile_zsh.rs::compile_command's Redirected arm pairs this
5315    // with a JumpIfTrue → WithRedirectsEnd to abandon the body.
5316    vm.register_builtin(BUILTIN_REDIRECT_FAILED_CHECK, |vm, _argc| {
5317        let failed = with_executor(|exec| {
5318            let f = exec.redirect_failed;
5319            exec.redirect_failed = false;
5320            f
5321        });
5322        if failed {
5323            vm.last_status = 1;
5324            Value::Int(1)
5325        } else {
5326            Value::Int(0)
5327        }
5328    });
5329    // c:Src/exec.c — drop-in replacement for fusevm's Op::Exec used by
5330    // the dynamic-first-word path (`$cmd`, `$(cmd)`, glob-named cmds).
5331    // fusevm's Op::Exec returns Value::Status(0) when post-expansion
5332    // argv is empty (vm.rs:1722) — that clobbers \$? for the
5333    // `\$(exit 1); echo \$?` case where the cmd-subst left
5334    // last_status = 1 but the empty expansion gets exec'd to 0.
5335    // Mirror C zsh: when the word list is empty after expansion,
5336    // \$? becomes whatever the inner cmd-subst's last_status is
5337    // (preserved here by returning Value::Status(last_status)).
5338    vm.register_builtin(BUILTIN_EXEC_DYNAMIC, |vm, argc| {
5339        let raw = pop_args(vm, argc);
5340        // Flatten Array entries into argv slots (matches fusevm
5341        // Op::Exec's flatten at vm.rs:1660-1665) so `${arr[@]}` /
5342        // splice expansions produce one argv slot per element.
5343        let args: Vec<String> = raw.into_iter().collect();
5344        // c:Src/subst.c paramsubst — when `${var:?msg}` or
5345        // `${var?msg}` set errflag, the expansion may produce empty
5346        // argv[0] which would fall into the EACCES/permission-denied
5347        // path below, masking the real paramsubst diagnostic with a
5348        // spurious "permission denied:" line and rc=126. Honour
5349        // errflag so the simple command ends with the paramsubst
5350        // error as the sole diagnostic, rc=1. Bug #86.
5351        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
5352            & crate::ported::zsh_h::ERRFLAG_ERROR)
5353            != 0
5354        {
5355            return Value::Status(1);
5356        }
5357        if args.is_empty() {
5358            // c:Src/exec.c — empty argv preserves prior \$?. The
5359            // cmd-subst inside the word already set last_status; just
5360            // round-trip it back through SetStatus.
5361            return Value::Status(vm.last_status);
5362        }
5363        if args[0].is_empty() {
5364            // Explicit empty command word — exec returns EACCES.
5365            let script_name =
5366                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
5367            let lineno: u64 = with_executor(|exec| {
5368                exec.scalar("LINENO")
5369                    .and_then(|s| s.parse::<u64>().ok())
5370                    .unwrap_or(1)
5371            });
5372            eprintln!("{}:{}: permission denied: ", script_name, lineno);
5373            return Value::Status(126);
5374        }
5375        // c:Src/exec.c:2900 execcmd_exec — canonical simple-command
5376        // dispatcher. Runs precmd-modifier walk (c:3013-3091), then
5377        // dispatches to execbuiltin (c:4233) / runshfunc (c:3431+) /
5378        // execute (c:4314) per the resolved head. zshrs's bytecode VM
5379        // expanded the args before reaching here; we feed them in via
5380        // eparams.args and let execcmd_exec do the rest exactly as C
5381        // does for static heads. Without this, `c=builtin; $c source X`
5382        // skipped the precmd walk and emitted "command not found:
5383        // builtin".
5384        let mut state = crate::ported::zsh_h::estate {
5385            prog: Box::<crate::ported::zsh_h::eprog>::default(),
5386            pc: 0,
5387            strs: None,
5388            strs_offset: 0,
5389        };
5390        let mut eparams = crate::ported::zsh_h::execcmd_params {
5391            args: Some(args),
5392            redir: None,
5393            beg: 0,
5394            varspc: None,
5395            assignspc: None,
5396            typ: crate::ported::zsh_h::WC_SIMPLE as i32,
5397            postassigns: 0,
5398            htok: 0,
5399        };
5400        // input/output=0 → no pipe redirection (use shell stdio
5401        // directly); `output != 0` at c:2988 forks immediately. last1=1
5402        // marks this as the last/only command (no further pipe stages).
5403        crate::ported::exec::execcmd_exec(
5404            &mut state,
5405            &mut eparams,
5406            0,                                    // input  (c:2989)
5407            0,                                    // output (c:2988)
5408            crate::ported::zsh_h::Z_SYNC as i32,  // how
5409            1,                                    // last1=1 last/only
5410            -1,                                   // close_if_forked
5411        );
5412        let status = crate::ported::builtin::LASTVAL
5413            .load(std::sync::atomic::Ordering::Relaxed);
5414        let mut synth = crate::ported::zsh_h::job::default();
5415        crate::ported::jobs::waitonejob(&mut synth);
5416        Value::Status(status)
5417    });
5418    // c:Src/exec.c:3340-3364 — `< file` / `> file` with no command
5419    // word. Resolves NULLCMD/READNULLCMD at runtime then routes
5420    // through host_exec_external. Redirects are already applied by
5421    // the surrounding WithRedirectsBegin scope.
5422    vm.register_builtin(BUILTIN_NULLCMD_EXEC, |vm, argc| {
5423        let args = pop_args(vm, argc);
5424        let is_single_read = args
5425            .first()
5426            .map(|s| s != "0" && !s.is_empty())
5427            .unwrap_or(false);
5428        // c:Src/exec.c — when the surrounding redir-open failed
5429        // (e.g. `< /nonexistent`), zerr already printed the diag
5430        // and set redirect_failed. Don't invoke NULLCMD — return
5431        // status 1 like the wordcode path does.
5432        let redir_failed = with_executor(|exec| {
5433            let f = exec.redirect_failed;
5434            exec.redirect_failed = false;
5435            f
5436        });
5437        if redir_failed {
5438            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
5439            return Value::Status(1);
5440        }
5441        let nullcmd = crate::ported::params::getsparam("NULLCMD");
5442        let nc_str = nullcmd.as_deref().unwrap_or("");
5443        let nc_empty = nc_str.is_empty();
5444        // c:3340-3344 — CSHNULLCMD or no NULLCMD set → diagnostic.
5445        if nc_empty || crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLCMD) {
5446            let script_name =
5447                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
5448            let lineno: u64 = with_executor(|exec| {
5449                exec.scalar("LINENO")
5450                    .and_then(|s| s.parse::<u64>().ok())
5451                    .unwrap_or(1)
5452            });
5453            eprintln!("{}:{}: redirection with no command", script_name, lineno);
5454            return Value::Status(1);
5455        }
5456        // c:3350 — SHNULLCMD → run `:`.
5457        let cmd: String = if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHNULLCMD) {
5458            ":".to_string()
5459        } else if is_single_read {
5460            // c:3354-3359 — single REDIR_READ + READNULLCMD set → readnullcmd.
5461            let rnc = crate::ported::params::getsparam("READNULLCMD");
5462            let rnc_str = rnc.as_deref().unwrap_or("");
5463            if !rnc_str.is_empty() {
5464                rnc_str.to_string()
5465            } else {
5466                nc_str.to_string() // c:3360-3363 fallback
5467            }
5468        } else {
5469            nc_str.to_string() // c:3360-3363
5470        };
5471        let status = with_executor(|exec| exec.host_exec_external(&[cmd]));
5472        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
5473        Value::Status(status)
5474    });
5475    // c:Src/exec.c:3342 — `zerr("redirection with no command")`.
5476    // Bare prefix-keyword (`builtin`, `command`, `exec`, `noglob`,
5477    // `nocorrect`) with a redirect but no command word. Emits the
5478    // canonical diagnostic via zerr (which sets errflag) and
5479    // returns Status(1). Bug #534.
5480    vm.register_builtin(BUILTIN_REDIR_NO_CMD, |_vm, _argc| {
5481        crate::ported::utils::zerr("redirection with no command");
5482        Value::Status(1)
5483    });
5484    vm.register_builtin(BUILTIN_DEBUG_TRAP, |vm, _argc| {
5485        // c:Src/signals.c:1245 dotrap(SIGDEBUG) — fires the DEBUG
5486        // trap body once per statement. The body sees the parent
5487        // shell's $? (LASTVAL). Guard against re-entry: commands
5488        // inside the DEBUG trap body would otherwise trigger
5489        // DEBUG_TRAP recursively → stack overflow. zsh guards via
5490        // its in_trap counter; we mirror with a thread-local Cell.
5491        //
5492        // c:Src/exec.c::trapcmd — before dotrap, the C source sets
5493        // `ZSH_DEBUG_CMD` to the about-to-run command text via
5494        // `dupstring(text)`. The trap body reads the parameter;
5495        // C unsets it after the trap returns. compile_list emits
5496        // the rendered statement text as the single arg here so the
5497        // shell-visible parameter reflects the command. Bug #263 in
5498        // docs/BUGS.md.
5499        let cmd_text = vm.pop().to_str();
5500        DEBUG_TRAP_REENTRY.with(|c| {
5501            if c.get() {
5502                return Value::Status(0);
5503            }
5504            // c:Src/exec.c:1423 — `if (sigtrapped[SIGDEBUG] &&
5505            // isset(DEBUGBEFORECMD) && !intrap)`. Bug #573: without
5506            // this gate, every sublist boundary called
5507            // setsparam("ZSH_DEBUG_CMD", ...) even when no DEBUG trap
5508            // was set, polluting the param table and (under
5509            // WARN_CREATE_GLOBAL) emitting a spurious
5510            // `scalar parameter ZSH_DEBUG_CMD created globally`
5511            // warning at every function call.
5512            //
5513            // Two trap registries exist (per signals.rs:1481-1511 dotrap):
5514            //   - settrap path → sigtrapped[SIGDEBUG] bits set
5515            //   - bin_trap path → traps_table["DEBUG"] populated, sigtrapped untouched
5516            // Mirror the dotrap dispatch decision: skip only when BOTH
5517            // are absent.
5518            let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
5519            let debug_trapped = crate::ported::signals::sigtrapped
5520                .lock()
5521                .map(|v| v.get(sig_debug).copied().unwrap_or(0))
5522                .unwrap_or(0);
5523            let debug_in_table = crate::ported::builtin::traps_table()
5524                .lock()
5525                .map(|t| t.contains_key("DEBUG"))
5526                .unwrap_or(false);
5527            if debug_trapped == 0 && !debug_in_table {
5528                return Value::Status(0);
5529            }
5530            c.set(true);
5531            // c:Src/exec.c — set ZSH_DEBUG_CMD scalar (PM_READONLY
5532            // is NOT set on ZSH_DEBUG_CMD, so the canonical
5533            // setsparam path is fine here — no direct paramtab
5534            // mutation needed).
5535            crate::ported::params::setsparam("ZSH_DEBUG_CMD", &cmd_text);
5536            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGDEBUG);
5537            // c:Src/exec.c::trapcmd — `unsetparam("ZSH_DEBUG_CMD")`
5538            // after the trap returns. Mirror that.
5539            crate::ported::params::unsetparam("ZSH_DEBUG_CMD");
5540            c.set(false);
5541            Value::Status(0)
5542        })
5543    });
5544
5545    vm.register_builtin(BUILTIN_ERREXIT_CHECK, |vm, _argc| {
5546        // Returns Value::Int(1) when the caller should jump to the
5547        // current scope's return-patch landing (subshell-end / func-
5548        // end / chunk-end). Returns Value::Int(0) otherwise. Emit
5549        // side at `emit_errexit_check` pairs this with a JumpIfTrue
5550        // → return_patches pattern so the caller can short-circuit.
5551        //
5552        // Four triggers:
5553        //   1. RETFLAG set by a nested `return` / `exit` (eval,
5554        //      sourced file, called function). Unwind THIS scope so
5555        //      the flag propagates outward until something clears it.
5556        //   2. EXIT_PENDING set (mostly subshell-context exits). Same
5557        //      propagation logic.
5558        //   3. `set -e` + nonzero status — the classic errexit path.
5559        //   4. errflag set in non-interactive mode — readonly
5560        //      reassign, bad redirect, parse error mid-expansion etc.
5561        //      Aborts the script (c:Src/init.c loop()).
5562        use std::sync::atomic::Ordering;
5563        let retflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
5564        let exit_pending = crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed);
5565        if retflag != 0 || exit_pending != 0 {
5566            return Value::Int(1);
5567        }
5568        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
5569            & crate::ported::zsh_h::ERRFLAG_ERROR)
5570            != 0;
5571        if errflag_set && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE) {
5572            // Clear errflag so the abort doesn't keep re-triggering;
5573            // the script-end last_status gives the caller the
5574            // failing status. Update BOTH executor's last_status
5575            // (LASTVAL) AND the VM's last_status so run_chunk's
5576            // post-script sync sees the failing value.
5577            crate::ported::utils::errflag
5578                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
5579            with_executor(|exec| exec.set_last_status(1));
5580            vm.last_status = 1;
5581            // c:Src/init.c loop() — a non-interactive errflag-fired
5582            // abort propagates to the SHELL, not just the current
5583            // function/sourced file. Inside a function, the local
5584            // BUILTIN_ERREXIT_CHECK unwinds the function scope; but
5585            // the caller's next ERREXIT_CHECK only sees errflag if we
5586            // didn't clear it — and we did (above). Set EXIT_PENDING
5587            // so the outer ERREXIT_CHECK at script-level takes the
5588            // EXIT_PENDING arm and aborts. Bug #74 in docs/BUGS.md:
5589            // `f() { local -r x=5; x=10; }; f; echo after` printed
5590            // `after` because errflag-clear above let the script-level
5591            // check see a clean state.
5592            crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
5593            crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
5594            return Value::Int(1);
5595        }
5596        let last = vm.last_status;
5597        if last == 0 {
5598            return Value::Int(0);
5599        }
5600        // c:Src/exec.c:1598 `if (!this_noerrexit && !donetrap &&
5601        // !this_donetrap)` — gate the ZERR trap fire on DONETRAP so
5602        // an inner sublist (e.g. `false` inside a function) that
5603        // already fired ZERR doesn't fire it AGAIN at the outer
5604        // sublist's post-command check (after the function
5605        // returned non-zero). Bug #303 in docs/BUGS.md. DONETRAP
5606        // is reset at top-level statement boundaries via
5607        // BUILTIN_DONETRAP_RESET (compile_list emit at
5608        // compile_zsh.rs).
5609        let already_done =
5610            crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
5611        if !already_done {
5612            // c:Src/signals.c:1245 dotrap(SIGZERR) — canonical ZERR
5613            // trap dispatch. Fires whenever a command exits
5614            // non-zero.
5615            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR);
5616            // c:1602 — `donetrap = 1;` after firing.
5617            crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed);
5618        }
5619        // c:Src/exec.c:1605-1610 — compute errreturn / errexit.
5620        //   errreturn = ERRRETURN && (INTERACTIVE || locallevel || sourcelevel)
5621        //               && !(noerrexit & NOERREXIT_RETURN)
5622        //   errexit   = (ERREXIT || (ERRRETURN && !errreturn))
5623        //               && !(noerrexit & NOERREXIT_EXIT)
5624        let no_err = crate::ported::exec::noerrexit.load(Ordering::Relaxed);
5625        let locallvl = crate::ported::params::locallevel.load(Ordering::Relaxed);
5626        let sourcelvl = crate::ported::init::sourcelevel.load(Ordering::Relaxed);
5627        let errreturn_opt = isset(crate::ported::zsh_h::ERRRETURN);
5628        let in_unwindable_scope = isset(crate::ported::zsh_h::INTERACTIVE)
5629            || locallvl != 0
5630            || sourcelvl != 0;
5631        let errreturn = errreturn_opt
5632            && in_unwindable_scope
5633            && (no_err & crate::ported::zsh_h::NOERREXIT_RETURN) == 0;
5634        if errreturn {
5635            // c:1620-1623 — `retflag = 1; breaks = loops;` — unwind to
5636            // function boundary without exiting the shell.
5637            crate::ported::builtin::RETFLAG.store(1, Ordering::Relaxed);
5638            let loops = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
5639            crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed);
5640            return Value::Int(1);
5641        }
5642        let (errexit_on, in_subshell) = with_executor(|exec| {
5643            let on_canonical = isset(ERREXIT)
5644                || (errreturn_opt && !errreturn); // c:1608-1609
5645            let on_legacy = opt_state_get("errexit").unwrap_or(false);
5646            (
5647                (on_canonical || on_legacy)
5648                    && (no_err & crate::ported::zsh_h::NOERREXIT_EXIT) == 0,
5649                !exec.subshell_snapshots.is_empty(),
5650            )
5651        });
5652        if !errexit_on {
5653            return Value::Int(0);
5654        }
5655        // c:Src/exec.c — `set -e` fires shell exit, NOT a function-only
5656        // unwind. When LOCAL_OPTIONS restores the option mid-fn, the
5657        // restoration would otherwise mask the trigger and let the
5658        // outer scope continue. Setting EXIT_PENDING + EXIT_VAL here
5659        // (for ALL scope kinds, not just subshells) makes the fn-exit
5660        // path propagate to the shell-exit boundary at c:6135-6155.
5661        crate::ported::builtin::EXIT_VAL.store(last, Ordering::Relaxed);
5662        crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
5663        let _ = in_subshell;
5664        // Function scope and top-level scope both branch to their
5665        // respective return_patches; top-level lands at chunk-end,
5666        // so execute_script returns `last` as the script's exit
5667        // status (same observable behavior as a process::exit).
5668        Value::Int(1)
5669    });
5670
5671    // `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
5672    // Pops [name, op_byte, rhs] (rhs popped first). Returns the modified
5673    // value as Value::Str. Handles unset/empty distinction (`:-` etc.
5674    // treat empty same as unset, matching POSIX).
5675    // BUILTIN_PARAM_DEFAULT_FAMILY — `${var-x}` / `${var:-x}` / `${var=x}` /
5676    // `${var:=x}` / `${var?x}` / `${var:?x}` / `${var+x}` / `${var:+x}`.
5677    // PURE PASSTHRU: pop name + op + rhs, reconstruct the canonical
5678    // brace expression, hand to `subst::paramsubst` (C port of
5679    // `Src/subst.c::paramsubst`). All "missing vs empty" gating,
5680    // nounset suppression, default-evaluation, and elide-empty-words
5681    // semantics live inside paramsubst.
5682    vm.register_builtin(BUILTIN_PARAM_DEFAULT_FAMILY, |vm, _argc| {
5683        let rhs = vm.pop().to_str();
5684        let op = vm.pop().to_int() as u8;
5685        let name = vm.pop().to_str();
5686        // op=8 is the `${+name}` set-test prefix form (distinct from the
5687        // `${name+rhs}` substitute-if-set suffix form which is op=7).
5688        // Per compile_zsh.rs::parse_param_modifier: the `+` is emitted as
5689        // a leading sigil and `rhs` is empty.
5690        let body = if op == 8 {
5691            format!("${{+{}}}", name)
5692        } else {
5693            let op_str = match op {
5694                0 => ":-",
5695                1 => ":=",
5696                2 => ":?",
5697                3 => ":+",
5698                4 => "-",
5699                5 => "=",
5700                6 => "?",
5701                7 => "+",
5702                _ => "-",
5703            };
5704            format!("${{{}{}{}}}", name, op_str, rhs)
5705        };
5706        paramsubst_to_value(&body)
5707    });
5708
5709    // `${var:offset[:length]}` — substring. Pops [name, offset, length].
5710    // length == -1 means "rest of string". Negative offset counts from end.
5711    // BUILTIN_PARAM_SUBSTRING — `${var:offset:length}` literal-int form.
5712    // PURE PASSTHRU: reconstruct `${name:offset:length}` and route
5713    // through `subst::paramsubst`. Length sentinel `i64::MIN` =
5714    // "no length given" (omit the `:length` portion).
5715    //
5716    // c:Src/subst.c:1571,3781 — `${name:-N}` is the colon-default
5717    // operator, NOT a substring with negative offset. zsh's lexical
5718    // rule disambiguates via a literal space: `${name: -N}` (space
5719    // before `-`) is the substring form. The reconstructed body MUST
5720    // preserve that space when offset < 0; otherwise paramsubst's
5721    // `:-` dispatch fires on the synthesized `${name:-N}` body and
5722    // returns N as the unset-default instead of slicing the last N
5723    // chars. Length-form `${name:-N:M}` has the same trap.
5724    vm.register_builtin(BUILTIN_PARAM_SUBSTRING, |vm, _argc| {
5725        let length = vm.pop().to_int();
5726        let offset = vm.pop().to_int();
5727        let name = vm.pop().to_str();
5728        let off_sep = if offset < 0 { " " } else { "" };
5729        let body = if length == i64::MIN {
5730            format!("${{{}:{}{}}}", name, off_sep, offset)
5731        } else {
5732            format!("${{{}:{}{}:{}}}", name, off_sep, offset, length)
5733        };
5734        paramsubst_to_value(&body)
5735    });
5736
5737    // BUILTIN_PARAM_SUBSTRING_EXPR — `${var:offset_expr[:length_expr]}` form.
5738    // PURE PASSTHRU: rebuild `${name:offset:length}` using the
5739    // expression text verbatim (paramsubst's offset/length
5740    // parser evaluates arith / param refs itself).
5741    //
5742    // c:Src/subst.c:1571,3781 — same `:-` disambiguation trap as
5743    // BUILTIN_PARAM_SUBSTRING. The expression text may itself start
5744    // with `-` (e.g. `${VAR:$((-1))}` arith resolves at the body-
5745    // assembly layer in some upstream paths, leaving `-1` in
5746    // off_expr). Insert a leading space when off_expr starts with
5747    // `-` so paramsubst's check_colon_subscript (subst.c:1571)
5748    // accepts the operand as a math expression instead of the
5749    // `:-` operator catching it.
5750    vm.register_builtin(BUILTIN_PARAM_SUBSTRING_EXPR, |vm, _argc| {
5751        let has_len = vm.pop().to_int() != 0;
5752        let len_expr = vm.pop().to_str();
5753        let off_expr = vm.pop().to_str();
5754        let name = vm.pop().to_str();
5755        let off_sep = if off_expr.starts_with('-') { " " } else { "" };
5756        let body = if has_len {
5757            format!("${{{}:{}{}:{}}}", name, off_sep, off_expr, len_expr)
5758        } else {
5759            format!("${{{}:{}{}}}", name, off_sep, off_expr)
5760        };
5761        paramsubst_to_value(&body)
5762    });
5763
5764    // `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}`
5765    // Pops [name, pattern, op_byte]. op: 0=`#` short-prefix, 1=`##` long,
5766    // 2=`%` short-suffix, 3=`%%` long. Glob-pattern matching via the
5767    // existing glob_match_static helper.
5768    // BUILTIN_PARAM_STRIP — `${var#pat}` / `${var##pat}` / `${var%pat}` /
5769    // `${var%%pat}`. PURE PASSTHRU: reconstruct the brace expression
5770    // and route through `subst::paramsubst`. (M)/(S) flags arrive
5771    // through SUB_FLAGS (already inside paramsubst's scope), so we
5772    // just clear the bridge-side cached read.
5773    vm.register_builtin(BUILTIN_PARAM_STRIP, |vm, _argc| {
5774        let _dq_flag = vm.pop().to_int() != 0;
5775        let op = vm.pop().to_int() as u8;
5776        let pattern = vm.pop().to_str();
5777        let name = vm.pop().to_str();
5778        let op_str = match op {
5779            0 => "#",
5780            1 => "##",
5781            2 => "%",
5782            3 => "%%",
5783            _ => "#",
5784        };
5785        let body = format!("${{{}{}{}}}", name, op_str, pattern);
5786        paramsubst_to_value(&body)
5787    });
5788
5789    // `$((expr))` — pops [expr_string], evaluates via MathEval which
5790    // honors integer-vs-float distinction (zsh-compatible). Returns
5791    // the result as Value::Str so it can be Concat'd into surrounding
5792    // word context.
5793    vm.register_builtin(BUILTIN_ARITH_EVAL, |vm, _argc| {
5794        // Pure path: evaluate expr, return string. errflag may be
5795        // set by arithsubst on math error; the caller decides
5796        // whether to clear it. For `(( ... ))` (math command) the
5797        // compile_arith path clears via BUILTIN_ARITH_CMD_FINISH;
5798        // for `$((... ))` (substitution inside another command)
5799        // errflag stays set so the surrounding command aborts —
5800        // matches c:Src/math.c "math errors propagate as errflag
5801        // through the containing word expansion".
5802        let expr = vm.pop().to_str();
5803        let result = crate::ported::subst::arithsubst(&expr, "", "");
5804        let _ = vm; // silence unused warning when no math error path mutates
5805        Value::str(result)
5806    });
5807
5808    // After-call hook used by compile_arith's `(( ... ))` path: when
5809    // arithsubst set errflag (math error), clear it and signal
5810    // status=2 in vm.last_status — matches zsh's c:exec.c arith-
5811    // failure: the math command exits 2 and the script continues.
5812    vm.register_builtin(BUILTIN_ARITH_CMD_FINISH, |vm, _argc| {
5813        use std::sync::atomic::Ordering;
5814        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
5815        let err = live & crate::ported::zsh_h::ERRFLAG_ERROR;
5816        let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
5817        if err != 0 {
5818            // c:Src/subst.c:3344 — when `${var:?msg}` fires, errflag
5819            // is OR'd with ERRFLAG_HARD to signal a script-abort
5820            // error (vs a recoverable math error like `$((1/0))`).
5821            // Clear only the ERRFLAG_ERROR bit; preserve
5822            // ERRFLAG_HARD so the next ERREXIT_CHECK aborts the
5823            // script. Bug #193 in docs/BUGS.md.
5824            if hard != 0 {
5825                // Keep ERRFLAG_HARD AND ERRFLAG_ERROR set so the
5826                // script-abort gate downstream still fires.
5827                vm.last_status = 2;
5828                Value::Status(2)
5829            } else {
5830                crate::ported::utils::errflag
5831                    .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
5832                vm.last_status = 2;
5833                Value::Status(2)
5834            }
5835        } else {
5836            Value::Status(vm.last_status)
5837        }
5838    });
5839
5840    // `$(cmd)` — pops [cmd_string], routes through
5841    // run_command_substitution which performs an in-process pipe-capture.
5842    // Avoids the Op::CmdSubst sub-chunk word-emit bug
5843    // (`printf "a\nb"` produced "anb" via that path). Returns trimmed
5844    // output (trailing newlines stripped per POSIX cmd-sub semantics).
5845    vm.register_builtin(BUILTIN_CMD_SUBST_TEXT, |vm, _argc| {
5846        let cmd = vm.pop().to_str();
5847        // Inherit live $? into the inner shell so cmd-subst sees the
5848        // parent's most recent exit. Same rationale as the mode-3
5849        // backtick path above.
5850        let live_status = vm.last_status;
5851        let result = with_executor(|exec| {
5852            exec.set_last_status(live_status);
5853            exec.run_command_substitution(&cmd)
5854        });
5855        // Mirror run_command_substitution's exec.last_status side
5856        // effect into the VM's live counter so a containing
5857        // assignment's BUILTIN_SET_VAR — which reads vm.last_status
5858        // — sees the cmd-subst's exit. Without this, `a=$(false);
5859        // echo $?` reads stale 0 (vm.last_status was zeroed by
5860        // compile_assign's prelude SetStatus, and run_cmd_subst only
5861        // updated exec.last_status). Pull the value back through
5862        // exec since it owns the canonical post-subst record.
5863        let cs_status = with_executor(|exec| exec.last_status());
5864        vm.last_status = cs_status;
5865        Value::str(result)
5866    });
5867
5868    // Text-based word expansion. Pops [preserved_text, mode_byte].
5869    // mode_byte:
5870    //   0 = Default — expand_string + xpandbraces + expand_glob
5871    //   1 = DoubleQuoted — strip outer `"…"`, expand_string only
5872    //         (no brace, no glob — DQ semantics)
5873    //   2 = SingleQuoted — strip outer `'…'`, no expansion
5874    //         (kept for symmetry; Snull early-return covers most SQ)
5875    //   3 = AltBackquote — strip backticks, run as cmd-sub
5876    // Single result → Value::str; multi → Value::Array.
5877    vm.register_builtin(BUILTIN_EXPAND_TEXT, |vm, _argc| {
5878        let mode = vm.pop().to_int() as u8;
5879        let text = vm.pop().to_str();
5880        // Sync vm.last_status → exec.last_status so cmd-subst (mode 3)
5881        // and any nested $? reads inside singsub see the live `$?`
5882        // from the most recent VM op. Without this, cmd-subst inside
5883        // arg-eval saw a stale exec.last_status that was zeroed at
5884        // the start of the current statement. Direct port of zsh's
5885        // pre-cmdsubst lastval propagation per Src/exec.c:4770.
5886        let live_status = vm.last_status;
5887        with_executor(|exec| exec.set_last_status(live_status));
5888        let result_value = with_executor(|exec| match mode {
5889            // Mode 1 = DoubleQuoted (argument context).
5890            // Mode 5 = DoubleQuoted in scalar-assignment context.
5891            // Both share the same DQ unescape pre-processing; mode 5
5892            // additionally bumps `in_scalar_assign` so subst_port's
5893            // paramsubst sees ssub=true and suppresses split flags
5894            // `(f)` / `(s:STR:)` / `(0)` per Src/subst.c:1759 +
5895            // Src/exec.c::addvars line 2546 (the PREFORK_SINGLE bit
5896            // C zsh sets when prefork-ing the assignment RHS).
5897            1 | 5 => {
5898                // DoubleQuoted: strip outer `"…"` if present. In DQ
5899                // context, `\` escapes the DQ-special chars `$`, `` ` ``,
5900                // `"`, `\`. zsh's expand_string expects the lexer's
5901                // `\0X` literal-marker for an already-escaped char, so
5902                // we pre-process: `\$` → `\0$`, `\\` → `\0\`, etc. Then
5903                // expand_string handles the rest.
5904                let inner = if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
5905                    &text[1..text.len() - 1]
5906                } else {
5907                    text.as_str()
5908                };
5909                // The lexer's dquote_parse (Src/lex.c) already tokenized
5910                // DQ contents: `$` → Qstring (\u{8c}), `\$`/`\\`/`\"`/
5911                // `` \` `` → Bnull (\u{9f}) + literal. Stringsubst /
5912                // multsub recognize these markers natively. We pass
5913                // `inner` through verbatim — no re-tokenization needed.
5914                let prepped: String = inner.to_string();
5915                // Tell parameter-flag application that we're inside
5916                // double quotes — array-only flags ((o), (O), (n),
5917                // (i), (M), (u)) must be no-ops here per zsh.
5918                exec.in_dq_context += 1;
5919                if mode == 5 {
5920                    exec.in_scalar_assign += 1;
5921                }
5922                // Mode 1 = argv DQ word; mode 5 = scalar-assign RHS.
5923                // In C zsh, the corresponding prefork-on-list paths
5924                // are: argv → `prefork(argv_list, 0)` returns multi-
5925                // word LinkList (Src/exec.c::execcmd), assignment →
5926                // `prefork(rhs_list, PREFORK_SINGLE|PREFORK_ASSIGN)`
5927                // returns single-word (Src/exec.c::addvars line
5928                // 2546). zshrs's `multsub` (Src/subst.c:544) is the
5929                // multi-result variant; `singsub` (Src/subst.c:514)
5930                // asserts ≤1 node. Mode 5 keeps singsub; mode 1
5931                // switches to multsub so `"${(@)arr}"`/`"$@"`/
5932                // `"${arr[@]}"` in argv context emit multiple words
5933                // as the C path would.
5934                let result_value = if mode == 5 {
5935                    let out = crate::ported::subst::singsub(&prepped);
5936                    Value::str(out)
5937                } else {
5938                    let (_first, nodes, _ms_ws, _ret) = crate::ported::subst::multsub(&prepped, 0);
5939                    // c:Src/subst.c:655 — multsub returns Vec::new()
5940                    // for zero-word results (quoted array splat that
5941                    // resolved to empty array). Surface as
5942                    // Value::Array(vec![]) so the downstream array
5943                    // assignment / argv flattening sees ZERO args.
5944                    // Previous Rust port returned Value::str("") which
5945                    // surfaced as ONE empty arg. Bug #120 in
5946                    // docs/BUGS.md.
5947                    if nodes.is_empty() {
5948                        Value::Array(Vec::new())
5949                    } else if nodes.len() == 1 {
5950                        Value::str(nodes.into_iter().next().unwrap())
5951                    } else {
5952                        Value::Array(nodes.into_iter().map(Value::str).collect())
5953                    }
5954                };
5955                if mode == 5 {
5956                    exec.in_scalar_assign -= 1;
5957                }
5958                exec.in_dq_context -= 1;
5959                result_value
5960            }
5961            2 => {
5962                // SingleQuoted: pure literal, strip outer `'…'`.
5963                let inner = if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
5964                    &text[1..text.len() - 1]
5965                } else {
5966                    text.as_str()
5967                };
5968                Value::str(inner.to_string())
5969            }
5970            3 => {
5971                // Backquote command sub: strip outer backticks.
5972                // Word-split the result on IFS when the surrounding
5973                // word is unquoted — zsh: `print -l \`echo a b c\``
5974                // emits one arg per word. The $(…) path applies the
5975                // same split via BUILTIN_WORD_SPLIT after capture; do
5976                // the equivalent here for the `…` form.
5977                let inner = if text.len() >= 2 && text.starts_with('`') && text.ends_with('`') {
5978                    &text[1..text.len() - 1]
5979                } else {
5980                    text.as_str()
5981                };
5982                // Apply the live VM status before running the inner
5983                // shell so the inherited $? matches zsh's lastval
5984                // propagation.
5985                exec.set_last_status(live_status);
5986                let captured = exec.run_command_substitution(inner);
5987                let trimmed = captured.trim_end_matches('\n');
5988                if exec.in_dq_context > 0 {
5989                    Value::str(trimmed.to_string())
5990                } else {
5991                    let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
5992                    let parts: Vec<Value> = trimmed
5993                        .split(|c: char| ifs.contains(c))
5994                        .filter(|s| !s.is_empty())
5995                        .map(|s| Value::str(s.to_string()))
5996                        .collect();
5997                    if parts.is_empty() {
5998                        Value::str(String::new())
5999                    } else if parts.len() == 1 {
6000                        parts.into_iter().next().unwrap()
6001                    } else {
6002                        Value::Array(parts)
6003                    }
6004                }
6005            }
6006            4 => {
6007                // HeredocBody: expand variables / command-subst / arith
6008                // but NOT glob or brace. Heredoc lines like `[42]` must
6009                // pass through verbatim — running them through the
6010                // default pipeline triggers NOMATCH on the literal.
6011                Value::str(crate::ported::subst::singsub(&text))
6012            }
6013            _ => {
6014                // Default (unquoted): the lexer's gettokstr already
6015                // tokenized backslash-escapes (`\$` → Bnull+$, etc).
6016                // Pass `text` through verbatim — multsub/stringsubst
6017                // recognize the markers natively. No bridge-side
6018                // re-tokenization needed.
6019                //
6020                // Mode 6 = unquoted RHS in scalar-assign context.
6021                // Pass PREFORK_ASSIGN so prefork's filesub colon-walk
6022                // fires per c:Src/exec.c:2546.
6023                let prepped: String = text.clone();
6024                if std::env::var("ZSHRS_TRACE_DEFP").is_ok() {
6025                    eprintln!(
6026                        "[TRACE_DEFP] text={:?} prepped={:?} mode={}",
6027                        text, prepped, mode
6028                    );
6029                }
6030                let pf_flags = if mode == 6 {
6031                    crate::ported::zsh_h::PREFORK_ASSIGN
6032                } else {
6033                    0
6034                };
6035                // c:Src/subst.c:544+ — `multsub(&prepped, 0)` is the
6036                // unquoted-argv equivalent of zsh's `prefork(list,
6037                // 0, NULL)` for a single-element list. Returns the
6038                // post-expansion node list (Vec<String>) so array-
6039                // shape results (e.g. `${a:e}`, `${a[@]}`,
6040                // `${(s::)str}`) splat into multiple argv words.
6041                // singsub() collapses to one string and discards the
6042                // splat — parity bug #28 (whole-array modifier).
6043                let (_first, nodes, _ms_ws, _ret) =
6044                    crate::ported::subst::multsub(&prepped, pf_flags);
6045                if std::env::var("ZSHRS_TRACE_MULTSUB").is_ok() {
6046                    eprintln!("[TRACE_MULTSUB] prepped={:?} nodes={:?}", prepped, nodes);
6047                }
6048                // c:Src/subst.c:166 — xpandbraces runs AFTER prefork's
6049                // substitution pass and BEFORE untokenize/glob. Per
6050                // word, scan for Inbrace TOKEN and expand. Words that
6051                // don't contain Inbrace TOKEN pass through unchanged.
6052                // Brace expansion is done here (inside the bridge
6053                // default arm) instead of via a post-EXPAND_TEXT
6054                // BRACE_EXPAND emit because untokenize (line below)
6055                // strips TOKEN bytes, after which the strict-TOKEN
6056                // xpandbraces gate would no longer match.
6057                let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
6058                // c:Src/options.c — `no_brace_expand` (negated
6059                // `braceexpand`) gates brace expansion entirely.
6060                // When off, `{a,b}` stays literal.
6061                let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
6062                let pre_brace: Vec<String> = if nodes.is_empty() {
6063                    vec![String::new()]
6064                } else {
6065                    nodes
6066                };
6067                let brace_expanded: Vec<String> = pre_brace
6068                    .into_iter()
6069                    .flat_map(|w| {
6070                        if brace_expand && w.contains('\u{8f}') {
6071                            crate::ported::glob::xpandbraces(&w, brace_ccl)
6072                        } else {
6073                            vec![w]
6074                        }
6075                    })
6076                    .collect();
6077                // zsh stores the option as `glob` (default ON);
6078                // `setopt noglob` writes `glob=false`. Honor either
6079                // form so the dispatcher behaves the same as zsh.
6080                let noglob = opt_state_get("noglob").unwrap_or(false)
6081                    || opt_state_get("GLOB").map(|v| !v).unwrap_or(false)
6082                    || !opt_state_get("glob").unwrap_or(true);
6083                let parts: Vec<String> = brace_expanded
6084                    .into_iter()
6085                    .flat_map(|s| {
6086                        // The lexer leaves glob metacharacters in their
6087                        // META-encoded form: `*` → `\u{87}`, `?` →
6088                        // `\u{86}`, `[` → `\u{91}`, etc. expand_string
6089                        // doesn't untokenize them, so the literal-char
6090                        // checks below (`s.contains('*')`) would miss
6091                        // every real glob and skip expand_glob — that
6092                        // bug let `echo *.toml` print the literal
6093                        // `*.toml` because the META `\u{87}` never
6094                        // matched the literal `*`. Untokenize once so
6095                        // the metacharacter checks see the canonical
6096                        // form. zsh's pattern.c expects `*` etc. as
6097                        // bare chars at the glob layer.
6098                        // c:Src/pattern.c:4306 haswilds — token-only
6099                        // gate matching C verbatim. C's haswilds checks
6100                        // ONLY the META-TOKEN codes (Inpar `\u{88}`,
6101                        // Bar `\u{89}`, Star `\u{87}`, Inbrack `\u{91}`,
6102                        // Inang `\u{94}`, Quest `\u{86}`, Pound `\u{84}`
6103                        // /EXTENDEDGLOB, Hat `\u{8a}`/EXTENDEDGLOB),
6104                        // never their literal ASCII counterparts. The
6105                        // lexer tokenizes source-level `[abc]` →
6106                        // Inbrack/Outbrack, source-level `*.toml` → Star
6107                        // — those reach haswilds with tokens and fire.
6108                        // Bare literal `[`/`*`/`?` from `$'...'` decode,
6109                        // `:-` default values, or variable expansion
6110                        // never get shtokenize'd (C subst.c:3231 sets
6111                        // globsubst=0 in the `:-` arm) so haswilds
6112                        // skips them. Bug #625: `${${X}:-$'\e[hi]'}`
6113                        // returned bare literal `[hi]` from the nested
6114                        // paramsubst; the previous post-untokenize
6115                        // haswilds saw literal `[` and globbed → NOMATCH
6116                        // fired. The TOKEN-only check has to happen
6117                        // PRE-untokenize so source-level Star tokens
6118                        // (`*.toml`) survive while substituted bare
6119                        // glob chars stay literal.
6120                        let is_glob_pre = if noglob {
6121                            false
6122                        } else {
6123                            use crate::ported::zsh_h::{
6124                                isset, Bar, Bang, EXTENDEDGLOB, Hat, Inang, Inbrack, Inpar,
6125                                KSHGLOB, Outbrack, Pound, Quest, SHGLOB, Star,
6126                            };
6127                            // c:Src/pattern.c:4310-4312 — single-byte
6128                            // bare Inbrack/Outbrack is legal pattern.
6129                            let bytes = s.as_bytes();
6130                            let len = bytes.len();
6131                            let single_bracket = len == 1
6132                                && (bytes[0] == Inbrack as u8 || bytes[0] == Outbrack as u8);
6133                            // c:4317-4318 — `%?foo` job-ref skip.
6134                            let skip_pos_1 = len >= 2
6135                                && bytes[0] == b'%'
6136                                && bytes[1] == Quest as u8;
6137                            let mut found = false;
6138                            if !single_bracket {
6139                                let disp = crate::ported::pattern::zpc_disables
6140                                    .lock()
6141                                    .unwrap();
6142                                for i in 0..len {
6143                                    if skip_pos_1 && i == 1 {
6144                                        continue;
6145                                    }
6146                                    let b = bytes[i];
6147                                    // c:4326-4335 Inpar — KSHGLOB
6148                                    // prev-char gating mirrors C.
6149                                    if b == Inpar as u8 {
6150                                        let prev = if i > 0 { bytes[i - 1] } else { 0 };
6151                                        if (!isset(SHGLOB)
6152                                            && disp[crate::ported::zsh_h::ZPC_INPAR as usize] == 0)
6153                                            || (i > 0
6154                                                && isset(KSHGLOB)
6155                                                && ((prev == Quest as u8
6156                                                    && disp[crate::ported::zsh_h::ZPC_KSH_QUEST as usize] == 0)
6157                                                    || (prev == Star as u8
6158                                                        && disp[crate::ported::zsh_h::ZPC_KSH_STAR as usize] == 0)
6159                                                    || (prev == b'+'
6160                                                        && disp[crate::ported::zsh_h::ZPC_KSH_PLUS as usize] == 0)
6161                                                    || (prev == Bang as u8
6162                                                        && disp[crate::ported::zsh_h::ZPC_KSH_BANG as usize] == 0)
6163                                                    || (prev == b'!'
6164                                                        && disp[crate::ported::zsh_h::ZPC_KSH_BANG2 as usize] == 0)
6165                                                    || (prev == b'@'
6166                                                        && disp[crate::ported::zsh_h::ZPC_KSH_AT as usize] == 0)))
6167                                        {
6168                                            found = true;
6169                                            break;
6170                                        }
6171                                    } else if b == Bar as u8 {
6172                                        if disp[crate::ported::zsh_h::ZPC_BAR as usize] == 0 {
6173                                            found = true;
6174                                            break;
6175                                        }
6176                                    } else if b == Star as u8 {
6177                                        if disp[crate::ported::zsh_h::ZPC_STAR as usize] == 0 {
6178                                            found = true;
6179                                            break;
6180                                        }
6181                                    } else if b == Inbrack as u8 {
6182                                        if disp[crate::ported::zsh_h::ZPC_INBRACK as usize] == 0 {
6183                                            found = true;
6184                                            break;
6185                                        }
6186                                    } else if b == Inang as u8 {
6187                                        if disp[crate::ported::zsh_h::ZPC_INANG as usize] == 0 {
6188                                            found = true;
6189                                            break;
6190                                        }
6191                                    } else if b == Quest as u8 {
6192                                        if disp[crate::ported::zsh_h::ZPC_QUEST as usize] == 0 {
6193                                            found = true;
6194                                            break;
6195                                        }
6196                                    } else if b == Pound as u8 {
6197                                        if isset(EXTENDEDGLOB)
6198                                            && disp[crate::ported::zsh_h::ZPC_HASH as usize] == 0
6199                                        {
6200                                            found = true;
6201                                            break;
6202                                        }
6203                                    } else if b == Hat as u8 {
6204                                        if isset(EXTENDEDGLOB)
6205                                            && disp[crate::ported::zsh_h::ZPC_HAT as usize] == 0
6206                                        {
6207                                            found = true;
6208                                            break;
6209                                        }
6210                                    }
6211                                }
6212                            }
6213                            found
6214                        };
6215                        let s = crate::lex::untokenize(&s);
6216                        // Skip glob expansion for assignment-shaped
6217                        // words (`NAME=value`). zsh doesn't expand the
6218                        // RHS of an assignment as a path glob unless
6219                        // `setopt globassign` is set, and feeding such
6220                        // words through expand_glob makes NOMATCH
6221                        // (default ON) fire spuriously on
6222                        // `integer i=2*3+1`, `path=*.rs`, etc.
6223                        let is_assignment_shape = {
6224                            let bytes = s.as_bytes();
6225                            let mut i = 0;
6226                            if !bytes.is_empty()
6227                                && (bytes[0] == b'_' || bytes[0].is_ascii_alphabetic())
6228                            {
6229                                i += 1;
6230                                while i < bytes.len()
6231                                    && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric())
6232                                {
6233                                    i += 1;
6234                                }
6235                                i < bytes.len() && bytes[i] == b'='
6236                            } else {
6237                                false
6238                            }
6239                        };
6240                        // Glob-trigger decision: pre-untokenize
6241                        // haswilds_tokens_only result (computed above
6242                        // before the untokenize that collapses META
6243                        // tokens to their ASCII forms). The TOKEN-only
6244                        // gate matches C `Src/pattern.c:4306-4376`
6245                        // exactly — only Inbrack/Star/Quest/Inpar/Bar/
6246                        // Inang/Pound/Hat token codes count as wild,
6247                        // not their literal ASCII counterparts. Source-
6248                        // level `*.toml` carries Star token so globs;
6249                        // `$'…'`-decoded `[abc]` carries bare `[` so
6250                        // stays literal. Bug #625.
6251                        if is_glob_pre && !is_assignment_shape {
6252                            exec.expand_glob(&s)
6253                        } else {
6254                            vec![s]
6255                        }
6256                    })
6257                    .collect();
6258                if parts.len() == 1 {
6259                    let only = parts.into_iter().next().unwrap_or_default();
6260                    // Empty unquoted expansion → drop the arg entirely
6261                    // (zsh "remove empty unquoted words" rule). Returning
6262                    // an empty Value::Array makes pop_args contribute zero
6263                    // items. Direct port of subst.c's empty-elide pass at
6264                    // the end of multsub which removes empty linknodes
6265                    // from unquoted contexts. Quoted DQ/SQ paths (modes
6266                    // 1/2/5) take separate arms above and always emit
6267                    // Value::Str so the empty arg survives.
6268                    if only.is_empty() {
6269                        Value::Array(Vec::new())
6270                    } else {
6271                        Value::str(only)
6272                    }
6273                } else {
6274                    Value::Array(parts.into_iter().map(Value::str).collect())
6275                }
6276            }
6277        });
6278        // Pull any inner cmd-subst (`` `cmd` `` via mode 3 or via
6279        // mode 0/6 multsub → getoutput, `$(cmd)` via the default
6280        // arm's multsub path, nested `$()`s reached through
6281        // stringsubst) back into vm.last_status so a containing
6282        // assignment's BUILTIN_SET_VAR — which reads vm.last_status —
6283        // sees the cmd-subst's exit. Without this, backtick
6284        // assignments (`a=\`false\`; echo $?`) reported 0 because the
6285        // ported LASTVAL update never reached the VM-side counter.
6286        let cs_status = with_executor(|exec| exec.last_status());
6287        vm.last_status = cs_status;
6288        result_value
6289    });
6290
6291    // `${#name}` — pops [name]. Returns the value's element count for
6292    // arrays (indexed and assoc) or character length for scalars.
6293    // BUILTIN_PARAM_LENGTH — `${#name}`. PURE PASSTHRU.
6294    vm.register_builtin(BUILTIN_PARAM_LENGTH, |vm, _argc| {
6295        let name = vm.pop().to_str();
6296        // PARAM_LENGTH's empty-result semantics differ from
6297        // paramsubst_to_value: 0 nodes → "0" (numeric length), not
6298        // empty array. paramsubst on `${#X}` always returns at least
6299        // one node in practice (the length string); the empty case
6300        // is defensive.
6301        let mut ret_flags: i32 = 0;
6302        let (_full, _pos, nodes) = crate::ported::subst::paramsubst(
6303            &format!("${{#{}}}", name),
6304            0,
6305            false,
6306            0i32,
6307            &mut ret_flags,
6308        );
6309        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
6310            with_executor(|exec| exec.set_last_status(1));
6311        }
6312        if nodes.is_empty() {
6313            Value::str("0")
6314        } else {
6315            nodes_to_value(nodes)
6316        }
6317    });
6318
6319    // `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
6320    // `${var/%pat/repl}` — Pops [name, pattern, replacement, op_byte].
6321    // op: 0=first, 1=all, 2=anchor-prefix (`/#`), 3=anchor-suffix (`/%`).
6322    // BUILTIN_PARAM_REPLACE — `${var/pat/repl}` / `${var//pat/repl}` /
6323    // `${var/#pat/repl}` / `${var/%pat/repl}`. PURE PASSTHRU.
6324    vm.register_builtin(BUILTIN_PARAM_REPLACE, |vm, _argc| {
6325        let _dq_flag = vm.pop().to_int() != 0;
6326        let op = vm.pop().to_int() as u8;
6327        let repl = vm.pop().to_str();
6328        let pattern = vm.pop().to_str();
6329        let name = vm.pop().to_str();
6330        // op encoding: 0 = first `/`, 1 = all `//`, 2 = anchor-prefix
6331        // `/#`, 3 = anchor-suffix `/%`. The brace form distinguishes
6332        // first-vs-all by single vs doubled slash, and anchored by
6333        // a `#` or `%` immediately after the slash(es).
6334        let body = match op {
6335            0 => format!("${{{}/{}/{}}}", name, pattern, repl),
6336            1 => format!("${{{}//{}/{}}}", name, pattern, repl),
6337            2 => format!("${{{}/#{}/{}}}", name, pattern, repl),
6338            3 => format!("${{{}/%{}/{}}}", name, pattern, repl),
6339            _ => format!("${{{}/{}/{}}}", name, pattern, repl),
6340        };
6341        paramsubst_to_value(&body)
6342    });
6343
6344    vm.register_builtin(BUILTIN_REGISTER_COMPILED_FN, |vm, argc| {
6345        let args = pop_args(vm, argc);
6346        let mut iter = args.into_iter();
6347        let name = iter.next().unwrap_or_default();
6348        let body_b64 = iter.next().unwrap_or_default();
6349        let body_source = iter.next().unwrap_or_default();
6350        let line_base_str = iter.next().unwrap_or_default();
6351        let line_base: i64 = line_base_str.parse().unwrap_or(0);
6352        let bytes = base64_decode(&body_b64);
6353        let status = match bincode::deserialize::<fusevm::Chunk>(&bytes) {
6354            Ok(chunk) => with_executor(|exec| {
6355                // c:Src/exec.c:5383 — `shf->filename =
6356                // ztrdup(scriptfilename);` — the function's
6357                // definition-file is read from the canonical
6358                // file-scope `scriptfilename` global at compile
6359                // time, NOT from a per-executor struct field.
6360                // exec.scriptfilename is seeded once at
6361                // bins/zshrs.rs:1717 to the bin basename ("zsh")
6362                // and never updates on source/dot, so reading from
6363                // it left every user function's def_file as "zsh".
6364                // Route through scriptfilename_get() so source /
6365                // dot's set_scriptfilename calls propagate.
6366                let def_file = crate::ported::utils::scriptfilename_get()
6367                    .or_else(|| exec.scriptfilename.clone());
6368                if !body_source.is_empty() {
6369                    exec.function_source
6370                        .insert(name.clone(), body_source.clone());
6371                }
6372                exec.function_line_base.insert(name.clone(), line_base);
6373                exec.function_def_file.insert(name.clone(), def_file);
6374                // PFA-SMR aspect: every `name() {}` / `function name { }`
6375                // funnels through here at compile time. Emit one record
6376                // with the function name + raw body source.
6377                #[cfg(feature = "recorder")]
6378                if crate::recorder::is_enabled() {
6379                    let ctx = exec.recorder_ctx();
6380                    let body = if body_source.is_empty() {
6381                        None
6382                    } else {
6383                        Some(body_source.as_str())
6384                    };
6385                    crate::recorder::emit_function(&name, body, ctx);
6386                }
6387                // Mirror into canonical shfunctab so scanfunctions /
6388                // ${(k)functions} / functions builtin see user defs.
6389                // C: exec.c:funcdef → shfunctab->addnode(ztrdup(name),shf).
6390                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
6391                    let mut shf = crate::ported::hashtable::shfunc_with_body(&name, &body_source);
6392                    // c:Src/exec.c:5409 — `shf->lineno = lineno;`. Use
6393                    // the same max(1, line_base) clamp as the synth_shf
6394                    // in vm_helper::dispatch_function_call. Bug #396.
6395                    shf.lineno = std::cmp::max(1, line_base);
6396                    tab.add(shf);
6397                }
6398                // c:Src/exec.c:5460-5475 — `TRAP<SIG>() { ... }` is the
6399                // function-named trap install. zsh detects the `TRAP`
6400                // prefix at func-def time and calls
6401                // `settrap(signum, NULL, ZSIG_FUNC)` so the next
6402                // dispatch of that signal routes to the named shfunc.
6403                // Bug #157 in docs/BUGS.md — fusevm_bridge's funcdef
6404                // opcode skipped this dispatch entirely, so TRAPEXIT /
6405                // TRAPUSR1 / TRAPZERR / TRAPDEBUG never fired.
6406                if name.len() > 4 && name.starts_with("TRAP") {
6407                    if let Some(sn) = crate::ported::jobs::getsigidx(&name[4..]) {
6408                        let _ = crate::ported::signals::settrap(
6409                            sn,
6410                            None,
6411                            crate::ported::zsh_h::ZSIG_FUNC as i32,
6412                        );
6413                    }
6414                }
6415                exec.functions_compiled.insert(name, chunk);
6416                0
6417            }),
6418            Err(_) => 1,
6419        };
6420        Value::Status(status)
6421    });
6422
6423    // Wire the ShellHost so direct shell ops (Op::Glob, Op::TildeExpand,
6424    // Op::ExpandParam, Op::CmdSubst, Op::CallFunction, etc.) route through
6425    // ZshrsHost back into the executor.
6426    vm.set_shell_host(Box::new(ZshrsHost));
6427}
6428
6429impl ZshrsHost {
6430    /// True iff `c` can be a `(j:…:)` / `(s:…:)` delimiter — non-alphanumeric,
6431    /// non-underscore. Restricting to punctuation avoids `(jL)` consuming `L`
6432    /// as a delim instead of as the next flag.
6433    fn is_zsh_flag_delim(c: char) -> bool {
6434        !c.is_ascii_alphanumeric() && c != '_'
6435    }
6436}
6437
6438/// Run `body` through `crate::ported::subst::paramsubst` and convert
6439/// the resulting node list into a fusevm `Value`. Centralises the
6440/// pattern duplicated across ~10 BUILTIN_* handlers:
6441///   - build a `${...}` body string from opcode operands
6442///   - paramsubst the body
6443///   - propagate errflag to `exec.last_status`
6444///   - delegate the LinkList → Value conversion to `nodes_to_value`
6445///
6446/// **Extension** — Rust-only helper. No direct C analog because C
6447/// zsh uses LinkList everywhere; the conversion happens at the
6448/// boundary back into the VM's stack.
6449fn paramsubst_to_value(body: &str) -> Value {
6450    // c:Src/subst.c:1625 paramsubst's `qt` flag is the C signal that
6451    // the current expansion is inside `"…"`. The fast-path bridges
6452    // (BUILTIN_PARAM_*, BUILTIN_BRIDGE_BRACE_ARRAY) used to hardcode
6453    // qt=false, which silently broke DQ-only semantics inside
6454    // `${arr:^other}` / `${arr:^^other}` (Src/subst.c:3456-3520).
6455    // The executor's `in_dq_context` counter is bumped by EXPAND_TEXT
6456    // mode 1 / mode 5 before the bridge fires, so reading it here
6457    // propagates the DQ flag without changing every bridge call site.
6458    let qt = with_executor(|exec| exec.in_dq_context > 0);
6459    let mut ret_flags: i32 = 0;
6460    let (_full, _pos, nodes) = crate::ported::subst::paramsubst(body, 0, qt, 0i32, &mut ret_flags);
6461    if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
6462        with_executor(|exec| exec.set_last_status(1));
6463    }
6464    nodes_to_value(nodes)
6465}
6466
6467/// Wrap a `Vec<String>` (e.g. paramsubst nodes, multsub parts,
6468/// xpandbraces output) into a fusevm `Value`: 0 → empty Array, 1 →
6469/// Str, >1 → Array. Same unwrap idiom every handler that calls a
6470/// canonical Vec-returning fn does.
6471fn nodes_to_value(nodes: Vec<String>) -> Value {
6472    // c:Src/glob.c:3649 remnulargs — strip the Nularg (`\u{a1}`)
6473    //   sentinel and other INULL bytes that paramsubst's splat block
6474    //   emits for empty array elements (so prefork's empty-node-delete
6475    //   pass doesn't drop them). Downstream consumers (cond `-z`/`-n`,
6476    //   command args, etc.) must see the post-remnulargs strings. Bug
6477    //   #185 in docs/BUGS.md: `[[ -z "${b[@]}" ]]` for b=("") returned
6478    //   false because the leftover `\u{a1}` had StringLen=1.
6479    let stripped: Vec<String> = nodes
6480        .into_iter()
6481        .map(|mut s| {
6482            crate::ported::glob::remnulargs(&mut s);
6483            s
6484        })
6485        .collect();
6486    if stripped.is_empty() {
6487        Value::Array(Vec::new())
6488    } else if stripped.len() == 1 {
6489        let only = stripped.into_iter().next().unwrap();
6490        // c:Src/subst.c:183-186 — `else if (!(flags & PREFORK_SINGLE)
6491        // && !(*ret_flags & PREFORK_KEY_VALUE) && !keep)
6492        //   uremnode(list, node);`
6493        // C zsh's prefork removes empty linknodes from the result
6494        // list when in non-SINGLE (argv-context) mode. The ported
6495        // prefork at subst.rs:388-396 honors the same delete-empty
6496        // pass, but some paramsubst paths land here with a single-
6497        // empty-string Vec instead of an empty Vec (paramsubst's
6498        // slice / substring / parameter-flag branches allocate a
6499        // result before checking emptiness). Mirror the prefork
6500        // drop at this layer: single-empty under !in_dq_context
6501        // collapses to Value::Array(empty), and pop_args (line 6243)
6502        // splats the empty Array → zero argv words. DQ context
6503        // (in_dq_context > 0) keeps the empty string so
6504        // `echo "${UNSET}"` still produces an empty arg per zsh's
6505        // quoting rules (c:Src/subst.c:1650-1656 isarr comment).
6506        if only.is_empty() {
6507            let in_dq = with_executor(|exec| exec.in_dq_context > 0);
6508            if !in_dq {
6509                return Value::Array(Vec::new());
6510            }
6511        }
6512        Value::str(only)
6513    } else {
6514        Value::Array(stripped.into_iter().map(Value::str).collect())
6515    }
6516}
6517
6518fn pop_args(vm: &mut fusevm::VM, argc: u8) -> Vec<String> {
6519    let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
6520    for _ in 0..argc {
6521        popped.push(vm.pop());
6522    }
6523    popped.reverse();
6524    let mut args: Vec<String> = Vec::with_capacity(popped.len());
6525    for v in popped {
6526        match v {
6527            Value::Array(items) => {
6528                for item in items {
6529                    args.push(item.to_str());
6530                }
6531            }
6532            other => args.push(other.to_str()),
6533        }
6534    }
6535    // `expand_glob` set the glob-failed cell when a no-match glob
6536    // triggered nomatch (c:Src/glob.c:1877). Signal the failure via
6537    // last_status + the per-command glob_failed cell; the dispatcher
6538    // (`host_exec_external`) consumes + clears it and returns status 1
6539    // without running the command body.
6540    if with_executor(|exec| exec.current_command_glob_failed.get()) {
6541        with_executor(|exec| exec.set_last_status(1));
6542    }
6543    // `$_` tracks the last argument of the PREVIOUSLY executed
6544    // command (zsh / bash convention). Promote the deferred value
6545    // into `$_` BEFORE this command runs (so `echo $_` reads the
6546    // prior command's last arg) then stash THIS command's last arg
6547    // for the next dispatch.
6548    let new_last = args.last().cloned();
6549    with_executor(|exec| {
6550        if let Some(prev) = exec.pending_underscore.take() {
6551            exec.set_scalar("_".to_string(), prev);
6552        }
6553        if let Some(last) = new_last {
6554            exec.pending_underscore = Some(last);
6555        }
6556    });
6557    args
6558}
6559
6560/// zsh dispatch order is alias → function → builtin → external. The
6561/// compiler emits direct CallBuiltin ops for known builtin names for
6562/// perf, which silently skips a user function that shadows the same
6563/// name (e.g. `echo() { ... }; echo hi` would run the C builtin
6564/// without this check). Returns Some(status) when the call is routed
6565/// to the user function; the builtin handler should fall through to
6566/// its native impl when None.
6567/// Fork+exec a system binary by name. Used by `reg_overridable!` as
6568/// the fall-through path when `[builtins].coreutils_shadows = off`
6569/// (the default) — runs the canonical `/bin/X` instead of zshrs's
6570/// in-process shadow so old scripts hit zero behavioral divergence.
6571///
6572/// Inherits stdin/stdout/stderr from the parent so pipelines work
6573/// transparently. Resolves the binary via PATH; mirrors what zsh's
6574/// own external-command dispatch would do. Returns the child's exit
6575/// status (or 127 if PATH lookup fails — the standard "command not
6576/// found" code).
6577fn exec_system_command(name: &str, args: &[String]) -> i32 {
6578    let status = std::process::Command::new(name)
6579        .args(args)
6580        .stdin(std::process::Stdio::inherit())
6581        .stdout(std::process::Stdio::inherit())
6582        .stderr(std::process::Stdio::inherit())
6583        .status();
6584    match status {
6585        Ok(s) => s.code().unwrap_or(if s.success() { 0 } else { 1 }),
6586        Err(e) => {
6587            eprintln!("zshrs: {}: {}", name, e);
6588            127
6589        }
6590    }
6591}
6592
6593fn try_user_fn_override(name: &str, args: &[String]) -> Option<i32> {
6594    let has_fn = with_executor(|exec| {
6595        exec.functions_compiled.contains_key(name) || exec.function_exists(name)
6596    });
6597    if !has_fn {
6598        return None;
6599    }
6600    Some(with_executor(|exec| {
6601        exec.dispatch_function_call(name, args).unwrap_or(127)
6602    }))
6603}
6604
6605/// Builtin ID for `${name}` reads — routes through canonical
6606/// `getsparam` (Src/params.c:3076) via paramtab + env walk so nested
6607/// VMs (function calls) see the same storage.
6608pub const BUILTIN_GET_VAR: u16 = 283;
6609
6610/// Builtin ID for `name=value` assignments — pops [name, value] and
6611/// routes through canonical `setsparam` (Src/params.c:3350).
6612pub const BUILTIN_SET_VAR: u16 = 284;
6613
6614/// Builtin ID for pipeline execution. Pops N sub-chunk indices from the stack;
6615/// each index points into `vm.chunk.sub_chunks` (compiled stage bodies). Forks
6616/// N children, wires stdin/stdout between them via pipes, runs each stage's
6617/// bytecode on a fresh VM in its child, parent waits for all and pushes the
6618/// last stage's exit status. This is bytecode-native pipeline execution —
6619/// no tree-walker delegation.
6620pub const BUILTIN_RUN_PIPELINE: u16 = 285;
6621
6622/// Builtin ID for `Array → String` joining. Pops one value: if it's an Array,
6623/// joins its string-coerced elements with a single space; otherwise passes
6624/// through. Used after `Op::Glob` to convert the pattern's matched paths into
6625/// the single argv-token form the bytecode word model expects (no per-word
6626/// splitting yet — that's a future phase).
6627pub const BUILTIN_ARRAY_JOIN: u16 = 286;
6628
6629/// Builtin ID for `cmd &` background execution. IDs 287/288/289 are reserved
6630/// for the planned array work in Phase G1 (SET_ARRAY/SET_ASSOC/ARRAY_INDEX),
6631/// so this lands at 290. Pops one sub-chunk index; forks; child detaches
6632/// (`setsid`), runs the sub-chunk on a fresh VM, exits with last_status; parent
6633/// returns Status(0) immediately. Job-table registration (so `jobs`/`fg`/`wait`
6634/// can see the pid) is deferred to Phase G6 — fire-and-forget for now.
6635pub const BUILTIN_RUN_BG: u16 = 290;
6636
6637/// Indexed-array assignment: `arr=(a b c)`. Compile_simple emits N element
6638/// pushes followed by name push, then `CallBuiltin(BUILTIN_SET_ARRAY, N+1)`.
6639/// The handler pops args (last popped = name in our pushing order) and stores
6640/// `Vec<String>` into `executor.arrays`. Tree-walker callers see the same
6641/// storage. Any prior scalar binding in `executor.variables` for `name` is
6642/// removed so `${name}` (scalar context) consistently reflects the array's
6643/// first element via `get_variable`.
6644pub const BUILTIN_SET_ARRAY: u16 = 287;
6645
6646/// Single-key set on an associative array: `foo[key]=val`. Stack (top-down):
6647/// [name, key, value]. Stores `value` into `executor.assoc_arrays[name][key]`,
6648/// creating the outer entry if missing. compile_simple detects `var[...]=...`
6649/// in assignments and emits this builtin.
6650pub const BUILTIN_SET_ASSOC: u16 = 288;
6651
6652/// `${arr[idx]}` — single-element array index. Pops two args:
6653///   stack: [name, idx_str]
6654/// Returns the indexed element as Value::str. Indexing semantics: zsh is
6655/// 1-based by default; bash is 0-based. We follow zsh.
6656/// Special idx values: `@` and `*` return the whole array as Value::Array
6657/// (which fuses correctly via the Op::Exec splice for argv splice).
6658pub const BUILTIN_ARRAY_INDEX: u16 = 289;
6659
6660/// `${#arr[@]}` and `${#arr}` (when arr is an array name) — array length.
6661/// Pops one arg: name. Returns Value::str of len.
6662
6663/// `${arr[@]}` — splice all elements as a Value::Array. Pops one arg: name.
6664/// The Array gets flattened by Op::Exec/ExecBg/CallFunction into argv.
6665pub const BUILTIN_ARRAY_ALL: u16 = 292;
6666
6667/// Flatten one level of Value::Array nesting. Pops N values; for each, if it's
6668/// a Value::Array, its elements are appended directly; otherwise the value is
6669/// appended as-is. Pushes a single Value::Array of the flattened result. Used
6670/// by the for-loop word-list compile path: when a word like `${arr[@]}`
6671/// produces a nested Array, this lets `for i in ${arr[@]}` iterate over the
6672/// inner elements rather than the outer single-element array.
6673pub const BUILTIN_ARRAY_FLATTEN: u16 = 293;
6674
6675/// `coproc [name] { body }` — bidirectional pipe to async child. Pops a name
6676/// (optional, "" for default) and a sub-chunk index. Creates two pipes, forks,
6677/// child redirects its fd 0/1 to the inner ends and runs the body, parent
6678/// stores [write_fd, read_fd] into the named array (default `COPROC`). Caller
6679/// closes the fds and `wait`s when done. Job-table integration deferred to
6680/// Phase G6 alongside the bg `&` work.
6681pub const BUILTIN_RUN_COPROC: u16 = 294;
6682
6683/// `arr+=(d e f)` — append N elements to an existing indexed array. Compile
6684/// emits N element pushes + name push, then `CallBuiltin(295, N+1)`. Handler
6685/// drains args (last popped = name), extends `executor.arrays[name]` (creates
6686/// the entry if missing). Mirrors zsh's `+=` semantics for indexed arrays.
6687pub const BUILTIN_APPEND_ARRAY: u16 = 295;
6688
6689/// `select var in words; do body; done` — interactive numbered-menu loop.
6690/// Compile emits N word pushes + var-name push + sub-chunk index push, then
6691/// `CallBuiltin(296, N+2)`. Handler prints `1) word1\n2) word2\n...` to
6692/// stderr, prints `$PROMPT3` (default `?# `) to stderr, reads a line from
6693/// stdin. On EOF returns 0. On a valid 1-based number, sets `var` to the
6694/// chosen word, runs the sub-chunk, then redisplays the menu and loops. On
6695/// invalid input redraws the menu without running the body. `break` from
6696/// inside the body exits the loop (handled by the body's own bytecode).
6697pub const BUILTIN_RUN_SELECT: u16 = 296;
6698
6699/// `m[k]+=value` — append onto an existing assoc-array value (string concat).
6700/// If the key doesn't exist, behaves like SET_ASSOC. Stack: [name, key, value].
6701
6702/// `break` from inside a body that runs on a sub-VM (select, future
6703/// loop-via-builtin constructs). Writes the canonical
6704/// `crate::ported::builtin::BREAKS` atomic (port of `Src/loop.c:46
6705/// breaks`). Outer-loop builtins drain BREAKS/CONTFLAG after each
6706/// body run, matching the loop.c:529-534 drain pattern.
6707pub const BUILTIN_SET_BREAK: u16 = 299;
6708
6709/// `continue` from inside a sub-VM body. Sets CONTFLAG=1 + bumps
6710/// BREAKS, matching `bin_break`'s WC_CONTINUE arm at Src/builtin.c
6711/// c:5836 `contflag = 1; FALLTHROUGH; breaks++;`.
6712pub const BUILTIN_SET_CONTINUE: u16 = 300;
6713
6714/// Brace expansion: `{a,b,c}` → 3 values, `{1..5}` → 5 values, `{01..05}` →
6715/// zero-padded numerics, `{a..e}` → letter range. Pops one string, returns
6716/// Value::Array of expansions (empty array → original string preserved).
6717pub const BUILTIN_BRACE_EXPAND: u16 = 301;
6718
6719/// Glob qualifier filter: `*(qualifier)` filters glob results by predicate.
6720/// Pops [pattern, qualifier_string]. Returns Value::Array of matching paths.
6721
6722/// Re-export the regex_match host method as a builtin so `[[ s =~ pat ]]`
6723/// works even when fusevm's Op::RegexMatch isn't routed (compat fallback).
6724
6725/// Word-split a string on IFS (default: whitespace). Pops one string,
6726/// returns Value::Array of fields. Used in array-literal context where
6727/// `arr=($(cmd))` should expand cmd's stdout into multiple elements.
6728pub const BUILTIN_WORD_SPLIT: u16 = 304;
6729
6730/// Register a pre-compiled fusevm chunk as a function. Stack: [name,
6731/// base64-bincode-of-Chunk]. Used by compile_zsh's compile_funcdef to
6732/// register functions parsed via parse_init+parse without going through the
6733/// ShellCommand JSON serialization path.
6734pub const BUILTIN_REGISTER_COMPILED_FN: u16 = 305;
6735/// `BUILTIN_VAR_EXISTS` constant.
6736pub const BUILTIN_VAR_EXISTS: u16 = 306;
6737/// Native param-modifier builtins. Each takes a fixed argv shape and
6738/// returns the modified value as Value::Str.
6739///
6740/// `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
6741/// — pop [name, op_byte, rhs]. op_byte: 0=`:-`, 1=`:=`, 2=`:?`, 3=`:+`.
6742pub const BUILTIN_PARAM_DEFAULT_FAMILY: u16 = 307;
6743/// `${var:offset[:length]}` — pop [name, offset, length] (length=-1 means
6744/// "rest of value"; negative offset counts from end).
6745pub const BUILTIN_PARAM_SUBSTRING: u16 = 308;
6746/// `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}` — pop
6747/// [name, pattern, op_byte]. op_byte: 0=`#`, 1=`##`, 2=`%`, 3=`%%`.
6748pub const BUILTIN_PARAM_STRIP: u16 = 309;
6749/// `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
6750/// `${var/%pat/repl}` — pop [name, pattern, replacement, op_byte].
6751/// op_byte: 0=first, 1=all, 2=anchor-prefix, 3=anchor-suffix.
6752pub const BUILTIN_PARAM_REPLACE: u16 = 310;
6753/// `${#name}` — character length of a scalar value, or element count
6754/// of an indexed/assoc array. Pops \[name\], returns count as Value::Str.
6755pub const BUILTIN_PARAM_LENGTH: u16 = 311;
6756/// `$((expr))` arithmetic substitution. Pops \[expr_string\], evaluates
6757/// via the executor's MathEval (integer-aware), returns result as
6758/// Value::Str. Bypasses ArithCompiler's float-only Op::Div path so
6759/// `$((10/3))` returns "3" not "3.333...".
6760pub const BUILTIN_ARITH_EVAL: u16 = 312;
6761/// `(( ... ))` math command post-eval status hook. Pops nothing,
6762/// pushes Value::Status. If errflag is set (math error in the
6763/// preceding BUILTIN_ARITH_EVAL call), clears it and emits status=2
6764/// matching c:Src/math.c arith-failure semantics. Otherwise emits
6765/// the current vm.last_status. Used by compile_arith's `(( ... ))`
6766/// path so the math command swallows errors without halting the
6767/// script — `$((... ))` substitutions skip this hook so their
6768/// errflag propagates up to the containing command.
6769pub const BUILTIN_ARITH_CMD_FINISH: u16 = 527;
6770/// `$(cmd)` command substitution. Pops \[cmd_string\], runs through
6771/// `run_command_substitution` which compiles via parse_init+parse + ZshCompiler
6772/// and captures stdout via an in-process pipe. Returns trimmed output
6773/// as Value::Str. Avoids the sub-chunk word-emit quoting bug in the
6774/// raw Op::CmdSubst path.
6775pub const BUILTIN_CMD_SUBST_TEXT: u16 = 313;
6776/// Text-based word expansion. Pops \[preserved_text\]: the word with
6777/// quotes preserved (Dnull→`"`, Snull→`'`, Bnull→`\`), runs
6778/// `expand_string` (variable + cmd-sub + arith) then `xpandbraces`
6779/// then `expand_glob`. Returns Value::str (single match) or
6780/// Value::Array (multi-match brace/glob).
6781pub const BUILTIN_EXPAND_TEXT: u16 = 314;
6782
6783/// `[[ a -ef b ]]` — same-inode test. Stack: [a, b]. Pushes Bool true iff
6784/// both paths resolve to the same `(dev, inode)` pair (zsh + bash semantics).
6785pub const BUILTIN_SAME_FILE: u16 = 315;
6786
6787/// `[[ a -nt b ]]` — file `a` newer than file `b` (mtime strict).
6788/// Stack: [path_a, path_b]. Pushes Bool. zsh-compatible "missing"
6789/// rules: if both exist, compare mtime; if only `a` exists → true;
6790/// otherwise false.
6791pub const BUILTIN_FILE_NEWER: u16 = 324;
6792
6793/// `[[ a -ot b ]]` — mirror of `-nt`. If both exist, compare mtime;
6794/// if only `b` exists → true; otherwise false.
6795pub const BUILTIN_FILE_OLDER: u16 = 325;
6796
6797/// `[[ -k path ]]` — sticky bit (S_ISVTX) set on path.
6798pub const BUILTIN_HAS_STICKY: u16 = 326;
6799/// `[[ -u path ]]` — setuid bit (S_ISUID).
6800pub const BUILTIN_HAS_SETUID: u16 = 327;
6801/// `[[ -g path ]]` — setgid bit (S_ISGID).
6802pub const BUILTIN_HAS_SETGID: u16 = 328;
6803/// `[[ -O path ]]` — owned by effective UID.
6804pub const BUILTIN_OWNED_BY_USER: u16 = 329;
6805/// `[[ -G path ]]` — owned by effective GID.
6806pub const BUILTIN_OWNED_BY_GROUP: u16 = 330;
6807/// `[[ -N path ]]` — file modified since last accessed (atime <= mtime).
6808pub const BUILTIN_FILE_MODIFIED_SINCE_ACCESS: u16 = 341;
6809
6810/// `name+=val` (no parens) — runtime-dispatched append.
6811/// If name is an indexed array → push val as element.
6812/// If name is an assoc array → error (zsh requires `(k v)` form).
6813/// Else → scalar concat (existing SET_VAR behavior).
6814pub const BUILTIN_APPEND_SCALAR_OR_PUSH: u16 = 331;
6815
6816/// `[[ -c path ]]` — character device.
6817pub const BUILTIN_IS_CHARDEV: u16 = 332;
6818/// `[[ -b path ]]` — block device.
6819pub const BUILTIN_IS_BLOCKDEV: u16 = 333;
6820/// `[[ -p path ]]` — FIFO / named pipe.
6821pub const BUILTIN_IS_FIFO: u16 = 334;
6822/// `[[ -S path ]]` — socket.
6823pub const BUILTIN_IS_SOCKET: u16 = 335;
6824/// `BUILTIN_ERREXIT_CHECK` constant.
6825pub const BUILTIN_ERREXIT_CHECK: u16 = 336;
6826/// Post-`always`-arm checks for the canonical RETFLAG / BREAKS /
6827/// CONTFLAG atomics that mark try-block escapes. Each returns
6828/// Value::Int(1) when the corresponding atomic is set (and consumes
6829/// it so the next escape doesn't re-fire) and Value::Int(0) otherwise.
6830/// Paired with JumpIfFalse + Jump to outer return_patches /
6831/// break_patches / continue_patches by compile_zsh's `Try` arm.
6832pub const BUILTIN_RETFLAG_CHECK: u16 = 600;
6833/// `BUILTIN_BREAKS_CHECK` constant.
6834pub const BUILTIN_BREAKS_CHECK: u16 = 601;
6835/// `BUILTIN_CONTFLAG_CHECK` constant.
6836pub const BUILTIN_CONTFLAG_CHECK: u16 = 602;
6837/// Fire the DEBUG trap (SIGDEBUG) before each statement.
6838/// c:Src/exec.c:1357-1500 DEBUGBEFORECMD — when a "DEBUG" entry is
6839/// installed via `trap '...' DEBUG`, run the body just before the
6840/// next command. Cheap when no DEBUG trap is set (one hashmap lookup
6841/// returns None and we early-out).
6842pub const BUILTIN_DEBUG_TRAP: u16 = 603;
6843/// `set -n` / `set -o noexec` — parse but don't execute. Returns
6844/// Value::Int(1) when the noexec option is set so the caller's
6845/// JumpIfTrue skips the statement body. c:Src/exec.c:1390 main loop
6846/// check.
6847pub const BUILTIN_NOEXEC_CHECK: u16 = 604;
6848/// Block-level redirect-failure gate. Reads exec.redirect_failed
6849/// (set by host.redirect when a redirect open fails); returns
6850/// Value::Int(1) AND clears the flag if set, else 0. Emit-side at
6851/// compile_zsh.rs::compile_command's Redirected arm pairs with a
6852/// JumpIfTrue → WithRedirectsEnd to abandon the body. Without this,
6853/// a multi-statement block after a failed redir kept running every
6854/// statement after the first (the first builtin consumed the flag,
6855/// subsequent statements ran unimpeded).
6856pub const BUILTIN_REDIRECT_FAILED_CHECK: u16 = 605;
6857/// Drop-in replacement for fusevm's Op::Exec for the dynamic-first-
6858/// word path (`$cmd`, `$(cmd)`, `~/bin/foo`). Returns
6859/// Value::Status(vm.last_status) when post-expansion argv is empty
6860/// (preserves the inner cmd-subst's exit), Value::Status(126) with
6861/// "permission denied" when `argv[0]` is empty, otherwise routes
6862/// through executor.host_exec_external like Op::Exec did.
6863pub const BUILTIN_EXEC_DYNAMIC: u16 = 606;
6864/// `< file` / `> file` with no command word (NULLCMD path).
6865/// Resolves NULLCMD (default "cat") / READNULLCMD (default "more")
6866/// at runtime per Src/exec.c:3340-3364 and exec's it through
6867/// host_exec_external. Argc is 1: the int (0 or 1) on the stack
6868/// indicates whether this is a single REDIR_READ redirect
6869/// (selects READNULLCMD when set + non-empty).
6870pub const BUILTIN_NULLCMD_EXEC: u16 = 607;
6871/// `.` (dot) — alias of source/bin_dot but dispatches with the
6872/// literal name "." so the diagnostic prefix matches zsh's
6873/// (`zsh:.:1: …` vs source's `zsh:source:1: …`).
6874/// c:Src/builtin.c:9308 — `BUILTIN(".", BINF_PSPECIAL, bin_dot, …)`.
6875pub const BUILTIN_DOT: u16 = 608;
6876/// `logout` — fusevm maps this to BUILTIN_EXIT alongside `exit`/`bye`,
6877/// which drops the name and dispatches with BIN_EXIT funcid. zsh's
6878/// `logout` outside a login shell must emit "not login shell" + exit 1,
6879/// which only fires when bin_break sees BIN_LOGOUT funcid. Dedicated
6880/// opcode dispatches via BUILTINS table by literal name "logout".
6881pub const BUILTIN_LOGOUT: u16 = 610;
6882/// `BUILTIN_PARAM_SUBSTRING_EXPR` constant.
6883pub const BUILTIN_PARAM_SUBSTRING_EXPR: u16 = 337;
6884/// `BUILTIN_XTRACE_LINE` constant.
6885pub const BUILTIN_XTRACE_LINE: u16 = 338;
6886/// `BUILTIN_ARRAY_JOIN_STAR` constant.
6887pub const BUILTIN_ARRAY_JOIN_STAR: u16 = 339;
6888/// `BUILTIN_SET_RAW_OPT` constant.
6889pub const BUILTIN_SET_RAW_OPT: u16 = 340;
6890
6891/// `time { compound; ... }` — wall-clock-time the sub-chunk and print
6892/// elapsed seconds. Stack: [sub_chunk_idx as Int]. Runs the sub-chunk
6893/// on the current VM (so positional/local state is shared) and prints
6894/// the timing summary to stderr in zsh's format. Pushes Status.
6895pub const BUILTIN_TIME_SUBLIST: u16 = 316;
6896
6897/// `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocation.
6898/// Stack: [path, varid, op_byte]. Opens `path` per `op_byte`, gets the
6899/// new fd (≥10 in zsh; we use libc::open with O_CLOEXEC bit cleared so
6900/// the inherited fd survives Command::new spawns), stores the fd number
6901/// as a string in `$varid`. Pushes Status (0 success, 1 error).
6902pub const BUILTIN_OPEN_NAMED_FD: u16 = 317;
6903
6904/// Word-segment concat that does cartesian-product distribution over
6905/// arrays. Stack: [lhs, rhs]. Used for RC_EXPAND_PARAM `${arr}` and
6906/// explicit-distribute forms (`${^arr}`, `${(@)…}`).
6907///
6908/// - both scalar: `Value::str(a + b)` (fast path, identical to Op::Concat)
6909/// - lhs Array, rhs scalar: `Value::Array([a + rhs for a in lhs])`
6910/// - lhs scalar, rhs Array: `Value::Array([lhs + b for b in rhs])`
6911/// - both Array: cartesian product `[a + b for a in lhs for b in rhs]`
6912pub const BUILTIN_CONCAT_DISTRIBUTE: u16 = 318;
6913
6914/// Forced-distribute concat — like `BUILTIN_CONCAT_DISTRIBUTE` but
6915/// always distributes cartesian regardless of the `rcexpandparam`
6916/// option. Emitted by the segments fast-path when an
6917/// `is_distribute_expansion` segment is present (`${^arr}`,
6918/// `${(@)arr}`, `${(s.…)arr}` etc.) per zsh: the source-level
6919/// distribution flag overrides the option default.
6920/// Direct port of Src/subst.c:1875 `case Hat: nojoin = 1` and the
6921/// `rcexpandparam` test bypass for the explicit-distribute flags.
6922pub const BUILTIN_CONCAT_DISTRIBUTE_FORCED: u16 = 522;
6923
6924/// Capture current `last_status` into the `TRY_BLOCK_ERROR` variable.
6925/// Emitted between the try block and the always block of `{ … } always
6926/// { … }` so the finally arm can read $TRY_BLOCK_ERROR.
6927pub const BUILTIN_SET_TRY_BLOCK_ERROR: u16 = 320;
6928/// `BUILTIN_RESTORE_TRY_BLOCK_STATUS` constant.
6929pub const BUILTIN_RESTORE_TRY_BLOCK_STATUS: u16 = 432;
6930/// `BUILTIN_BEGIN_INLINE_ENV` constant.
6931pub const BUILTIN_BEGIN_INLINE_ENV: u16 = 433;
6932/// `BUILTIN_END_INLINE_ENV` constant.
6933pub const BUILTIN_END_INLINE_ENV: u16 = 434;
6934
6935/// `[[ -o option ]]` — shell-option-set test. Stack: \[option_name\].
6936/// Normalizes the name (strip underscores, lowercase) and reads
6937/// `exec.options`. Pushes Bool.
6938pub const BUILTIN_OPTION_SET: u16 = 321;
6939/// Tri-state `[[ -o NAME ]]` — same lookup as BUILTIN_OPTION_SET
6940/// but returns a Value::Int (0=set, 1=unset, 3=invalid-name). The
6941/// 3-state code matches zsh's `[[ -o invalid ]]` exit (Src/cond.c
6942/// :502 `optison()`). Used by compile_cond's `-o` arm to skip the
6943/// generic bool→status conversion and preserve the invalid-name
6944/// signal in `$?`.
6945pub const BUILTIN_OPTION_CHECK_TRISTATE: u16 = 609;
6946
6947/// `${var:#pattern}` — array filter: remove elements matching `pattern`.
6948/// Stack: [name, pattern]. For scalar `var`, returns empty if it matches
6949/// the pattern, else the value. For array `var`, returns Array of
6950/// non-matching elements.
6951pub const BUILTIN_PARAM_FILTER: u16 = 322;
6952
6953/// `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()` —
6954/// subscripted-array assign with array-literal RHS. Stack:
6955/// [...elements, name, key]. Empty elements + single-int key `a[i]=()`
6956/// removes that element. Comma-key `a[i,j]=(...)` splices.
6957pub const BUILTIN_SET_SUBSCRIPT_RANGE: u16 = 323;
6958
6959/// `[[ -X file ]]` for unknown unary test op `-X`. Stack: \[op_name\].
6960/// Emits zsh's `unknown condition: -X` diagnostic to stderr and
6961/// pushes Bool(false). Without this, unknown conditions silently
6962/// returned false matching neither zsh's error format nor the
6963/// expected status code (zsh returns 2 for parse error).
6964
6965/// `[[ -t fd ]]` — fd-is-a-tty check. Stack: \[fd_string\].
6966/// Routes through libc::isatty. Pushes Bool.
6967pub const BUILTIN_IS_TTY: u16 = 325;
6968
6969/// Update `$LINENO` to track the source line of the next statement.
6970/// Stack: \[n\] (the line number from `ZshPipe.lineno`). Direct port
6971/// of zsh's `lineno` global tracking (Src/input.c:330) — the
6972/// compiler emits one of these per top-level pipe so `$LINENO`
6973/// reflects the source position at runtime. ID 342 picked because
6974/// the previous `326` collided with `BUILTIN_HAS_STICKY` (the file
6975/// has several other duplicate IDs — 325 has two as well — but
6976/// fixing those is out of scope for this port).
6977pub const BUILTIN_SET_LINENO: u16 = 342;
6978
6979/// Pop a scalar from the VM stack, run expand_glob on it, push the
6980/// result as Value::Array. Used by the segment-concat compile path
6981/// when var refs concatenate with glob meta literals (`$D/*`,
6982/// `${prefix}*`, etc.) — those skip the bridge's pathname-expansion
6983/// pass and would otherwise leak the glob meta to argv as a literal.
6984pub const BUILTIN_GLOB_EXPAND: u16 = 343;
6985
6986/// Push a `CmdState` token onto the command-context stack. Direct
6987/// port of zsh's `cmdpush(int cmdtok)` (Src/prompt.c:1623). The
6988/// stack is consulted by `%_` in PS4/prompt expansion to produce
6989/// the cumulative control-flow-context labels (`if`, `then`,
6990/// `cmdand`, `cmdor`, `cmdsubst`, …) that `zsh -x` xtrace shows
6991/// in the trace prefix. Compile_zsh emits push/pop pairs around
6992/// each compound command (if/while/[[…]]/((…))/$(…) etc.).
6993/// Token is a `CmdState as u8`.
6994pub const BUILTIN_CMD_PUSH: u16 = 344;
6995
6996/// Pop the top of the command-context stack. Direct port of zsh's
6997/// `cmdpop(void)` (Src/prompt.c:1631).
6998pub const BUILTIN_CMD_POP: u16 = 345;
6999
7000/// Emit an xtrace line built from the top `argc` values on the VM
7001/// stack, peeked WITHOUT consuming. Used to trace simple commands
7002/// AFTER expansion, so `echo for $i` shows as `echo for a` / `echo
7003/// for b`. Direct port of Src/exec.c:2055-2066.
7004pub const BUILTIN_XTRACE_ARGS: u16 = 346;
7005
7006/// Trace one assignment: emits `name=<quoted-value> ` (no newline)
7007/// to xtrerr if XTRACE is on. Coalesces with subsequent
7008/// XTRACE_ASSIGN / XTRACE_ARGS calls onto the SAME line via the
7009/// `XTRACE_DONE_PS4` flag so `a=1 b=2 echo $a $b` produces:
7010///   `<PS4>a=1 b=2 echo 1 2\n`
7011/// matching C zsh's `execcmd_exec` body (Src/exec.c:2517-2582):
7012///   xtr = isset(XTRACE);
7013///   if (xtr) { printprompt4(); doneps4 = 1; }
7014///   while (assign) {
7015///       if (xtr) fprintf(xtrerr, "%s=", name);
7016///       ... eval value ...
7017///       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
7018///   }
7019///
7020/// Stack contract on entry: [..., name, value]. Both peeked, NOT
7021/// consumed (the matching SET_VAR call pops them after). argc = 2.
7022pub const BUILTIN_XTRACE_ASSIGN: u16 = 525;
7023
7024/// Emit a trailing `\n` + flush iff XTRACE is on AND PS4 was
7025/// emitted by an earlier XTRACE_ASSIGN this line. Used at the end
7026/// of compile_simple's assignment-only path so the trace line gets
7027/// terminated. Mirrors C's exec.c:3397-3399 (the assign-only return
7028/// path through execcmd_exec which does `fputc('\n', xtrerr);
7029/// fflush(xtrerr)`).
7030///
7031/// Stack: untouched. argc = 0.
7032pub const BUILTIN_XTRACE_NEWLINE: u16 = 526;
7033
7034/// Push the live `xtrace` opt-state as `Value::Int(1)` (on) or
7035/// `Value::Int(0)` (off). Used by `compile_cond` to gate the
7036/// trace-string-building block on xtrace state at runtime — without
7037/// this the trace path's `compile_word_str` on each operand re-
7038/// evaluates side-effectful expressions (`$((i++))`) once for the
7039/// trace string and once for the actual condition, doubling the
7040/// effective increment. Bug #159 in docs/BUGS.md.
7041///
7042/// Stack: pushes Int(0|1). argc = 0.
7043pub const BUILTIN_XTRACE_IS_ON: u16 = 611;
7044
7045/// Reset the `DONETRAP` flag at the start of each top-level statement
7046/// (sublist boundary). Mirrors C `Src/exec.c:1455` — `donetrap = 0`.
7047/// Stack: untouched. argc = 0. Bug #303 in docs/BUGS.md.
7048pub const BUILTIN_DONETRAP_RESET: u16 = 612;
7049
7050/// `[[ -z X ]]` / `[[ -n X ]]` operand-empty test that honours zsh's
7051/// array-splice semantics. C zsh evaluates `[[ -z X ]]` per
7052/// `Src/cond.c:347` (case 'z'): `s` is the SCALAR operand passed
7053/// through `cond_str`'s singsub. For `"${arr[@]}"` zsh expands per
7054/// `Src/subst.c:multsub` which yields each element as its own word
7055/// list node; cond.c then sees the joined-or-single-element form.
7056///
7057/// The compile-side `-z` shortcut at `compile_zsh.rs:5371` used
7058/// `Op::StringLen` which calls `Value::len` — for `Value::Array`
7059/// that returns ARRAY LENGTH, not string length. `b=("")` produced
7060/// `Value::Array([""])` → `len = 1` → `-z` returned false.
7061///
7062/// This builtin pops one `Value` and pushes `1` (empty) or `0`
7063/// (non-empty) per the cond context:
7064///   - `Value::Str(s)` → s.is_empty()
7065///   - `Value::Array([])` → true (zero words → vacuous-empty)
7066///   - `Value::Array([s])` → s.is_empty() (single-word case)
7067///   - `Value::Array([_; n>=2])` → false (multiple non-empty
7068///     words; zsh would raise "unknown condition" but the
7069///     observable test result is non-empty/false)
7070///
7071/// Companion to BUILTIN_COND_STR_NONEMPTY (#185 in docs/BUGS.md).
7072pub const BUILTIN_COND_STR_EMPTY: u16 = 613;
7073
7074/// `[[ -n X ]]` operand-non-empty test (logical complement of
7075/// BUILTIN_COND_STR_EMPTY).
7076pub const BUILTIN_COND_STR_NONEMPTY: u16 = 614;
7077
7078/// `exec N<<<"str"` — herestring redirect to explicit fd, applied
7079/// permanently to the shell (no scope restoration). Pops `[content,
7080/// fd]` from the stack; creates a temp file, writes
7081/// `content + "\n"`, reopens read-only, dup2's to `fd`, unlinks the
7082/// temp path so it disappears on close. Mirrors C `Src/exec.c:4655
7083/// getherestr` + `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)`
7084/// at c:3766-3780 for the bare-exec-redir code path (nullexec=1).
7085/// Bug #205 in docs/BUGS.md.
7086///
7087/// Stack: pushes `Value::Status(0)` on success, `Status(1)` on
7088/// failure. argc = 2.
7089pub const BUILTIN_EXEC_HERESTR_FD: u16 = 615;
7090
7091/// MULTIOS write/append fan-out for `cmd > a > b` / `cmd > a >> b`
7092/// style redirects (Bug #36 in docs/BUGS.md). zsh's MULTIOS option
7093/// (Src/exec.c:2418 `mfds[fd1]` check + addfd splice) creates a
7094/// pipe at fd1, spawns an internal "tee" process that copies
7095/// stdin → every collected target file. Without this, only the
7096/// LAST redirect target survives because each dup2 overwrites the
7097/// previous binding.
7098///
7099/// Stack layout (pushed by compile_zsh's compile_redirs coalescing
7100/// pass): `[target_1, op_byte_1, target_2, op_byte_2, …, target_N,
7101/// op_byte_N, fd]`. Pops 2N+1 elements; `argc = 2*N + 1`.
7102///
7103/// Runtime:
7104///   1. Open all targets per their op_byte (WRITE truncate /
7105///      APPEND).
7106///   2. Save `dup(fd)` onto the active redirect_scope_stack so
7107///      `host_redirect_scope_end` restores the original fd.
7108///   3. Create a pipe; spawn a thread that reads from the pipe
7109///      read-end and writes every chunk to every opened target.
7110///   4. dup2 the pipe write-end onto `fd` so the command's writes
7111///      go through the splitter.
7112///   5. Track `(pipe_write_fd, JoinHandle)` so scope-end can close
7113///      the pipe (draining the thread) and join before restoring.
7114pub const BUILTIN_MULTIOS_REDIRECT: u16 = 617;
7115
7116/// MULTIOS input-side concatenation for `cmd < a < b` shapes
7117/// (Bug #36 input arm). C zsh's `Src/exec.c:2418` mfds dispatch
7118/// also covers the read direction — when multiple `<` redirects
7119/// target the same fd, mfds[fd] grows and addfd splices a
7120/// concatenating cat into the pipe.
7121///
7122/// Stack layout: `[source_1, source_2, …, source_N, fd]`. Pops
7123/// N + 1 elements (argc = N + 1). All sources are file paths; the
7124/// op_byte is implicitly READ.
7125///
7126/// Runtime:
7127///   1. Open every source file for reading.
7128///   2. Save `dup(fd)` onto the redirect_scope_stack.
7129///   3. Create a pipe; spawn a thread that reads each source in
7130///      order and writes every chunk to the pipe write-end. Close
7131///      write-end when done so the consumer sees EOF.
7132///   4. dup2 the pipe read-end onto `fd`.
7133///   5. Track the JoinHandle so scope-end joins (no fd-close needed
7134///      here — the producer thread closes its own pipe write-end
7135///      on exit).
7136pub const BUILTIN_MULTIOS_READ: u16 = 618;
7137
7138/// `redirection with no command` parse-time error for bare
7139/// `builtin 2>&1` / `command < file` / `exec >&-` precmd-keyword
7140/// shapes with a redirect but no following command. Direct port
7141/// of `Src/exec.c:3342 zerr("redirection with no command")`.
7142/// argc=0; pushes Value::Status(1).
7143pub const BUILTIN_REDIR_NO_CMD: u16 = 616;
7144
7145/// GLOB_SUBST guard for `[[ x == $pat ]]` pattern RHS coming from
7146/// parameter / command substitution. C-zsh's `[[ == ]]` semantics
7147/// (Src/options.c GLOB_SUBST default OFF + Src/cond.c:552
7148/// `cond_match` + Src/pattern.c patcompile tokenization-based
7149/// meta detection) treat chars from substitution as LITERAL
7150/// unless GLOB_SUBST is on. The Rust patcompile accepts both
7151/// tokenized and raw-ASCII meta chars, losing the distinction,
7152/// so `pat="h*"; [[ hello == $pat ]]` matched in zshrs but not
7153/// in zsh. Bug #116 in docs/BUGS.md.
7154///
7155/// Compile-time signal: emitted by `compile_cond_expr` ONLY when
7156/// the RHS contains `$` or backtick. Runtime checks the live
7157/// option state. If GLOB_SUBST is OFF, the popped string has
7158/// its glob metachars escaped with `\` so the downstream StrMatch
7159/// → patcompile treats them as literals. If GLOB_SUBST is ON,
7160/// the value passes through unchanged so `setopt glob_subst`
7161/// restores zsh's pattern-on-expansion behavior.
7162///
7163/// Stack: pops one string, pushes the (possibly escaped) result.
7164/// argc = 1.
7165pub const BUILTIN_GLOB_SUBST_GUARD: u16 = 528;
7166
7167/// Coerce a string parameter value to a math number (Int or Float)
7168/// for arithmetic-context reads, mirroring C-zsh's `getmathparam`
7169/// (Src/math.c:337). When the variable holds a string like "hello"
7170/// that isn't numeric, C falls back to recursively evaluating the
7171/// raw string as an arith expression; if that fails too, returns 0.
7172///
7173/// Used by the ArithCompiler pre-load path so `(( y = x ))` with
7174/// `x="hello"` reads `x` as integer 0, then assigns y as integer 0
7175/// — matching zsh's behaviour. The previous Rust port used
7176/// BUILTIN_GET_VAR which returned the raw string "hello"; the
7177/// ArithCompiler stored it verbatim in y's slot, and the post-sync
7178/// BUILTIN_SET_VAR wrote y="hello" as scalar instead of y=0 as
7179/// integer. Bug #118 in docs/BUGS.md.
7180///
7181/// Stack: pops `name` (string), pushes coerced numeric Value.
7182/// argc = 1.
7183pub const BUILTIN_GET_MATH_VAR: u16 = 529;
7184
7185/// GLOB_SUBST runtime gate for words containing parameter / command
7186/// substitution. C-zsh's `prefork` (Src/subst.c) runs `shtokenize`
7187/// on the substituted value when `GLOB_SUBST` is set, making the
7188/// substituted chars eligible for filename generation. With the
7189/// option off, substituted chars stay literal.
7190///
7191/// The Rust port's compile_zsh emits `compile_word_str` for words
7192/// like `/tmp/X/$pat`, which returns the post-expansion string but
7193/// never runs glob expansion (no path here triggers
7194/// BUILTIN_GLOB_EXPAND). Bug #119 in docs/BUGS.md: with `setopt
7195/// glob_subst`, `for f in /tmp/X/$pat` (pat="*.txt") never matched
7196/// `*.txt` files.
7197///
7198/// This opcode wraps the substitution result and dispatches at
7199/// runtime: when GLOB_SUBST is OFF, return unchanged; when ON,
7200/// pass the value through `expand_glob` so glob metas become
7201/// active. Emitted by `compile_for_words` (and similar sites)
7202/// after WORD_SPLIT for words with unquoted expansion.
7203///
7204/// Stack: pops a Value (Str or Array of Str), pushes the glob-
7205/// expanded result (still Str or Array depending on input shape).
7206/// argc = 1.
7207pub const BUILTIN_GLOB_SUBST_EXPAND: u16 = 530;
7208/// `BUILTIN_ASSOC_HAS_KEY` constant — `${(k)assoc[name]}` key-existence
7209/// query. Returns the key text on hit, empty string on miss. Bug #145.
7210pub const BUILTIN_ASSOC_HAS_KEY: u16 = 531;
7211/// `BUILTIN_ARRAY_DROP_EMPTY` constant — filter empty elements from
7212/// an Array on the stack. Used by `for x in $@` / `for x in $*`
7213/// unquoted forms. Bug #166.
7214pub const BUILTIN_ARRAY_DROP_EMPTY: u16 = 532;
7215/// `BUILTIN_QUOTEDZPUTS` constant — run top-of-stack value through
7216/// `crate::ported::utils::quotedzputs` and push the quoted result.
7217/// Used by the cond xtrace path so non-printable bytes (e.g.
7218/// `$'\C-[OP'` expanded ESC+OP) get re-wrapped in `$'…'` form for
7219/// the trace prefix line, matching zsh's `Src/exec.c` cond trace
7220/// which calls `quotedzputs(operand, xtrerr)` on each side. Bug
7221/// surfaced when `[[ -n $'\C-[OP' ]]` traced as `[[ -n OP ]]`
7222/// (raw bytes leaked through the terminal) vs zsh's
7223/// `[[ -n $'\C-[OP' ]]` source-form preservation.
7224pub const BUILTIN_QUOTEDZPUTS: u16 = 533;
7225/// `BUILTIN_QUOTE_TOKENIZED_OUTPUT` — port of
7226/// `crate::ported::exec::quote_tokenized_output` (Src/exec.c:2114)
7227/// applied to top-of-stack scalar. Used by cond xtrace for the RHS
7228/// of pattern-context comparisons (`=` / `==` / `!=`) where C zsh
7229/// emits the SOURCE form: untokenize lexer tokens (Star → `*`,
7230/// Inpar → `(`, …) and backslash-escape special chars, but
7231/// preserve literal ASCII unchanged. Distinct from quotedzputs
7232/// which wraps the whole string in `'…'` / `$'…'` based on
7233/// non-printability — that's wrong for `[[ x = a* ]]` which must
7234/// render as `[[ x = a* ]]`, not `'a*'`.
7235pub const BUILTIN_QUOTE_TOKENIZED_OUTPUT: u16 = 534;
7236
7237/// Bridge into subst_port::substitute_brace_array for nested forms
7238/// that need to PRESERVE array shape across the expand_string
7239/// boundary. Stack: `[content_string]`. Returns Value::Array of the
7240/// per-element words. Used by the compile path for
7241/// `${(@)<nested>...##pat}` shapes — the standard substitute_brace
7242/// returns String which collapses array→scalar; this builtin
7243/// preserves the multi-word output via paramsubst's third return
7244/// (`nodes` vec, the C source's `aval` thread).
7245pub const BUILTIN_BRIDGE_BRACE_ARRAY: u16 = 347;
7246
7247/// Word-segment concat with FIRST/LAST sticking. Stack: [lhs, rhs].
7248/// Used for default unquoted splice forms (`${arr[@]}`, `$@`, `$*`)
7249/// where prefix sticks to first element only and suffix to last only.
7250///
7251/// Distribution table:
7252/// - both scalar: `Value::str(a + b)` (fast path)
7253/// - lhs scalar, rhs Array(b₀..bₙ): `Value::Array([lhs+b₀, b₁, …, bₙ])`
7254/// - lhs Array(a₀..aₙ), rhs scalar: `Value::Array([a₀, …, aₙ₋₁, aₙ+rhs])`
7255/// - both Array: `Value::Array([a₀, …, aₙ₋₁, aₙ+b₀, b₁, …, bₙ])`
7256///   (last of lhs merges with first of rhs; the rest stay separate)
7257///
7258/// This is the default zsh semantics for `print -l X${arr[@]}Y` →
7259/// "Xa", "b", "cY" — three distinct args, surrounding text only on ends.
7260pub const BUILTIN_CONCAT_SPLICE: u16 = 319;
7261
7262/// `${(flags)name}` — zsh parameter expansion flags. Stack: [name, flags].
7263/// Flags applied left-to-right. Supported subset (high-value, used by zpwr):
7264///
7265///   `L` — lowercase the value (scalar; or each element if array)
7266///   `U` — uppercase
7267///   `j:sep:` — join array with `sep` (delim is the char after `j`)
7268///   `s:sep:` — split scalar on `sep` (returns Value::Array)
7269///   `f` — split on newlines (shorthand for `s.\n.`)
7270///   `o` — sort array ascending
7271///   `O` — sort array descending
7272///   `P` — indirect: read name's value as another var name, return that's value
7273///   `@` — keep as array (returns Value::Array — useful before `j` etc.)
7274///   `k` — keys of assoc array
7275///   `v` — values of assoc array
7276///   `#` — word count (array length as scalar)
7277///
7278/// Flags can stack: `(jL)` joins then lowercases; `(s.,.U)` splits on `,`
7279/// then uppercases each element. The long-tail flags (`q`, `qq`, `qqq` for
7280/// quoting, `A` for assoc, `%` for prompt expansion, `e`/`g` for re-eval,
7281/// `n`/`p` for numeric, `t` for type, etc.) are deferred — they hit the
7282/// runtime fallback via the catch-all expansion path.
7283pub const BUILTIN_PARAM_FLAG: u16 = 297;
7284
7285/// `ShellHost` implementation that delegates to the current `ShellExecutor`
7286/// via the `with_executor` thread-local.
7287///
7288/// Construct fresh on each VM run (it carries no state itself). The VM
7289/// dispatches host method calls during `vm.run()`, and `with_executor`
7290/// resolves to the executor pointer set by `ExecutorContext::enter`.
7291/// fusevm-host implementation tying bytecode ops to the
7292/// shell executor.
7293/// zshrs-original — no C counterpart. C zsh has no bytecode VM
7294/// to host; everything runs through `execlist()`/`execpline()`
7295/// directly (Src/exec.c lines 1349/1668).
7296pub struct ZshrsHost;
7297
7298impl fusevm::ShellHost for ZshrsHost {
7299    fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> {
7300        with_executor(|exec| exec.expand_glob(pattern))
7301    }
7302
7303    fn tilde_expand(&mut self, s: &str) -> String {
7304        with_executor(|exec| s.to_string())
7305    }
7306
7307    fn brace_expand(&mut self, s: &str) -> Vec<String> {
7308        // Direct call to the canonical brace expander
7309        // (Src/glob.c::xpandbraces port at glob.rs:1678). Was
7310        // routing through singsub which uses PREFORK_SINGLE — that
7311        // flag explicitly suppresses brace expansion in subst.c:166,
7312        // so `print X{1,2,3}Y` returned the literal string.
7313        //
7314        // brace_ccl: respect the BRACE_CCL option which the bracket-
7315        // class form `{a-z}` requires. Pull from executor options.
7316        let brace_ccl = with_executor(|exec| opt_state_get("braceccl").unwrap_or(false));
7317        crate::ported::glob::xpandbraces(s, brace_ccl)
7318    }
7319
7320    fn str_match(&mut self, s: &str, pattern: &str) -> bool {
7321        // Shell glob match — `*`, `?`, `[...]`, alternation. Used by `[[ x = pat ]]`,
7322        // `case` arms, and any other point that compares against a glob pattern.
7323        glob_match_static(s, pattern)
7324    }
7325
7326    fn expand_param(&mut self, name: &str, _modifier: u8, _args: &[Value]) -> Value {
7327        // Sole funnel: route through `getsparam` matching C zsh's
7328        // `getsparam(name)` → `getvalue` → `getstrvalue` →
7329        // `Param.gsu->getfn` dispatch (Src/params.c:3076 / 2335).
7330        //
7331        // The lookup chain (GSU dispatch + variables + env + array-
7332        // join) lives in `params::getsparam`; subst.rs and this
7333        // bridge both call into it so the logic is in exactly one
7334        // place — mirroring C's "every read goes through getsparam"
7335        // architecture. fuseVM bytecode triggers this bridge when
7336        // the VM hits a PARAM opcode, equivalent to C's wordcode VM
7337        // resolving a parameter read during `exec.c` execution.
7338        //
7339        // Modifier handling: the `_modifier` / `_args` parameters
7340        // are populated by the bytecode compiler but applied by
7341        // separate VM opcodes (LENGTH/STRIP/SUBST/etc.) downstream
7342        // of this fetch — matching C's split between getsparam
7343        // (value fetch) and paramsubst's modifier-walk loop. This
7344        // bridge is the value-fetch step only.
7345        let val_str = crate::ported::params::getsparam(name).unwrap_or_default();
7346        Value::str(val_str)
7347    }
7348
7349    fn process_sub_in(&mut self, sub: &fusevm::Chunk) -> String {
7350        // c:Src/exec.c::getproc — `<(cmd)` uses pipe + fork + the
7351        // `/dev/fd/N` filesystem entry (where N is the read end of
7352        // the pipe held open in the parent). Consumer opens
7353        // `/dev/fd/N`, reads the cmd's stdout through the pipe.
7354        // Both macOS and Linux expose `/dev/fd` for held-open file
7355        // descriptors. Previous Rust port captured stdout into
7356        // `/tmp/zshrs_psub_*` tempfiles synchronously — works for
7357        // `diff <(a) <(b)` style readers that scan once but diverges
7358        // from zsh's observable path string and breaks any consumer
7359        // that introspects the path or expects a non-seekable pipe.
7360        let mut fds: [libc::c_int; 2] = [-1, -1];
7361        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
7362            // Pipe creation failed — fall back to tempfile so we at
7363            // least return SOMETHING.
7364            let fifo_path = format!(
7365                "/tmp/zshrs_psub_fallback_{}_{}",
7366                std::process::id(),
7367                with_executor(|e| {
7368                    let n = e.process_sub_counter;
7369                    e.process_sub_counter += 1;
7370                    n
7371                })
7372            );
7373            let _ = fs::remove_file(&fifo_path);
7374            return fifo_path;
7375        }
7376        let (read_end, write_end) = (fds[0], fds[1]);
7377        let sub_for_child = sub.clone();
7378        match unsafe { libc::fork() } {
7379            -1 => {
7380                unsafe {
7381                    libc::close(read_end);
7382                    libc::close(write_end);
7383                }
7384                return String::from("/dev/null");
7385            }
7386            0 => {
7387                // Child: close read end, dup write end to stdout,
7388                // run the sub-chunk, exit. The exit closes the
7389                // write end automatically, so the parent's reader
7390                // gets EOF when the cmd finishes.
7391                unsafe {
7392                    libc::close(read_end);
7393                    libc::dup2(write_end, libc::STDOUT_FILENO);
7394                    libc::close(write_end);
7395                }
7396                crate::fusevm_disasm::maybe_print_stdout("process_subst_in", &sub_for_child);
7397                let mut vm = fusevm::VM::new(sub_for_child);
7398                register_builtins(&mut vm);
7399                vm.set_shell_host(Box::new(ZshrsHost));
7400                let _ = vm.run();
7401                let _ = std::io::stdout().flush();
7402                unsafe { libc::_exit(0) };
7403            }
7404            _ => {
7405                // Parent: close write end, keep read end open under
7406                // the same fd value so `/dev/fd/N` resolves to the
7407                // pipe's read side. NOTE: FD_CLOEXEC must STAY clear
7408                // — consumers like `cat <(cmd)` and `diff <(a) <(b)`
7409                // discover the fd via exec inheritance, so closing
7410                // on exec defeats the whole point. C zsh's getproc
7411                // (Src/exec.c:5045+) leaves the fd open across exec.
7412                unsafe {
7413                    libc::close(write_end);
7414                }
7415            }
7416        }
7417        format!("/dev/fd/{}", read_end)
7418    }
7419
7420    fn process_sub_out(&mut self, sub: &fusevm::Chunk) -> String {
7421        // `>(cmd)` — consumer reads stdin from a FIFO that the parent
7422        // writes to. Create a real named pipe, fork a child that
7423        // dup2s the read end onto stdin and runs the sub-chunk; return
7424        // the FIFO path to the parent so it writes there.
7425        let fifo_path = format!(
7426            "/tmp/zshrs_psub_out_{}_{}",
7427            std::process::id(),
7428            with_executor(|e| {
7429                let n = e.process_sub_counter;
7430                e.process_sub_counter += 1;
7431                n
7432            })
7433        );
7434        let _ = fs::remove_file(&fifo_path);
7435        let cpath = match CString::new(fifo_path.clone()) {
7436            Ok(c) => c,
7437            Err(_) => return fifo_path,
7438        };
7439        if unsafe { libc::mkfifo(cpath.as_ptr(), 0o600) } != 0 {
7440            // Fall back to plain file if mkfifo fails.
7441            let _ = fs::write(&fifo_path, "");
7442            return fifo_path;
7443        }
7444        let sub = sub.clone();
7445        let fifo_for_child = fifo_path.clone();
7446        match unsafe { libc::fork() } {
7447            -1 => {
7448                let _ = fs::remove_file(&fifo_path);
7449            }
7450            0 => {
7451                // Child: open FIFO for read, dup onto stdin, run sub-chunk, exit.
7452                if let Ok(f) = fs::OpenOptions::new().read(true).open(&fifo_for_child) {
7453                    let fd = f.as_raw_fd();
7454                    unsafe {
7455                        libc::dup2(fd, libc::STDIN_FILENO);
7456                    }
7457                }
7458                crate::fusevm_disasm::maybe_print_stdout("process_subst_out:child", &sub);
7459                let mut vm = fusevm::VM::new(sub);
7460                register_builtins(&mut vm);
7461                vm.set_shell_host(Box::new(ZshrsHost));
7462                let _ = vm.run();
7463                unsafe { libc::_exit(0) };
7464            }
7465            _ => {
7466                // Parent — return path; child handles cleanup of the FIFO
7467                // once stdin EOFs. (The path may leak if the parent never
7468                // writes; acceptable for common `>(cmd)` idioms.)
7469            }
7470        }
7471        fifo_path
7472    }
7473
7474    fn subshell_begin(&mut self) {
7475        with_executor(|exec| {
7476            // libc::umask returns the previous mask AND sets the new
7477            // one; call with current value to read without changing.
7478            let cur_umask = unsafe {
7479                let m = libc::umask(0o022);
7480                libc::umask(m);
7481                m as u32
7482            };
7483            // Snapshot paramtab + hashed-storage too (step 1 of the
7484            // store unification mirrors writes there; restoring only
7485            // the HashMaps leaks subshell-scoped writes to the parent
7486            // via paramtab readers like `paramsubst → vars_get`).
7487            let paramtab_snap = crate::ported::params::paramtab()
7488                .read()
7489                .ok()
7490                .map(|t| t.clone())
7491                .unwrap_or_default();
7492            let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
7493                .lock()
7494                .ok()
7495                .map(|m| m.clone())
7496                .unwrap_or_default();
7497            exec.subshell_snapshots.push(SubshellSnapshot {
7498                paramtab: paramtab_snap,
7499                paramtab_hashed_storage: paramtab_hashed_snap,
7500                positional_params: exec.pparams(),
7501                env_vars: env::vars().collect(),
7502                // Save the LOGICAL pwd ($PWD env), not `current_dir()`'s
7503                // symlink-resolved path. zsh's subshell isolation per
7504                // Src/exec.c at the `entersubsh` path treats `pwd` (the
7505                // shell-tracked logical PWD) as the carrier — see
7506                // `Src/builtin.c:1239-1242` where cd writes the logical
7507                // dest into `pwd`. Falling back to current_dir() only
7508                // when PWD is unset matches `setupvals` at
7509                // `Src/init.c:1100+`.
7510                cwd: env::var("PWD")
7511                    .ok()
7512                    .map(PathBuf::from)
7513                    .or_else(|| env::current_dir().ok()),
7514                umask: cur_umask,
7515                // Snapshot canonical `traps_table` — bin_trap writes
7516                // there (`Src/builtin.c`).
7517                traps: crate::ported::builtin::traps_table()
7518                    .lock()
7519                    .map(|t| t.clone())
7520                    .unwrap_or_default(),
7521                // Snapshot option store so `(set -e)` /
7522                // `(setopt extendedglob)` don't leak to parent.
7523                opts: crate::ported::options::opt_state_snapshot(),
7524                // c:Src/exec.c — fork() copies the alias table to
7525                // the subshell. `(alias x=y)` inside the subshell
7526                // dies with the child; the parent doesn't see x.
7527                // Snapshot here so subshell_end can restore.
7528                // Bug #209 in docs/BUGS.md.
7529                aliases: crate::ported::hashtable::aliastab_lock()
7530                    .read()
7531                    .ok()
7532                    .map(|t| {
7533                        t.iter()
7534                            .map(|(k, v)| (k.clone(), v.text.clone()))
7535                            .collect()
7536                    })
7537                    .unwrap_or_default(),
7538                // c:Src/exec.c::entersubsh — same fork-copy
7539                //   semantics for shfunctab. `(f() { ... })` defined
7540                //   inside the subshell dies with the child; parent's
7541                //   `type f` reports "not found". Bug #208 in
7542                //   docs/BUGS.md.
7543                shfuncs: crate::ported::hashtable::shfunctab_lock()
7544                    .read()
7545                    .ok()
7546                    .map(|t| t.snapshot())
7547                    .unwrap_or_default(),
7548                functions_compiled: exec.functions_compiled.clone(),
7549                function_source: exec.function_source.clone(),
7550                // c:Src/exec.c::entersubsh — subshell forks its own
7551                // modulestab. A `(zmodload zsh/X)` inside the
7552                // subshell flips MOD_INIT_B on the CHILD's
7553                // modulestab; when the child exits the change
7554                // dies with it. zshrs's in-process subshell would
7555                // otherwise leak the load to the parent.
7556                // Bug #210 in docs/BUGS.md. Snapshot just the
7557                // (name → flags) pairs since the only mutating
7558                // field is the flags bitmask (MOD_INIT_B for
7559                // loaded, MOD_UNLOAD for unloaded).
7560                modules: crate::ported::module::MODULESTAB
7561                    .lock()
7562                    .ok()
7563                    .map(|t| {
7564                        t.modules
7565                            .iter()
7566                            .map(|(k, v)| (k.clone(), v.node.flags))
7567                            .collect()
7568                    })
7569                    .unwrap_or_default(),
7570                // c:Src/exec.c::entersubsh — fork-copy semantics for
7571                // THINGYTAB (ZLE widget registry). A subshell `zle -N`
7572                // / `zle -D` mutation dies with the child in C zsh;
7573                // mirror via in-process snapshot. Bug #453.
7574                thingytab: crate::ported::zle::zle_thingy::thingytab()
7575                    .lock()
7576                    .ok()
7577                    .map(|t| t.clone())
7578                    .unwrap_or_default(),
7579                // c:Src/exec.c::entersubsh — same fork-copy for the
7580                // KEYMAPNAMTAB (named keymap registry). `bindkey -N km`
7581                // / `bindkey -D km` inside a subshell dies with the
7582                // child. Bug #454.
7583                keymapnamtab: crate::ported::zle::zle_keymap::keymapnamtab()
7584                    .lock()
7585                    .ok()
7586                    .map(|t| t.clone())
7587                    .unwrap_or_default(),
7588            });
7589            // Subshell starts with EXIT trap cleared so the parent's
7590            // EXIT handler doesn't fire when the subshell ends. zsh:
7591            // each subshell has its own trap context. Other signals
7592            // are inherited (well, parent's are still in place — but
7593            // a trap set INSIDE the subshell shouldn't leak out).
7594            if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
7595                t.remove("EXIT");
7596            }
7597            let level = exec
7598                .scalar("ZSH_SUBSHELL")
7599                .and_then(|s| s.parse::<i32>().ok())
7600                .unwrap_or(0);
7601            // c:Src/exec.c — ZSH_SUBSHELL carries PM_READONLY (declared
7602            // in params.rs special_params); setsparam would be rejected
7603            // by assignstrvalue's PM_READONLY guard. Write u_val
7604            // directly — same bypass pattern as BUILTIN_SET_LINENO at
7605            // line 2784. C zsh's PM_SPECIAL GSU vtable handles this
7606            // implicitly via the setfn callback.
7607            let new_level = (level + 1) as i64;
7608            if let Ok(mut tab) = crate::ported::params::paramtab().write() {
7609                if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
7610                    pm.u_val = new_level;
7611                    pm.u_str = Some(new_level.to_string());
7612                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
7613                }
7614            }
7615        });
7616        // Bump SUBSHELL_DEPTH so zexit defers process::exit (see
7617        // SUBSHELL_DEPTH declaration in src/ported/builtin.rs for
7618        // rationale).
7619        crate::ported::builtin::SUBSHELL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
7620        // c:Src/exec.c::entersubsh — C zsh's subshell is a forked
7621        // child process: signals sent to the parent (via `kill $$`
7622        // inside the subshell, where `$$` is the parent's pid)
7623        // never reach the child's signal handlers. zshrs's
7624        // in-process subshell shares the process pid with the
7625        // parent, so without queueing the subshell's trap handler
7626        // fires for signals that zsh would deliver only to the
7627        // parent. Queue signals across the subshell body so the
7628        // parent's restored trap table sees them after
7629        // subshell_end's unqueue drain. Bug #450.
7630        crate::ported::signals_h::queue_signals();
7631    }
7632
7633    fn subshell_end(&mut self) -> Option<i32> {
7634        // Fire subshell's EXIT trap BEFORE restoring parent state so
7635        // the trap body sees the subshell's vars and exit status. zsh
7636        // forks for `(...)` so the trap runs in the child process,
7637        // before exit. We mirror by running it here, just before the
7638        // pop+restore. REMOVE the trap before firing so the inner
7639        // execute_script doesn't fire it again at its own end.
7640        let exit_trap_body = crate::ported::builtin::traps_table()
7641            .lock()
7642            .ok()
7643            .and_then(|mut t| t.remove("EXIT"));
7644        if let Some(body) = exit_trap_body {
7645            // Execute the trap body. Errors during trap execution
7646            // don't bubble — zsh ignores trap-body errors.
7647            with_executor(|exec| {
7648                let _ = exec.execute_script(&body);
7649            });
7650        }
7651        with_executor(|exec| {
7652            if let Some(snap) = exec.subshell_snapshots.pop() {
7653                // Restore paramtab + hashed storage so subshell-scoped
7654                // writes via setsparam/setaparam/sethparam don't leak
7655                // to the parent via paramtab readers.
7656                if let Some(tab) = crate::ported::params::paramtab()
7657                    .write()
7658                    .ok()
7659                    .as_deref_mut()
7660                {
7661                    *tab = snap.paramtab;
7662                }
7663                if let Some(m) = crate::ported::params::paramtab_hashed_storage()
7664                    .lock()
7665                    .ok()
7666                    .as_deref_mut()
7667                {
7668                    *m = snap.paramtab_hashed_storage;
7669                }
7670                exec.set_pparams(snap.positional_params);
7671                // Restore the OS env to its pre-subshell state.
7672                // Removes any `export` writes the subshell made, and
7673                // restores any vars the subshell unset. Without this
7674                // `(export y=sub)` would leak `y` to the parent shell.
7675                let current: HashMap<String, String> = env::vars().collect();
7676                for k in current.keys() {
7677                    if !snap.env_vars.contains_key(k) {
7678                        env::remove_var(k);
7679                    }
7680                }
7681                for (k, v) in &snap.env_vars {
7682                    if current.get(k) != Some(v) {
7683                        env::set_var(k, v);
7684                    }
7685                }
7686                if let Some(cwd) = snap.cwd {
7687                    let _ = env::set_current_dir(&cwd);
7688                    // Resync $PWD env so a parent `pwd` doesn't read
7689                    // the cwd the subshell `cd`'d into.
7690                    env::set_var("PWD", &cwd);
7691                }
7692                // Restore umask. zsh's `(umask 077)` doesn't leak to
7693                // parent because the subshell forks; we run in-process
7694                // so we manually reset.
7695                unsafe {
7696                    libc::umask(snap.umask as libc::mode_t);
7697                }
7698                // Restore parent's traps (the subshell's own traps die
7699                // with it). zsh: `(trap "X" USR1)` doesn't leak the
7700                // USR1 trap out of the subshell. Write back to the
7701                // canonical `traps_table` (bin_trap writes there).
7702                if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
7703                    *t = snap.traps;
7704                }
7705                // Restore parent's option store so `(set -e)` /
7706                // `(setopt extendedglob)` don't leak. zsh forks
7707                // subshells so child option changes die with the
7708                // child; we run in-process and must restore.
7709                crate::ported::options::opt_state_restore(snap.opts);
7710                // c:Src/exec.c — fork() means alias mutations in a
7711                // subshell die with the child. Restore parent's
7712                // alias table from snapshot. Clear current entries
7713                // then re-add parent's. Bug #209 in docs/BUGS.md.
7714                if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
7715                    tab.clear();
7716                    for (name, text) in snap.aliases {
7717                        tab.add(crate::ported::zsh_h::alias {
7718                            node: crate::ported::zsh_h::hashnode {
7719                                next: None,
7720                                nam: name,
7721                                flags: 0,
7722                            },
7723                            text,
7724                            inuse: 0,
7725                        });
7726                    }
7727                }
7728                // c:Src/exec.c::entersubsh — same fork-copy
7729                //   semantics for shfunctab. Restore parent's function
7730                //   table from snapshot so `(f() { ... })` definitions
7731                //   inside the subshell don't leak to the parent.
7732                //   Bug #208 in docs/BUGS.md.
7733                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
7734                    tab.restore(snap.shfuncs);
7735                }
7736                // Restore the runtime dispatch tables (compiled chunks
7737                // + source). Without these, a subshell-defined
7738                // override leaves its bytecode in place even after
7739                // shfunctab is restored — `g` after the subshell would
7740                // still run the override.
7741                exec.functions_compiled = snap.functions_compiled;
7742                exec.function_source = snap.function_source;
7743                // c:Src/exec.c::entersubsh — restore parent's
7744                // modulestab so a subshell `(zmodload zsh/X)` doesn't
7745                // leak to the parent. Bug #210 in docs/BUGS.md.
7746                // Restore via per-module flag write since the
7747                // snapshot is `(name → flags)` only.
7748                if let Ok(mut t) = crate::ported::module::MODULESTAB.lock() {
7749                    for (name, saved_flags) in &snap.modules {
7750                        if let Some(m) = t.modules.get_mut(name) {
7751                            m.node.flags = *saved_flags;
7752                        }
7753                    }
7754                }
7755                // c:Src/exec.c::entersubsh — restore parent's THINGYTAB
7756                // so a subshell's `zle -N w f` / `zle -D w` doesn't
7757                // affect the parent's widget registry. Bug #453.
7758                if let Ok(mut t) = crate::ported::zle::zle_thingy::thingytab().lock() {
7759                    *t = snap.thingytab;
7760                }
7761                // Same for KEYMAPNAMTAB. Bug #454.
7762                if let Ok(mut t) = crate::ported::zle::zle_keymap::keymapnamtab().lock() {
7763                    *t = snap.keymapnamtab;
7764                }
7765            }
7766        });
7767        // Decrement SUBSHELL_DEPTH. If a deferred subshell exit
7768        // landed inside (EXIT_PENDING set with depth > 0), promote
7769        // the deferred status into the subshell's exit status now
7770        // that we're at the boundary, then clear so the parent
7771        // continues. Matches C zsh's "subshell-exit-via-fork"
7772        // boundary where the child's process::exit(N) becomes
7773        // $WAITSTATUS / $? in the parent.
7774        crate::ported::builtin::SUBSHELL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
7775        // c:Src/exec.c — drain the signal queue against the now-
7776        // restored parent trap table. Pairs with the
7777        // queue_signals() call at the end of subshell_begin.
7778        // Any `kill $$` from inside the subshell is processed
7779        // here against OUTER's trap, matching C zsh's
7780        // signal-delivery-to-parent semantics. Bug #450.
7781        crate::ported::signals_h::unqueue_signals();
7782        let exit_pending =
7783            crate::ported::builtin::EXIT_PENDING.load(std::sync::atomic::Ordering::Relaxed);
7784        if exit_pending != 0 {
7785            // c:Src/builtin.c — `exit N` masks N to 8 bits because
7786            // POSIX _exit takes the low byte as status. `(exit 256)`
7787            // and `(exit 0)` are indistinguishable to the parent;
7788            // `(exit 257)` exits with 1. Without the mask zshrs's
7789            // in-process subshell propagated the full i32 (256) into
7790            // the parent's $?, diverging from zsh.
7791            let raw = crate::ported::builtin::EXIT_VAL.load(std::sync::atomic::Ordering::Relaxed);
7792            let val = raw & 0xFF;
7793            with_executor(|exec| exec.set_last_status(val));
7794            crate::ported::builtin::EXIT_PENDING.store(0, std::sync::atomic::Ordering::Relaxed);
7795            crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
7796            crate::ported::builtin::BREAKS.store(0, std::sync::atomic::Ordering::Relaxed);
7797            // Set the post-subshell-exit guard. The next GET_VAR
7798            // sync_status path consults this to skip its
7799            // vm.last_status→LASTVAL sync (which would overwrite the
7800            // deferred-exit status we just set with stale vm state
7801            // since SubshellEnd doesn't propagate status into the
7802            // VM). Cleared as soon as the next sync_status sees it.
7803            SUBSHELL_EXIT_STATUS_PENDING.with(|c| c.set(true));
7804            // Return the deferred-exit status so the VM updates its
7805            // own `last_status`. Otherwise run_chunk's post-script
7806            // `set_last_status(vm.last_status)` would clobber LASTVAL
7807            // back to the stale pre-subshell value.
7808            return Some(val);
7809        }
7810        None
7811    }
7812
7813    fn redirect(&mut self, fd: u8, op: u8, target: &str) {
7814        // Apply a redirection at the OS level for the next command/builtin.
7815        // The host tracks saved fds in a per-executor stack so a future
7816        // `with_redirects_end` can restore. For now, this is a thin wrapper
7817        // that performs the dup2; pairing with explicit save/restore is
7818        // delivered by `with_redirects_begin/end`.
7819        with_executor(|exec| exec.host_apply_redirect(fd, op, target));
7820    }
7821
7822    fn with_redirects_begin(&mut self, count: u8) {
7823        with_executor(|exec| exec.host_redirect_scope_begin(count));
7824    }
7825
7826    fn regex_match(&mut self, s: &str, regex: &str) -> bool {
7827        // c:Src/Modules/regex.c:54 `zcond_regex_match` — POSIX ERE
7828        // matching + populate `$MATCH` / `$MBEGIN` / `$MEND` /
7829        // `$match[]` / `$mbegin[]` / `$mend[]` (or `$BASH_REMATCH`
7830        // under BASHREMATCH). Direct delegation to the canonical
7831        // port at src/ported/modules/regex.rs:58.
7832        //
7833        // The bridge passthru path delivers TOKEN-form bytes here
7834        // (Inbrack \u{91}, Outbrack \u{92}, Star \u{87}, Quest
7835        // \u{86}, etc.) since the lexer tokenizes regex meta chars
7836        // inside `[[ ]]`. The host regex engine expects ASCII, so
7837        // untokenize the pattern (and subject, for safety) once at
7838        // this boundary. zsh C reaches its POSIX-ERE engine through
7839        // the same untokenize path inside zcond_regex_match.
7840        let s_clean = crate::lex::untokenize(s);
7841        let regex_clean = crate::lex::untokenize(regex);
7842        crate::ported::modules::regex::zcond_regex_match(
7843            &[s_clean.as_str(), regex_clean.as_str()],
7844            crate::ported::modules::regex::ZREGEX_EXTENDED,
7845        ) != 0
7846    }
7847
7848    fn with_redirects_end(&mut self) {
7849        with_executor(|exec| exec.host_redirect_scope_end());
7850        // c:Src/exec.c:5172 — if any redirect in this scope failed
7851        // (noclobber-blocked, ENOENT for read, etc.), the command's
7852        // exit status is forced to 1 regardless of what the (still-
7853        // executed) command's own exit was. C zsh prevents the
7854        // command from running at all when a redirect fails; the
7855        // Rust port still runs it (sinking output to /dev/null in
7856        // the noclobber arm at host_apply_redirect:5481) and then
7857        // overrides $? here. Same observable effect for the common
7858        // pattern `echo x > existing-file` under noclobber.
7859        let failed = with_executor(|exec| {
7860            let f = exec.redirect_failed;
7861            exec.redirect_failed = false;
7862            f
7863        });
7864        if failed {
7865            with_executor(|exec| exec.set_last_status(1));
7866        }
7867    }
7868
7869    fn heredoc(&mut self, content: &str) {
7870        // C `Src/exec.c:4641` — `parsestr(&buf)` runs parameter +
7871        // command substitution on the heredoc body. The lexer's
7872        // quoted-delimiter detection (`<<'EOF'`) routes through the
7873        // `Op::HereDoc` path in `compile_zsh.rs` which short-circuits
7874        // before reaching here; unquoted forms route through the
7875        // BUILTIN_EXPAND_TEXT mode-4 emit path that calls singsub.
7876        // This handler covers the verbatim/quoted case.
7877        with_executor(|exec| exec.host_set_pending_stdin(content.to_string()));
7878    }
7879
7880    fn herestring(&mut self, content: &str) {
7881        // Shell semantics: herestring appends a newline. `<<<` body
7882        // substitution (`Src/exec.c:4655 getherestr` calls
7883        // `quotesubst` + `untokenize`) lands here verbatim; the
7884        // upstream compiler routes through `Op::HereString` after
7885        // BUILTIN_EXPAND_TEXT for the substitution pass, so callers
7886        // of `host.herestring` see the already-expanded form.
7887        let mut s = content.to_string();
7888        s.push('\n');
7889        with_executor(|exec| exec.host_set_pending_stdin(s));
7890    }
7891
7892    fn exec(&mut self, args: Vec<String>) -> i32 {
7893        // c:Src/subst.c paramsubst — when `${var:?msg}` or `${var?msg}`
7894        // triggered the "parameter null or not set" error, errflag
7895        // is raised and zsh aborts the simple command without
7896        // attempting exec. The expansion may have produced empty
7897        // argv[0] which falls into the c:?/permission-denied path
7898        // below, masking the real diagnostic with a spurious
7899        // "permission denied:" line and rc=126 instead of rc=1.
7900        // Honour errflag here so the script ends with the
7901        // paramsubst error as the sole diagnostic. Bug #86.
7902        //
7903        // c:Src/exec.c — C's execlist loop clears ERRFLAG_ERROR
7904        // between sublists when the error came from a NOMATCH-style
7905        // command failure (glob no-match, etc.) so subsequent
7906        // sublists run. zshrs's vm dispatch handles this at the
7907        // post-command-boundary HERE: if THIS command has its
7908        // `current_command_glob_failed` cell set (meaning the glob
7909        // NOMATCH happened during this command's argv prep), surface
7910        // status 1 and clear BOTH the cell AND ERRFLAG_ERROR so the
7911        // NEXT exec call sees a clean state. The errflag from
7912        // genuine script-fatal errors (parse, redirect, paramsubst
7913        // `${:?msg}`) does NOT come paired with glob_failed, so
7914        // those still short-circuit + propagate.
7915        let glob_failed = with_executor(|exec| {
7916            let f = exec.current_command_glob_failed.get();
7917            exec.current_command_glob_failed.set(false);
7918            f
7919        });
7920        if glob_failed {
7921            crate::ported::utils::errflag.fetch_and(
7922                !crate::ported::zsh_h::ERRFLAG_ERROR,
7923                std::sync::atomic::Ordering::Relaxed,
7924            );
7925            with_executor(|exec| exec.set_last_status(1));
7926            return 1;
7927        }
7928        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
7929            & crate::ported::zsh_h::ERRFLAG_ERROR)
7930            != 0
7931        {
7932            return 1;
7933        }
7934        // c:Src/exec.c — two distinct empty-command cases:
7935        //
7936        // 1. args=[""]  — an explicit empty-string command word
7937        //    (`""`, `"\$unset"`, `\$'\$x'`). zsh attempts exec(2)
7938        //    on the empty path → EACCES → "permission denied", \$?
7939        //    = 126.
7940        //
7941        // 2. args=[]    — the WORD LIST is empty (unquoted \$(\$cmd)
7942        //    that produced empty, or an unquoted unset \$var that
7943        //    elided). zsh: no exec is attempted; \$? becomes the
7944        //    last cmd-subst's exit status (the inner sub-VM
7945        //    already set last_status), and the line completes
7946        //    silently. Critically NOT 126.
7947        if args.is_empty() {
7948            // c:Src/exec.c — empty word list passes through to a
7949            // no-op; preserve whatever the inner cmd-subst's exit
7950            // is. Return last_status so the caller's SetStatus
7951            // round-trips correctly.
7952            return with_executor(|exec| exec.last_status());
7953        }
7954        if args[0].is_empty() {
7955            let script_name =
7956                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
7957            let lineno: u64 = with_executor(|exec| {
7958                exec.scalar("LINENO")
7959                    .and_then(|s| s.parse::<u64>().ok())
7960                    .unwrap_or(1)
7961            });
7962            eprintln!("{}:{}: permission denied: ", script_name, lineno);
7963            return 126;
7964        }
7965        // c:Src/exec.c — when any redirect in the current scope
7966        // failed (e.g. noclobber blocked a `>` overwrite), zsh
7967        // refuses to execute the command and exits with status 1.
7968        // The Rust port still applied the command (writing to the
7969        // /dev/null sink installed by host_apply_redirect's
7970        // noclobber arm), but the success status overwrote the
7971        // intended `1`. Short-circuit here so the exec returns 1
7972        // without running the body.
7973        let redir_failed = with_executor(|exec| {
7974            let f = exec.redirect_failed;
7975            exec.redirect_failed = false;
7976            f
7977        });
7978        if redir_failed {
7979            return 1;
7980        }
7981        // Track `$_` as the last argument of the last command (zsh /
7982        // bash convention). Empty arglists leave it untouched.
7983        if let Some(last) = args.last() {
7984            with_executor(|exec| {
7985                exec.set_scalar("_".to_string(), last.clone());
7986            });
7987        }
7988        // Route external command spawning through `executor.execute_external`
7989        // so intercepts (AOP before/after/around), command_hash lookups,
7990        // pre/postexec hooks, and zsh-specific fork-then-exec all apply.
7991        // Without this override, fusevm's default `host.exec` calls
7992        // `Command::new` directly, bypassing zshrs's dispatch logic.
7993        let status = with_executor(|exec| exec.host_exec_external(&args));
7994        // c:Src/jobs.c:1748 waitonejob (no-procs else-branch). zshrs's
7995        // exec model routes external commands through host_exec_external
7996        // (which already waitpid'd in-line); the canonical waitonejob
7997        // expects a Job to derive lastval, but here we already know
7998        // it. Synthesize a procs-less job so waitonejob's no-procs
7999        // branch fires the `pipestats[0]=lastval; numpipestats=1;`
8000        // update via the canonical port.
8001        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
8002        let mut synth = crate::ported::zsh_h::job::default();
8003        crate::ported::jobs::waitonejob(&mut synth);
8004        status
8005    }
8006
8007    fn cmd_subst(&mut self, sub: &fusevm::Chunk) -> String {
8008        // Run the sub-chunk on a nested VM with the same host wired up,
8009        // capturing stdout. The current executor remains active via the
8010        // thread-local — the nested VM uses CallBuiltin to dispatch shell
8011        // ops back through `with_executor`.
8012        let (read_end, write_end) = match os_pipe::pipe() {
8013            Ok(p) => p,
8014            Err(_) => return String::new(),
8015        };
8016        let saved_stdout = unsafe { libc::dup(libc::STDOUT_FILENO) };
8017        if saved_stdout < 0 {
8018            return String::new();
8019        }
8020        let saved_stderr = unsafe { libc::dup(libc::STDERR_FILENO) };
8021        let write_fd = AsRawFd::as_raw_fd(&write_end);
8022        unsafe {
8023            libc::dup2(write_fd, libc::STDOUT_FILENO);
8024        }
8025        drop(write_end);
8026
8027        // c:Bug #56 — publish the saved outer fds so a trap firing
8028        // during the nested VM run can route its body output to the
8029        // PARENT's stdout instead of the cmdsub's pipe-bound fd 1.
8030        // zsh's forked cmdsub gets this for free (trap runs in the
8031        // parent process whose fd 1 is untouched). zshrs's
8032        // in-process cmdsub needs this thread-local stack so the
8033        // trap dispatcher can find the right destination fd.
8034        CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().push((saved_stdout, saved_stderr)));
8035
8036        crate::fusevm_disasm::maybe_print_stdout("host.cmd_subst", sub);
8037        let mut vm = fusevm::VM::new(sub.clone());
8038        register_builtins(&mut vm);
8039        vm.set_shell_host(Box::new(ZshrsHost));
8040        let _ = vm.run();
8041        let cmd_status = vm.last_status;
8042
8043        CMDSUBST_OUTER_FDS.with(|s| {
8044            s.borrow_mut().pop();
8045        });
8046
8047        unsafe {
8048            libc::dup2(saved_stdout, libc::STDOUT_FILENO);
8049            libc::close(saved_stdout);
8050            if saved_stderr >= 0 {
8051                libc::close(saved_stderr);
8052            }
8053        }
8054
8055        // Inner cmd's status not propagated for the same reason as
8056        // run_command_substitution — see GAPS.md.
8057        let _ = cmd_status;
8058
8059        let mut buf = String::new();
8060        let mut reader = read_end;
8061        let _ = reader.read_to_string(&mut buf);
8062        // Strip trailing newlines (POSIX command substitution semantics)
8063        while buf.ends_with('\n') {
8064            buf.pop();
8065        }
8066        buf
8067    }
8068
8069    fn call_function(&mut self, name: &str, args: Vec<String>) -> Option<i32> {
8070        // c:Src/exec.c — when the command word is empty (e.g. `""`
8071        // or `"$nonexistent"`), zsh attempts the exec(2) which
8072        // returns EACCES ("permission denied") and exits 126. The
8073        // Rust port silently treated empty as a no-op (status 0).
8074        // Match zsh by emitting the diagnostic and returning 126.
8075        if name.is_empty() {
8076            let script_name =
8077                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
8078            let lineno: u64 = with_executor(|exec| {
8079                exec.scalar("LINENO")
8080                    .and_then(|s| s.parse::<u64>().ok())
8081                    .unwrap_or(1)
8082            });
8083            eprintln!("{}:{}: permission denied: ", script_name, lineno);
8084            with_executor(|exec| exec.set_last_status(126));
8085            return Some(126);
8086        }
8087        // c:Src/exec.c — redirect failure in this scope means the
8088        // command should NOT run. The Host::exec path already has
8089        // this gate (at fn exec above); call_function takes external
8090        // commands like `cat <&3` through a different code path, so
8091        // gate here too. Without this, bad-fd redirects produced
8092        // the diagnostic but the external command still ran, so $?
8093        // came out from the command's natural exit instead of the
8094        // forced 1.
8095        let redir_failed = with_executor(|exec| {
8096            let f = exec.redirect_failed;
8097            exec.redirect_failed = false;
8098            f
8099        });
8100        if redir_failed {
8101            with_executor(|exec| exec.set_last_status(1));
8102            return Some(1);
8103        }
8104        // zsh-bundled rename helpers + zcalc: short-circuit BEFORE the
8105        // function/autoload lookup so the autoloaded zsh source (which
8106        // can hang zshrs's parser on zsh-specific syntax) never runs.
8107        // Native Rust impls live in builtin_zmv / builtin_zcalc.
8108        match name {
8109            "zmv" => {
8110                return Some(crate::extensions::ext_builtins::zmv(&args, "mv"));
8111            }
8112            "zcp" => {
8113                return Some(crate::extensions::ext_builtins::zmv(&args, "cp"));
8114            }
8115            "zln" => {
8116                return Some(crate::extensions::ext_builtins::zmv(&args, "ln"));
8117            }
8118            "zcalc" => {
8119                return Some(crate::extensions::ext_builtins::zcalc(&args));
8120            }
8121            // ztest framework (src/extensions/ztest.rs — port of
8122            // ../strykelang's unit-test framework). All zassert_*/
8123            // ztest_* names route through the single try_dispatch
8124            // helper so adding/removing assertions only touches
8125            // ztest.rs.
8126            n if crate::extensions::ztest::try_dispatch_known(n) => {
8127                let status = with_executor(|exec| {
8128                    crate::extensions::ztest::try_dispatch(exec, n, &args).unwrap_or(1)
8129                });
8130                return Some(status);
8131            }
8132            // Daemon-managed z* builtins — thin IPC wrappers. Short-circuit BEFORE
8133            // the function-lookup path so a missing daemon doesn't fall through to
8134            // "command not found". The name list is owned by the daemon crate
8135            // (zshrs_daemon::builtins::ZSHRS_BUILTIN_NAMES); routing through
8136            // try_dispatch keeps this site zero-touch as new z* builtins land.
8137            n if crate::daemon::builtins::is_zshrs_builtin(n) => {
8138                let argv: Vec<String> = std::iter::once(name.to_string()).chain(args).collect();
8139                return Some(crate::daemon::builtins::try_dispatch(n, &argv).unwrap_or(1));
8140            }
8141            _ => {}
8142        }
8143
8144        // c:Src/exec.c:3050-3068 — module-provided builtins (registered
8145        // via each module's `bintab` and folded into the canonical
8146        // `builtintab` by `createbuiltintable`) must dispatch BEFORE
8147        // PATH lookup. fusevm's `shell_builtins::builtin_id` doesn't
8148        // know about per-module entries like `log`
8149        // (Src/Modules/watch.c:693) — they reach call_function as
8150        // CallFunction ops. Consult the merged builtintab here so
8151        // `log` runs the canonical `bin_log` instead of falling
8152        // through to `/usr/bin/log` on macOS. Bug #72 in docs/BUGS.md.
8153        //
8154        // User-defined functions still take precedence over builtins
8155        // (zsh's `alias → function → builtin → external` resolution
8156        // order, c:Src/exec.c:3038-3068). Check `functions_compiled`
8157        // first so a user `log() { ... }` shadows the module bin_log.
8158        // c:Src/exec.c — shfunctab->getnode (the DISABLED-filtering
8159        // accessor) returns NULL for entries flipped to DISABLED via
8160        // `disable -f NAME`. functions_compiled holds the body
8161        // independently of the DISABLED flag, so check shfunctab first
8162        // and mask the lookup when the entry is disabled. Bug #221
8163        // in docs/BUGS.md.
8164        let user_fn_disabled = crate::ported::hashtable::shfunctab_lock()
8165            .read()
8166            .ok()
8167            .and_then(|t| {
8168                let entry = t.get_including_disabled(name)?;
8169                Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
8170            })
8171            .unwrap_or(false);
8172        let has_user_fn = !user_fn_disabled
8173            && with_executor(|exec| exec.functions_compiled.contains_key(name));
8174        if !has_user_fn {
8175            // c:Src/exec.c:3056 — `builtintab->getnode(builtintab,
8176            // cmdarg)` returns NULL for DISABLED entries, falling
8177            // execcmd through to PATH lookup. Mirror by gating the
8178            // bn_in_tab match on the BUILTINS_DISABLED set. Bug #106
8179            // in docs/BUGS.md.
8180            let disabled = crate::ported::builtin::BUILTINS_DISABLED
8181                .lock()
8182                .map(|s| s.contains(name))
8183                .unwrap_or(false);
8184            let bn_in_tab = !disabled
8185                && crate::ported::builtin::createbuiltintable().contains_key(name);
8186            if bn_in_tab {
8187                return Some(dispatch_builtin_raw(name, args));
8188            }
8189        }
8190
8191        // c:Src/lex.c — alias expansion is a LEXER-TIME pass, not a
8192        // run-time lookup. zsh parses the whole `-c` argument (or
8193        // script) before executing, so aliases defined in the same
8194        // parse unit don't apply to commands parsed earlier. Only at
8195        // an INTERACTIVE prompt does each line parse separately with
8196        // the latest aliastab visible.
8197        //
8198        // Gate the run-time alias-rewrite path on `interactive` so
8199        // `alias hi='echo hello'; hi` in `-c` mode falls through to
8200        // "command not found" (matching zsh) while interactive REPL
8201        // input still re-parses with the live aliastab.
8202        let interactive = crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE);
8203        let already_expanding = if interactive {
8204            crate::ported::hashtable::aliastab_lock()
8205                .read()
8206                .ok()
8207                .and_then(|tab| tab.get(name).map(|a| a.inuse != 0))
8208                .unwrap_or(false)
8209        } else {
8210            true // suppress lookup entirely in non-interactive mode
8211        };
8212        let alias_body = if already_expanding {
8213            None
8214        } else {
8215            with_executor(|exec| exec.alias(name))
8216        };
8217        if let Some(body) = alias_body {
8218            let combined = if args.is_empty() {
8219                body
8220            } else {
8221                let quoted: Vec<String> = args
8222                    .iter()
8223                    .map(|a| {
8224                        let escaped = a.replace('\'', "'\\''");
8225                        format!("'{}'", escaped)
8226                    })
8227                    .collect();
8228                format!("{} {}", body, quoted.join(" "))
8229            };
8230            // Bump inuse → run → clear, matching C's lexer behavior.
8231            if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
8232                if let Some(a) = tab.get_mut(name) {
8233                    a.inuse += 1;
8234                }
8235            }
8236            let status = with_executor(|exec| exec.execute_script(&combined).unwrap_or(1));
8237            if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
8238                if let Some(a) = tab.get_mut(name) {
8239                    a.inuse = (a.inuse - 1).max(0);
8240                }
8241            }
8242            return Some(status);
8243        }
8244
8245        // $_ pre-body bump and pending-underscore tracking are
8246        // ZshrsHost-only concerns (prompt rendering). Apply BEFORE
8247        // delegating to dispatch_function_call so the body sees the
8248        // bumped value.
8249        //
8250        // c:Src/exec.c:3491 — `setunderscore((args && nonempty(args))
8251        // ? ((char *) getdata(lastnode(args))) : "")`. C sets $_ to
8252        // the LAST node of the WHOLE args list (which includes argv[0]
8253        // == the function name). So for a no-arg `f`, $_ becomes "f"
8254        // inside the function body. The Rust port at the CallFunction
8255        // op-handler receives `args` WITHOUT the command name
8256        // (compile_zsh.rs:1571 only pushes simple.words[1..]). The
8257        // last() fallback `|| fn_name.clone()` already covers the
8258        // no-arg case, but `exec.set_scalar("_", ...)` writes paramtab
8259        // — the canonical `$_` read goes through `underscoregetfn`
8260        // (params.rs:7836) which reads the `zunderscore` Mutex.
8261        // setsparam("_") doesn't update that mutex, so the body's
8262        // `${_}` returned empty. Bug #279 in docs/BUGS.md. Mirror the
8263        // C `setunderscore` by writing via `set_zunderscore` directly.
8264        let fn_name = name.to_string();
8265        with_executor(|exec| {
8266            let dollar_underscore = args.last().cloned().unwrap_or_else(|| fn_name.clone());
8267            exec.set_scalar("_".to_string(), dollar_underscore.clone());
8268            crate::ported::params::set_zunderscore(std::slice::from_ref(&dollar_underscore));
8269            exec.pending_underscore = Some(dollar_underscore);
8270        });
8271
8272        // Delegate the actual function dispatch to the canonical
8273        // `dispatch_function_call` (which itself wraps the canonical
8274        // `doshfunc` port from `Src/exec.c:5823`). Single doshfunc
8275        // call-site keeps scope-mgmt invariants in one place.
8276        let status = with_executor(|exec| exec.dispatch_function_call(&fn_name, &args));
8277
8278        // $_ post-body — last call-arg or function name. Mirrors the
8279        // C `setunderscore` invocation after the body returns.
8280        with_executor(|exec| {
8281            let last_call_arg = args.last().cloned().unwrap_or_else(|| fn_name.clone());
8282            exec.set_scalar("_".to_string(), last_call_arg.clone());
8283            exec.pending_underscore = Some(last_call_arg);
8284        });
8285
8286        status
8287    }
8288}
8289
8290// ───────────────────────────────────────────────────────────────────────────
8291// Host-routed shell ops: ShellExecutor methods invoked by ZshrsHost from the
8292// fusevm VM. Not a port of Src/exec.c (see file-level docs above) — they're
8293// the bridge between fusevm opcodes and ShellExecutor state.
8294// ───────────────────────────────────────────────────────────────────────────
8295impl ShellExecutor {
8296    // ─── Host-routed shell ops (called by ZshrsHost from fusevm) ────────────
8297
8298    /// Apply a single redirection. The current scope's saved-fd vec gets a
8299    /// dup of the original fd so it can be restored by `host_redirect_scope_end`.
8300    /// `op_byte` matches `fusevm::op::redirect_op::*`.
8301    /// Apply a file-open result to a redirect fd; on error, emit
8302    /// zsh-format diagnostic, set redirect_failed, sink fd to /dev/null.
8303    /// Shared between WRITE/APPEND/READ/CLOBBER arms in
8304    /// host_apply_redirect to keep the error-handling identical.
8305    fn redir_open_or_fail(
8306        fd: i32,
8307        result: std::io::Result<fs::File>,
8308        target: &str,
8309        redirect_failed: &mut bool,
8310    ) -> bool {
8311        match result {
8312            Ok(file) => {
8313                let new_fd = file.into_raw_fd();
8314                unsafe {
8315                    libc::dup2(new_fd, fd);
8316                    libc::close(new_fd);
8317                }
8318                true
8319            }
8320            Err(e) => {
8321                let msg = match e.kind() {
8322                    std::io::ErrorKind::PermissionDenied => "permission denied",
8323                    std::io::ErrorKind::NotFound => "no such file or directory",
8324                    std::io::ErrorKind::IsADirectory => "is a directory",
8325                    _ => "redirect failed",
8326                };
8327                eprintln!("{}:1: {}: {}", shname(), msg, target);
8328                *redirect_failed = true;
8329                if let Ok(devnull) = fs::OpenOptions::new()
8330                    .read(true)
8331                    .write(true)
8332                    .open("/dev/null")
8333                {
8334                    let new_fd = devnull.into_raw_fd();
8335                    unsafe {
8336                        libc::dup2(new_fd, fd);
8337                        libc::close(new_fd);
8338                    }
8339                }
8340                false
8341            }
8342        }
8343    }
8344    /// `host_apply_redirect` — see implementation.
8345    pub fn host_apply_redirect(&mut self, fd: u8, op_byte: u8, target: &str) {
8346        // `&>` / `&>>` always target both fd 1 and fd 2 regardless of the
8347        // fd byte the parser supplied (the lexer's tokfd clamp makes the
8348        // raw value unreliable for these forms).
8349        let fd: i32 = if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
8350            1
8351        } else {
8352            fd as i32
8353        };
8354        // c:Src/exec.c — for DUP_READ / DUP_WRITE forms (<&N / >&N),
8355        // validate the source fd is open BEFORE the save-and-dup
8356        // dance below. The save's `dup(fd)` reclaims the lowest free
8357        // fd, which on closed-fd reuse would let dup2(src=N, …)
8358        // succeed against the freshly-claimed slot — masking the
8359        // user's "bad file descriptor" error. Check src_fd first.
8360        if matches!(op_byte, r::DUP_READ | r::DUP_WRITE) {
8361            let n_check = target.trim_start_matches('&');
8362            if n_check != "-" {
8363                if let Ok(src_fd) = n_check.parse::<i32>() {
8364                    if unsafe { libc::fcntl(src_fd, libc::F_GETFD) } == -1 {
8365                        eprintln!("{}:1: {}: bad file descriptor", shname(), src_fd);
8366                        self.set_last_status(1);
8367                        self.redirect_failed = true;
8368                        return;
8369                    }
8370                }
8371            }
8372        }
8373        let saved = unsafe { libc::dup(fd) };
8374        if saved >= 0 {
8375            if let Some(top) = self.redirect_scope_stack.last_mut() {
8376                top.push((fd, saved));
8377            } else {
8378                // No scope — leave saved fd open and let the next scope
8379                // reclaim it. (Caller without a scope leaks the dup; this
8380                // matches `WithRedirects` parser construction always wrapping.)
8381                unsafe { libc::close(saved) };
8382            }
8383        }
8384        // For `&>` / `&>>` also save fd 2 so the scope restores it after
8385        // the body. Otherwise stderr stays redirected past the command.
8386        if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
8387            let saved2 = unsafe { libc::dup(2) };
8388            if saved2 >= 0 {
8389                if let Some(top) = self.redirect_scope_stack.last_mut() {
8390                    top.push((2, saved2));
8391                } else {
8392                    unsafe { libc::close(saved2) };
8393                }
8394            }
8395        }
8396        match op_byte {
8397            r::WRITE => {
8398                // Honor `setopt noclobber`: refuse to overwrite an
8399                // existing regular file unless `>!` / `>|` (CLOBBER).
8400                // zsh internally stores the inverted-name `clobber`
8401                // (default ON); `setopt noclobber` writes
8402                // `clobber=false`. Honor both keys.
8403                //
8404                // c:Src/exec.c:2241-2245 clobber_open recover path:
8405                // after O_EXCL fails, reopen and `if (!S_ISREG(...))
8406                // return fd;` — non-regular targets (char/block-
8407                // special, FIFO, socket) bypass the noclobber check.
8408                // Bug #30 in docs/BUGS.md: this bridge-side check did
8409                // a bare `Path::exists()` and treated `/dev/null` as
8410                // a protected file, breaking `setopt no_clobber; echo
8411                // hi > /dev/null` and every `2> /dev/null` idiom.
8412                // Add a regular-file stat gate that matches the C
8413                // semantic. The canonical clobber_open at
8414                // src/ported/exec.rs:2123 already handles this; the
8415                // bridge duplicates a stripped-down version here and
8416                // must mirror the same check.
8417                let noclobber = opt_state_get("noclobber").unwrap_or(false)
8418                    || !opt_state_get("clobber").unwrap_or(true);
8419                let target_is_regular_file = std::fs::metadata(target)
8420                    .map(|m| m.file_type().is_file())
8421                    .unwrap_or(false);
8422                if noclobber && target_is_regular_file {
8423                    eprintln!("{}:1: file exists: {}", shname(), target);
8424                    self.set_last_status(1);
8425                    // c:Src/exec.c — set redirect_failed so the scope-end
8426                    // hook (`with_redirects_end` in this file) forces
8427                    // $? to 1 regardless of the still-running command's
8428                    // own exit. Without this the next command (e.g.
8429                    // `echo x` writing to /dev/null below) succeeds
8430                    // and overwrites the redirect-failure status,
8431                    // making noclobber unobservable from $?.
8432                    self.redirect_failed = true;
8433                    // Sink the upcoming command's stdout to /dev/null
8434                    // so we don't leak its output to the terminal.
8435                    // zsh skips the command entirely; we approximate by
8436                    // discarding the output (the redirect target was
8437                    // the user's chosen sink, but with noclobber the
8438                    // file is protected — discarding matches the
8439                    // user's intent better than printing to terminal).
8440                    if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
8441                        let new_fd = file.into_raw_fd();
8442                        unsafe {
8443                            libc::dup2(new_fd, fd);
8444                            libc::close(new_fd);
8445                        }
8446                    }
8447                    return;
8448                }
8449                if !Self::redir_open_or_fail(
8450                    fd,
8451                    fs::File::create(target),
8452                    target,
8453                    &mut self.redirect_failed,
8454                ) {
8455                    self.set_last_status(1);
8456                }
8457            }
8458            r::CLOBBER => {
8459                if !Self::redir_open_or_fail(
8460                    fd,
8461                    fs::File::create(target),
8462                    target,
8463                    &mut self.redirect_failed,
8464                ) {
8465                    self.set_last_status(1);
8466                }
8467            }
8468            r::APPEND => {
8469                // c:Src/exec.c:3924-3927 — `>>` honors NO_CLOBBER+!APPENDCREATE
8470                // by opening O_APPEND|O_WRONLY WITHOUT O_CREAT, so missing
8471                // files yield ENOENT. zsh source:
8472                //   if (!isset(CLOBBER) && !isset(APPENDCREATE) &&
8473                //       !IS_CLOBBER_REDIR(fn->type))
8474                //       mode = O_WRONLY|O_APPEND|O_NOCTTY;
8475                //   else mode = O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY;
8476                // (IS_CLOBBER_REDIR — `>>!`/`>>|` — is currently flattened
8477                // to plain APPEND at compile time in
8478                // src/extensions/compile_zsh.rs:1654-1655, so the bang/pipe
8479                // forms can't be distinguished here yet.)
8480                let noclobber = opt_state_get("noclobber").unwrap_or(false)
8481                    || !opt_state_get("clobber").unwrap_or(true);
8482                let append_create = opt_state_get("appendcreate").unwrap_or(false)
8483                    || opt_state_get("append_create").unwrap_or(false);
8484                let open_result = if noclobber && !append_create {
8485                    fs::OpenOptions::new().append(true).open(target) // no create
8486                } else {
8487                    fs::OpenOptions::new()
8488                        .create(true)
8489                        .append(true)
8490                        .open(target)
8491                };
8492                if !Self::redir_open_or_fail(
8493                    fd,
8494                    open_result,
8495                    target,
8496                    &mut self.redirect_failed,
8497                ) {
8498                    self.set_last_status(1);
8499                }
8500            }
8501            r::READ => {
8502                if !Self::redir_open_or_fail(
8503                    fd,
8504                    fs::File::open(target),
8505                    target,
8506                    &mut self.redirect_failed,
8507                ) {
8508                    self.set_last_status(1);
8509                }
8510            }
8511            r::READ_WRITE => {
8512                if let Ok(file) = fs::OpenOptions::new()
8513                    .create(true)
8514                    .truncate(false) // <> opens existing-or-new without truncating
8515                    .read(true)
8516                    .write(true)
8517                    .open(target)
8518                {
8519                    let new_fd = file.into_raw_fd();
8520                    unsafe {
8521                        libc::dup2(new_fd, fd);
8522                        libc::close(new_fd);
8523                    }
8524                }
8525            }
8526            r::DUP_READ | r::DUP_WRITE => {
8527                // Target is a numeric fd reference like `&3`. The parser
8528                // strips the `&` prefix before we get here in some paths,
8529                // others retain it — accept both. Also support `-` for
8530                // close-fd (`<&-` / `>&-`) per POSIX. The src_fd
8531                // validity check ran above before the save-and-dup.
8532                let n = target.trim_start_matches('&');
8533                if n == "-" {
8534                    unsafe { libc::close(fd) };
8535                } else if n == "p" {
8536                    // c:Src/exec.c — `<&p` / `>&p` route through the
8537                    // coprocin / coprocout globals. zsh's `coproc CMD`
8538                    // launch publishes those fds; the canonical
8539                    // bin_print / bin_read `-p` arms already consume
8540                    // them. The DUP redirect form is the third
8541                    // consumer: it must dup the coproc fd onto the
8542                    // target slot so the next command's stdin/stdout
8543                    // is wired to the running coprocess. Bug #388.
8544                    let coproc_fd = if op_byte == r::DUP_READ {
8545                        crate::ported::modules::clone::coprocin
8546                            .load(std::sync::atomic::Ordering::Relaxed)
8547                    } else {
8548                        crate::ported::modules::clone::coprocout
8549                            .load(std::sync::atomic::Ordering::Relaxed)
8550                    };
8551                    if coproc_fd < 0 {
8552                        eprintln!("{}:1: no coprocess", shname());
8553                        self.set_last_status(1);
8554                        self.redirect_failed = true;
8555                    } else {
8556                        unsafe {
8557                            libc::dup2(coproc_fd, fd);
8558                        }
8559                    }
8560                } else if let Ok(src_fd) = n.parse::<i32>() {
8561                    unsafe { libc::dup2(src_fd, fd) };
8562                } else {
8563                    tracing::warn!(target = %target, "DUP redir: target not parseable as fd");
8564                }
8565            }
8566            r::WRITE_BOTH => {
8567                if let Ok(file) = fs::File::create(target) {
8568                    let new_fd = file.into_raw_fd();
8569                    unsafe {
8570                        libc::dup2(new_fd, 1);
8571                        libc::dup2(new_fd, 2);
8572                        libc::close(new_fd);
8573                    }
8574                }
8575            }
8576            r::APPEND_BOTH => {
8577                if let Ok(file) = fs::OpenOptions::new()
8578                    .create(true)
8579                    .append(true)
8580                    .open(target)
8581                {
8582                    let new_fd = file.into_raw_fd();
8583                    unsafe {
8584                        libc::dup2(new_fd, 1);
8585                        libc::dup2(new_fd, 2);
8586                        libc::close(new_fd);
8587                    }
8588                }
8589            }
8590            _ => {}
8591        }
8592    }
8593
8594    /// Push a fresh redirect scope. `_count` is informational — the actual
8595    /// saved fds are appended by host_apply_redirect into the top scope.
8596    pub fn host_redirect_scope_begin(&mut self, _count: u8) {
8597        self.redirect_scope_stack.push(Vec::new());
8598        self.multios_scope_stack.push(Vec::new());
8599    }
8600
8601    /// Pop the top redirect scope, restoring saved fds.
8602    pub fn host_redirect_scope_end(&mut self) {
8603        // c:Src/exec.c — restore saved fds FIRST so the multios
8604        // pipe-write end is released from `fd`, then close our
8605        // tracked close_on_end (the last surviving writer dup), then
8606        // join the splitter thread. If we closed close_on_end before
8607        // restoring saved, `fd` would still hold a pipe writer and
8608        // the thread would block forever waiting for EOF.
8609        if let Some(saved) = self.redirect_scope_stack.pop() {
8610            for (fd, saved_fd) in saved.into_iter().rev() {
8611                unsafe {
8612                    libc::dup2(saved_fd, fd);
8613                    libc::close(saved_fd);
8614                }
8615            }
8616        }
8617        if let Some(scope) = self.multios_scope_stack.pop() {
8618            for (write_fd, handle) in scope {
8619                if write_fd >= 0 {
8620                    unsafe {
8621                        libc::close(write_fd);
8622                    }
8623                }
8624                let _ = handle.join();
8625            }
8626        }
8627    }
8628
8629    /// Set up `content` as stdin (fd 0) for the next command via a real pipe.
8630    /// Used by `Op::HereDoc(idx)` and `Op::HereString`.
8631    ///
8632    /// The pattern: dup2 the read end of a fresh pipe onto fd 0, save the
8633    /// original fd 0 into the active redirect scope so `WithRedirectsEnd`
8634    /// restores it, and spawn a thread that writes `content` to the write end
8635    /// and closes it (so the consumer sees EOF after the body). A thread is
8636    /// needed because writing could block on a finite pipe buffer.
8637    pub fn host_set_pending_stdin(&mut self, content: String) {
8638        let (read_end, write_end) = match os_pipe::pipe() {
8639            Ok(p) => p,
8640            Err(_) => return,
8641        };
8642        let saved = unsafe { libc::dup(libc::STDIN_FILENO) };
8643        if saved >= 0 {
8644            if let Some(top) = self.redirect_scope_stack.last_mut() {
8645                top.push((libc::STDIN_FILENO, saved));
8646            } else {
8647                unsafe { libc::close(saved) };
8648            }
8649        }
8650        let read_fd = AsRawFd::as_raw_fd(&read_end);
8651        unsafe { libc::dup2(read_fd, libc::STDIN_FILENO) };
8652        drop(read_end);
8653        std::thread::spawn(move || {
8654            let mut w = write_end;
8655            let _ = w.write_all(content.as_bytes());
8656        });
8657    }
8658
8659    /// Spawn an external command using zshrs's full dispatch logic
8660    /// (intercepts, command_hash, redirect handling). Used by
8661    /// `ZshrsHost::exec` so the bytecode VM's `Op::Exec` and
8662    /// `Op::CallFunction` external fallback get the same semantics as
8663    /// the tree-walker's `execute_external` rather than a plain
8664    /// `Command::new` shortcut. Returns the exit status.
8665    pub fn host_exec_external(&mut self, args: &[String]) -> i32 {
8666        // If a glob expansion in this command's argv triggered the
8667        // nomatch error path, suppress the actual exec and return
8668        // status 1 — mirrors zsh's command-aborted-on-glob-error
8669        // behaviour. The flag is reset BEFORE returning so the next
8670        // command starts clean.
8671        //
8672        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH sets
8673        // ERRFLAG_ERROR but C's execlist clears the bit per-sublist
8674        // so subsequent commands run. Symmetric with the builtin
8675        // dispatcher's clear at fusevm_bridge.rs:299 — clear it here
8676        // too at the external-command post-command-boundary.
8677        if self.current_command_glob_failed.get() {
8678            self.current_command_glob_failed.set(false);
8679            crate::ported::utils::errflag.fetch_and(
8680                !crate::ported::zsh_h::ERRFLAG_ERROR,
8681                std::sync::atomic::Ordering::Relaxed,
8682            );
8683            self.set_last_status(1);
8684            return 1;
8685        }
8686        let Some((cmd, rest)) = args.split_first() else {
8687            return 0;
8688        };
8689        // Empty command name (e.g. result of an empty `$(false)`
8690        // command-sub being the only word) — zsh: no command runs,
8691        // exit status preserved from prior step. Was hitting the
8692        // "command not found: " path with empty name.
8693        if cmd.is_empty() && rest.is_empty() {
8694            return self.last_status();
8695        }
8696        let rest_vec: Vec<String> = rest.to_vec();
8697        // Update `$_` with the just-arriving argv so the next command
8698        // reads `_=<last_arg>`. Mirrors C zsh's writeback in
8699        // `execcmd_exec` (Src/exec.c). Per `args.last()` semantics,
8700        // when invoked as `cmd a b c`, `$_` becomes "c" — for a bare
8701        // command with no args, `$_` becomes the command name itself.
8702        crate::ported::params::set_zunderscore(args);
8703
8704        // Builtins not in fusevm's name→id table fall through to
8705        // host.exec. Catch them here before the OS-level exec attempts
8706        // to spawn a non-existent binary.
8707        match cmd.as_str() {
8708            "sched" => return dispatch_builtin("sched", rest_vec.clone()),
8709            "echotc" => return dispatch_builtin("echotc", rest_vec.clone()),
8710            "echoti" => return dispatch_builtin("echoti", rest_vec.clone()),
8711            "zpty" => return dispatch_builtin("zpty", rest_vec.clone()),
8712            "ztcp" => return dispatch_builtin("ztcp", rest_vec.clone()),
8713            "zsocket" => {
8714                // c:Src/Modules/socket.c:276 BUILTIN spec — BUILTINS["zsocket"]
8715                // optstr "ad:ltv" parsed by execbuiltin.
8716                return dispatch_builtin("zsocket", rest_vec.clone());
8717            }
8718            "private" => {
8719                // c:Src/Modules/param_private.c:217 — bin_private via
8720                // BUILTINS["private"].
8721                return dispatch_builtin("private", rest_vec.clone());
8722            }
8723            "zformat" => return dispatch_builtin("zformat", rest_vec.clone()),
8724            "zregexparse" => return dispatch_builtin("zregexparse", rest_vec.clone()),
8725            // `unalias`/`unhash`/`unfunction` share `bin_unhash` but
8726            // each carries its own funcid (BIN_UNALIAS / BIN_UNHASH /
8727            // BIN_UNFUNCTION) — dispatch_builtin handles the BUILTINS
8728            // lookup + funcid propagation via execbuiltin.
8729            "unalias" | "unhash" | "unfunction" => {
8730                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
8731            }
8732            // zsh-bundled rename helpers — implemented natively in
8733            // Rust so `autoload -U zmv` works without shipping the
8734            // function source. (Without this, the autoload path hangs.)
8735            "zmv" => return crate::extensions::ext_builtins::zmv(&rest_vec, "mv"),
8736            "zcp" => return crate::extensions::ext_builtins::zmv(&rest_vec, "cp"),
8737            "zln" => return crate::extensions::ext_builtins::zmv(&rest_vec, "ln"),
8738            "zcalc" => return crate::extensions::ext_builtins::zcalc(&rest_vec),
8739            "zselect" => {
8740                // Route through canonical dispatch_builtin which goes
8741                // via execbuiltin → BUILTINS["zselect"] (zselect.c:272).
8742                return dispatch_builtin("zselect", rest_vec.clone());
8743            }
8744            "cap" => return dispatch_builtin("cap", rest_vec.clone()),
8745            "getcap" => return dispatch_builtin("getcap", rest_vec.clone()),
8746            "setcap" => return dispatch_builtin("setcap", rest_vec.clone()),
8747            "yes" => return self.builtin_yes(&rest_vec),
8748            "nl" => return self.builtin_nl(&rest_vec),
8749            "env" => return self.builtin_env(&rest_vec),
8750            "printenv" => return self.builtin_printenv(&rest_vec),
8751            "tty" => return self.builtin_tty(&rest_vec),
8752            // c:Src/Modules/files.c:806 — BUILTINS["chgrp"] with
8753            // BIN_CHGRP funcid + "hRs" optstr.
8754            "chgrp" => return dispatch_builtin("chgrp", rest_vec.clone()),
8755            "nproc" => return self.builtin_nproc(&rest_vec),
8756            "expr" => return self.builtin_expr(&rest_vec),
8757            "sha256sum" => return self.builtin_sha256sum(&rest_vec),
8758            "base64" => return self.builtin_base64(&rest_vec),
8759            "tac" => return self.builtin_tac(&rest_vec),
8760            "expand" => return self.builtin_expand(&rest_vec),
8761            "unexpand" => return self.builtin_unexpand(&rest_vec),
8762            "paste" => return self.builtin_paste(&rest_vec),
8763            "fold" => return self.builtin_fold(&rest_vec),
8764            "shuf" => return self.builtin_shuf(&rest_vec),
8765            "comm" => return self.builtin_comm(&rest_vec),
8766            "cksum" => return self.builtin_cksum(&rest_vec),
8767            "factor" => return self.builtin_factor(&rest_vec),
8768            "tsort" => return self.builtin_tsort(&rest_vec),
8769            "sum" => return self.builtin_sum(&rest_vec),
8770            "mkfifo" => return self.builtin_mkfifo(&rest_vec),
8771            "link" => return self.builtin_link(&rest_vec),
8772            "unlink" => return self.builtin_unlink(&rest_vec),
8773            "dircolors" => return self.builtin_dircolors(&rest_vec),
8774            "groups" => return self.builtin_groups(&rest_vec),
8775            "arch" => return self.builtin_arch(&rest_vec),
8776            "nice" => return self.builtin_nice(&rest_vec),
8777            "logname" => return self.builtin_logname(&rest_vec),
8778            "tput" => return self.builtin_tput(&rest_vec),
8779            "users" => return self.builtin_users(&rest_vec),
8780            // "sync" => return self.bin_sync(&rest_vec),
8781            "zbuild" => return self.builtin_zbuild(&rest_vec),
8782            // `zf_*` aliases from `zsh/files` (Src/Modules/files.c
8783            // BUILTIN table at line 816-824). The C source binds
8784            // both unprefixed (`chmod`) and prefixed (`zf_chmod`)
8785            // names to the SAME `bin_chmod` etc. handlers — the
8786            // prefixed forms exist so a script can portably reach
8787            // the builtin even when a function or alias has shadowed
8788            // the bare name. Each arm routes through the canonical
8789            // zf_* aliases route through canonical BUILTINS entries
8790            // (files.c:816-824) — execbuiltin parses each fn's optstr
8791            // automatically.
8792            "mkdir" | "zf_mkdir" | "zf_rm" | "zf_rmdir" | "zf_chmod" | "zf_chown" | "zf_chgrp"
8793            | "zf_ln" | "zf_mv" | "zf_sync" => {
8794                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
8795            }
8796            // `zstat` — port of zsh/stat module (Src/Modules/stat.c
8797            // BUILTIN("zstat", …)). Returns file metadata as
8798            // `field value` pairs / an assoc / a plus-separated
8799            // list depending on flags. zsh ALSO registers `stat`
8800            // bound to the same handler, but that name conflicts
8801            // with the system `stat(1)` binary (every script that
8802            // calls `stat -f '%Lp' …` would break). zsh resolves
8803            // this through opt-in `zmodload`; zshrs's modules are
8804            // statically linked so we keep `stat` routing to the
8805            // external command and only intercept the unambiguous
8806            // `zstat` name.
8807            "zstat" => {
8808                // Canonical bin_stat per stat.c:638 via BUILTINS["zstat"].
8809                return dispatch_builtin("zstat", rest_vec.clone());
8810            }
8811            _ => {}
8812        }
8813
8814        // AOP intercepts: when an `intercept :before/:around/:after foo` block
8815        // is registered, dynamic-command-name dispatch must consult it before
8816        // spawning. Without this, `cmd=ls; $cmd` bypasses every intercept that
8817        // a literal `ls` would trigger. The full_cmd string mirrors what the
8818        // tree-walker era passed (cmd + args joined by space) so existing
8819        // pattern matchers continue to work.
8820        if !self.intercepts.is_empty() {
8821            let full_cmd = if rest_vec.is_empty() {
8822                cmd.clone()
8823            } else {
8824                format!("{} {}", cmd, rest_vec.join(" "))
8825            };
8826            if let Some(intercept_result) = self.run_intercepts(cmd, &full_cmd, &rest_vec) {
8827                return intercept_result.unwrap_or(127);
8828            }
8829        }
8830
8831        // User-defined function lookup before OS-level exec. zsh's
8832        // dynamic-command-name dispatch (`cmd=hook1; $cmd`) checks
8833        // the function table FIRST — without this, `$f` for a
8834        // function-name `f` was always falling through to
8835        // `execute_external` and erroring "command not found".
8836        // Plugin code uses this pattern constantly:
8837        //   for f in "${precmd_functions[@]}"; do "$f"; done
8838        if self.function_exists(cmd) {
8839            if let Some(status) = self.dispatch_function_call(cmd, &rest_vec) {
8840                return status;
8841            }
8842        }
8843
8844        self.execute_external(cmd, &rest_vec, &[]).unwrap_or(127)
8845    }
8846}