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    /// c:Src/exec.c:5025 getproc (PATH_DEV_FD branch) — the parent
117    /// keeps the `>(cmd)` pipe WRITE end open under `/dev/fd/N`,
118    /// parks it in the job's filelist (`fdtable[fd] =
119    /// FDT_PROC_SUBST; addfilelist(NULL, fd)`), and deletefilelist
120    /// closes it when the consuming job finishes — that close is
121    /// what lets the `>(cmd)` child's reader see EOF. zshrs runs
122    /// commands in-process, so the equivalent is: record
123    /// `(scope_depth, fd)` here and drain after the consuming
124    /// command (external exec or builtin dispatch) completes.
125    static PSUB_PENDING_FDS: RefCell<Vec<(usize, i32)>> = const { RefCell::new(Vec::new()) };
126    /// Scope depth for PSUB_PENDING_FDS tagging. Incremented around
127    /// nested execution contexts (cmd-subst bodies, shell-function
128    /// bodies) so a command running INSIDE the nested context only
129    /// drains its own `>(cmd)` fds, never the enclosing command's
130    /// (e.g. `tee >(wc) $(print x)` — print must not close tee's
131    /// fd). Mirrors C's per-job filelist ownership.
132    static PSUB_SCOPE_DEPTH: Cell<usize> = const { Cell::new(0) };
133}
134
135/// RAII guard bumping the psub scope depth — see PSUB_SCOPE_DEPTH.
136pub(crate) struct PsubScope;
137
138impl PsubScope {
139    pub(crate) fn enter() -> Self {
140        PSUB_SCOPE_DEPTH.with(|d| d.set(d.get() + 1));
141        PsubScope
142    }
143}
144
145impl Drop for PsubScope {
146    fn drop(&mut self) {
147        PSUB_SCOPE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
148    }
149}
150
151/// RAII guard bumping `$ZSH_SUBSHELL` for the duration of an
152/// in-process command substitution.
153///
154/// c:Src/exec.c:1161 — entersubsh() does `zsh_subshell++;` and zsh's
155/// cmdsub FORKS, so the increment dies with the child and the parent's
156/// value is untouched. zshrs runs cmdsubs in-process on a nested VM,
157/// so the visible param must be bumped on entry and restored on exit.
158/// Writes paramtab u_val directly because ZSH_SUBSHELL is PM_READONLY
159/// (same bypass pattern as the subshell-builtin bump below); also
160/// mirrors into ported::exec::zsh_subshell so exec.c:4376-style
161/// `forked | zsh_subshell` reads agree.
162pub(crate) struct CmdSubstSubshellBump {
163    saved_val: i64,
164    saved_str: Option<String>,
165}
166
167impl CmdSubstSubshellBump {
168    pub(crate) fn enter() -> Self {
169        let mut saved_val = 0i64;
170        let mut saved_str = None;
171        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
172            if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
173                saved_val = pm.u_val;
174                saved_str = pm.u_str.clone();
175                pm.u_val = saved_val + 1;
176                pm.u_str = Some((saved_val + 1).to_string());
177                pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
178            }
179        }
180        crate::ported::exec::zsh_subshell
181            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
182        CmdSubstSubshellBump { saved_val, saved_str }
183    }
184}
185
186impl Drop for CmdSubstSubshellBump {
187    fn drop(&mut self) {
188        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
189            if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
190                pm.u_val = self.saved_val;
191                pm.u_str = self.saved_str.take();
192            }
193        }
194        crate::ported::exec::zsh_subshell
195            .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
196    }
197}
198
199/// Port of deletefilelist() from Src/jobs.c (the `>(cmd)` fd arm):
200/// closes every pending proc-subst write end created at or inside
201/// the current scope depth, exactly when C deletes the consuming
202/// job's filelist (getproc parks the fd there via
203/// `addfilelist(NULL, fd)`, Src/exec.c:5025+).
204fn close_pending_psub_fds() {
205    let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
206    PSUB_PENDING_FDS.with(|v| {
207        v.borrow_mut().retain(|&(d, fd)| {
208            if d >= depth {
209                unsafe { libc::close(fd) };
210                false
211            } else {
212                true
213            }
214        });
215    });
216}
217
218/// RAII drain guard — instantiated at the top of the consuming-
219/// command paths (dispatch_builtin, ZshrsHost::exec) so the pending
220/// `>(cmd)` write ends close on every exit path once the command
221/// finished, exactly when C's job filelist would be deleted.
222struct PsubFdGuard;
223
224impl Drop for PsubFdGuard {
225    fn drop(&mut self) {
226        close_pending_psub_fds();
227    }
228}
229
230/// Peek the outermost cmdsub-saved (stdout, stderr) fds, if any.
231/// Returns None when no cmdsub is currently capturing. Used by the
232/// trap dispatcher in `src/ported/signals.rs::dotrap` to route trap
233/// body output to the parent's real stdout (matching zsh's forked
234/// cmdsub behaviour) instead of the cmdsub's pipe-bound fd 1.
235/// Bug #56 in docs/BUGS.md.
236pub fn cmdsubst_outer_stdout() -> Option<i32> {
237    CMDSUBST_OUTER_FDS.with(|s| s.borrow().last().map(|(o, _)| *o))
238}
239
240// Thread-local pointer to the current ShellExecutor.
241// Set before VM execution, cleared after. Used by builtin handlers.
242thread_local! {
243    static CURRENT_EXECUTOR: RefCell<Option<*mut ShellExecutor>> = const { RefCell::new(None) };
244    /// Set by subshell_end after a deferred subshell `exit N` lands.
245    /// Read + cleared by the next GET_VAR sync_status path so the
246    /// vm.last_status → LASTVAL sync doesn't clobber the deferred
247    /// exit status. RUST-ONLY: needed because zshrs runs subshells
248    /// in-process (no fork) so vm.last_status doesn't track the
249    /// subshell's exit; C zsh's subshell forks and the child's
250    /// process::exit(N) becomes $? in the parent automatically.
251    static SUBSHELL_EXIT_STATUS_PENDING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
252}
253
254/// RAII guard that sets/clears the thread-local executor pointer.
255///
256/// Idempotent: calling `enter` when a context is already active is a no-op
257/// for the entry side, and the guard's drop only clears the thread-local if
258/// *this* call was the one that set it. Nested `execute_command` invocations
259/// (e.g. from inside a builtin handler) reuse the outer pointer instead of
260/// stomping it.
261pub(crate) struct ExecutorContext {
262    we_set_it: bool,
263}
264
265impl ExecutorContext {
266    pub(crate) fn enter(executor: &mut ShellExecutor) -> Self {
267        let we_set_it = CURRENT_EXECUTOR.with(|cell| {
268            let mut slot = cell.borrow_mut();
269            if slot.is_some() {
270                false
271            } else {
272                *slot = Some(executor as *mut ShellExecutor);
273                true
274            }
275        });
276        ExecutorContext { we_set_it }
277    }
278}
279
280impl Drop for ExecutorContext {
281    fn drop(&mut self) {
282        if self.we_set_it {
283            CURRENT_EXECUTOR.with(|cell| {
284                *cell.borrow_mut() = None;
285            });
286        }
287    }
288}
289
290/// Access the current executor from a builtin handler.
291/// # Safety
292/// Only call this from within a VM execution context (after ExecutorContext::enter).
293#[inline]
294pub(crate) fn with_executor<F, R>(f: F) -> R
295where
296    F: FnOnce(&mut ShellExecutor) -> R,
297{
298    CURRENT_EXECUTOR.with(|cell| {
299        let ptr = cell
300            .borrow()
301            .expect("with_executor called outside VM context");
302        // SAFETY: The pointer is valid for the duration of VM execution,
303        // and we're single-threaded within the executor.
304        let executor = unsafe { &mut *ptr };
305        f(executor)
306    })
307}
308
309/// Non-panicking variant of [`with_executor`]: runs `f` against the
310/// current executor and returns `Some(result)`, or `None` when no
311/// executor is in scope (`CURRENT_EXECUTOR` unset — e.g. unit tests /
312/// compsys contexts with no fusevm bridge running).
313///
314/// This is the primitive the `crate::ported::exec` accessor wrappers
315/// (array/assoc/dispatch_function_call/execute_script/...) use to
316/// reach the live executor while preserving the exact "no executor →
317/// fall back to the direct param table / default value" semantics that
318/// the deleted `exec_hooks` OnceLock layer encoded via its
319/// "is-the-hook-installed?" check. `CURRENT_EXECUTOR` being set is the
320/// faithful equivalent of "the bridge installed the hooks".
321#[inline]
322pub(crate) fn try_with_executor<F, R>(f: F) -> Option<R>
323where
324    F: FnOnce(&mut ShellExecutor) -> R,
325{
326    CURRENT_EXECUTOR.with(|cell| {
327        let ptr = (*cell.borrow())?;
328        // SAFETY: same contract as with_executor — the pointer is valid
329        // for the duration of VM execution and access is single-threaded.
330        let executor = unsafe { &mut *ptr };
331        Some(f(executor))
332    })
333}
334
335/// Look up a canonical builtin by name in `BUILTINS` and dispatch
336/// via `execbuiltin` (Src/builtin.c:250). NO shadow check — calls the
337/// builtin even if a user function with the same name exists. Used by
338/// the `builtin foo` prefix opcode (which explicitly bypasses function
339/// lookup per zsh semantics) and by internal call sites where shadowing
340/// is unwanted. For zsh's normal name-resolution order (function shadows
341/// builtin), use `dispatch_builtin` instead.
342/// Shell-identifier prefix for diagnostic lines. Reads the canonical
343/// scriptname (`zsh` in `--zsh` parity mode, `zshrs` otherwise) so a
344/// single helper replaces hardcoded `"zshrs:"` literals across the
345/// file's eprintln paths.
346fn shname() -> String {
347    crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string())
348}
349
350/// c:Src/subst.c:505-507 + Src/exec.c:3378-3380 — per-command
351/// CSH_NULL_GLOB outcome check. During this command's word expansion
352/// `expand_glob` accumulated `badcshglob |= 1` per failed glob and
353/// `|= 2` per successful one (Src/glob.c:1871-1875). Exactly 1 —
354/// failures and no successes — is the csh-style error: `no match`,
355/// command skipped, status 1. Any other value (0 = no globs, 2/3 =
356/// at least one matched) is silent. Always resets the counter for
357/// the next command (C resets at prefork entry, subst.rs:1307).
358/// Returns true when the error fired; callers mirror their
359/// glob_failed handling (builtins leave ERRFLAG_ERROR set so the
360/// script aborts, externals clear it so the next sublist runs —
361/// verified against zsh 5.9.1).
362/// Restore the user's GLOB_SUBST after a `${~spec}` carrier flip
363/// (see subst::TILDE_GLOBSUBST_CARRIER). Runs at the same
364/// command-dispatch boundaries that consume glob_failed /
365/// badcshglob — by then every glob op of the current word pipeline
366/// has read the carrier.
367pub(crate) fn consume_tilde_globsubst_carrier() {
368    crate::ported::subst::TILDE_GLOBSUBST_CARRIER.with(|c| {
369        if let Some(saved) = c.take() {
370            crate::ported::options::opt_state_set("globsubst", saved);
371        }
372    });
373}
374
375/// Pop `argc` stack slots for a whole-array assignment: the LAST popped
376/// (deepest pushed) is the param name, the rest are the values in stack
377/// order, with any `Value::Array` flattened to its elements. Mirrors the
378/// pop/flatten prologue of BUILTIN_SET_ARRAY / BUILTIN_APPEND_ARRAY.
379fn pop_array_args_with_name(vm: &mut fusevm::VM, argc: u8) -> (String, Vec<String>) {
380    let n = argc as usize;
381    let mut popped: Vec<Value> = Vec::with_capacity(n);
382    for _ in 0..n {
383        popped.push(vm.pop());
384    }
385    popped.reverse();
386    let name = popped.pop().map(|v| v.to_str()).unwrap_or_default();
387    let mut values: Vec<String> = Vec::new();
388    for v in popped {
389        match v {
390            Value::Array(items) => {
391                for it in items {
392                    values.push(it.to_str());
393                }
394            }
395            other => values.push(other.to_str()),
396        }
397    }
398    (name, values)
399}
400
401fn consume_badcshglob() -> bool {
402    let v = crate::ported::glob::BADCSHGLOB.swap(0, std::sync::atomic::Ordering::Relaxed);
403    if v == 1 {
404        crate::ported::utils::zerr("no match"); // c:Src/subst.c:507
405        true
406    } else {
407        false
408    }
409}
410
411/// Map a builtin name to the zsh module that owns it, IFF zsh does
412/// not auto-load that builtin on first use. Used by
413/// `dispatch_builtin_raw` to gate `--zsh` mode dispatch behind
414/// `zmodload`, mirroring `zsh -fc <name>` returning 127 for these
415/// names without an explicit module load.
416///
417/// Returns `Some(module_name)` if `name` belongs to a non-auto-load
418/// module per the per-module `Src/Modules/<x>.c` `bintab[]` plus
419/// the auto-load flag set at module-build time. `None` for core
420/// builtins and for auto-loaded module builtins (sched, log, echotc,
421/// echoti, zformat, zparseopts, zregexparse, zstyle, strftime,
422/// private, vared, zle, bindkey, comp*) which work without zmodload.
423fn module_bound_builtin_module(name: &str) -> Option<&'static str> {
424    match name {
425        "zftp" => Some("zsh/zftp"),
426        "zsocket" => Some("zsh/net/socket"),
427        "ztcp" => Some("zsh/net/tcp"),
428        "zstat" => Some("zsh/stat"),
429        "zselect" => Some("zsh/zselect"),
430        "zpty" => Some("zsh/zpty"),
431        "zprof" => Some("zsh/zprof"),
432        "zsystem" | "syserror" => Some("zsh/system"),
433        "clone" => Some("zsh/clone"),
434        "zcurses" => Some("zsh/curses"),
435        "ztie" | "zuntie" | "zgdbmpath" => Some("zsh/db/gdbm"),
436        "pcre_compile" | "pcre_match" | "pcre_study" => Some("zsh/pcre"),
437        "example" => Some("zsh/example"),
438        "cap" | "getcap" | "setcap" => Some("zsh/cap"),
439        "zgetattr" | "zsetattr" | "zdelattr" | "zlistattr" => Some("zsh/attr"),
440        // c:Src/Modules/datetime.c — `strftime` is registered via
441        // partab[] when zsh/datetime loads. Verified by
442        // `zsh -fc 'strftime -s s %Y 0'` → 127 "command not found".
443        "strftime" => Some("zsh/datetime"),
444        _ => None,
445    }
446}
447
448pub(crate) fn dispatch_builtin_raw(name: &str, args: Vec<String>) -> i32 {
449    // c:Src/exec.c:2700-2717 — `private` is an autoloaded builtin in
450    // zsh (autofeature b:private of zsh/param/private): first use
451    // runs ensurefeature → require_module → load_module → boot_,
452    // marking the module MOD_INIT_B (what `zmodload -e` reads) and
453    // installing the wrap_private FuncWrap (param_private.c:712).
454    // doshfunc gates the wrapper dispatch on that load state, so
455    // this require_module is what activates private scoping. The
456    // raw dispatcher is the chokepoint every builtin route funnels
457    // through; require_module is idempotent after the first call
458    // (needs_load checks MOD_INIT_B).
459    if name == "private" {
460        if let Ok(mut tab) = crate::ported::module::MODULESTAB.lock() {
461            let _ = crate::ported::module::require_module(
462                &mut tab,
463                "zsh/param/private",
464                None,
465                0,
466            ); // c:2710 ensurefeature
467        }
468    }
469    // c:Src/Modules/param_private.c:682-685 setup_ — loading
470    // zsh/param/private REPLACES the `local` builtintab node's
471    // handlerfunc + optstr with bin_private's ("Even more horrible
472    // hack"), so once the module is loaded `local` IS bin_private: it
473    // accepts the -P/-Pa private-scope flags, and without -P delegates
474    // to bin_typeset, which already treats `local` and `private`
475    // identically (is_locallike, builtin.rs:3666). Replicate the swap by
476    // routing `local` through the `private` node only after the module
477    // is loaded — before then, `local -P` still errors "bad option: -P"
478    // exactly like stock zsh. The `private` node carries the augmented
479    // optstr (with P) that the `local` node lacks.
480    if name == "local"
481        && crate::ported::module::MODULESTAB
482            .lock()
483            .map(|t| t.is_bound("zsh/param/private"))
484            .unwrap_or(false)
485    {
486        return dispatch_builtin_raw("private", args);
487    }
488    // c:Bugs #475/#504/#555 — bash-only builtins (`mapfile`,
489    // `readarray`, `compopt`) should emit "command not found" in
490    // `--zsh` mode matching zsh's external-command-lookup miss.
491    // The per-opcode closures for caller/help/complete/compgen
492    // already gate via IS_ZSH_MODE at their registration sites;
493    // names without dedicated opcodes (compopt/mapfile/readarray)
494    // route through this generic builtintab lookup and need the
495    // gate here.
496    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
497        && matches!(name, "compopt" | "mapfile" | "readarray")
498    {
499        eprintln!("zsh:1: command not found: {}", name);
500        let _ = args;
501        return 127;
502    }
503    // c:Src/exec.c:2700-2724 resolvebuiltin — autoloaded-builtin stub
504    // (registered by `zmodload -ab MOD NAME`, Src/module.c:426
505    // add_autobin) fires on first use: ensurefeature loads the owning
506    // module, then dispatch proceeds against the real builtin. Must
507    // run BEFORE the module-bound 127 gate below — `zmodload -ab
508    // zsh/zselect zselect; zselect` previously died there with
509    // `command not found` because the gate only checked is_loaded,
510    // never the autoload ledger.
511    if let Some(rc) = crate::ported::module::resolvebuiltin(name) {
512        if rc != 0 {
513            // Load failed or feature undefined — diagnostics already
514            // printed (load_module zwarn / resolvebuiltin zerr).
515            // C's execbuiltin head returns 1 (Src/builtin.c:264-267).
516            return 1;
517        }
518        // Module loaded — fall through; the is_loaded gates below now
519        // pass and the normal dispatch chain runs the real builtin.
520    }
521    // c:Src/Modules/<mod>.c boot_/setup_ chain — module-bound builtins
522    // (zftp, zsocket, ztcp, zstat, etc.) are only registered into
523    // `builtintab` when their module is loaded via `zmodload`. In
524    // zsh `-fc` (the parity test harness's invocation), the modules
525    // are NOT pre-loaded, so each name reports "command not found"
526    // with exit 127. zshrs intentionally pre-loads all module bintabs
527    // in `createbuiltintable` (builtin.rs:131-152) for the default
528    // mode so users can call these without `zmodload`; that auto-load
529    // diverges from zsh's gate behavior. Match zsh's stance only when
530    // the user explicitly asked for parity via `--zsh`.
531    //
532    // The list is the union of builtins from modules that zsh does
533    // NOT auto-load (verified via `zsh -fc <name>` returning 127):
534    //   zsh/zftp          → zftp
535    //   zsh/net/socket    → zsocket
536    //   zsh/net/tcp       → ztcp
537    //   zsh/stat          → zstat (NOT `stat`; that name resolves to
538    //                              /bin/stat on PATH per zsh's setup)
539    //   zsh/zselect       → zselect
540    //   zsh/zpty          → zpty
541    //   zsh/zprof         → zprof
542    //   zsh/system        → zsystem, syserror
543    //   zsh/clone         → clone
544    //   zsh/curses        → zcurses
545    //   zsh/db/gdbm       → ztie, zuntie, zgdbmpath
546    //   zsh/pcre          → pcre_compile, pcre_match, pcre_study
547    //   zsh/example       → example
548    //   zsh/cap           → cap, getcap, setcap
549    //   zsh/attr          → zgetattr, zsetattr, zdelattr, zlistattr
550    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
551        && module_bound_builtin_module(name)
552            .map(|m| {
553                !crate::ported::module::MODULESTAB
554                    .lock()
555                    .map(|t| t.is_loaded(m))
556                    .unwrap_or(false)
557            })
558            .unwrap_or(false)
559    {
560        eprintln!("zsh:1: command not found: {}", name);
561        let _ = args;
562        return 127;
563    }
564    // c:Src/Modules/files.c:806-824 — zsh/files registers `chmod`,
565    // `chown`, `chgrp`, `ln`, `mkdir`, `mv`, `rm`, `rmdir`, `sync`
566    // (plus their `zf_*` aliases) into builtintab on module load.
567    // Without an explicit `zmodload zsh/files`, zsh resolves the
568    // names through PATH lookup — `zsh -fc 'chmod +x f'` runs
569    // `/bin/chmod`, whose argv-parser accepts symbolic modes like
570    // `+x` that bin_chmod's octal-only parser rejects with
571    // "invalid mode `+x'". The shadow-aware wrapper at
572    // `dispatch_builtin` (line 438) already has this gate, but the
573    // direct `dispatch_builtin_raw` path used by fusevm's
574    // CallBuiltin opcode bypasses it. Mirror the gate here so the
575    // low-level dispatch matches C's PATH-fall-through behavior.
576    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
577        && module_gated_files_builtin(name)
578        && !crate::ported::module::MODULESTAB
579            .lock()
580            .map(|t| t.is_loaded("zsh/files"))
581            .unwrap_or(false)
582    {
583        // PATH lookup uses the LITERAL name: bare `mkdir` finds
584        // /bin/mkdir; a `zf_*` alias finds nothing and exits 127 —
585        // matching zsh -fc `zf_mkdir d` → "command not found:
586        // zf_mkdir" (the aliases exist ONLY in the loaded module's
587        // builtintab, Src/Modules/files.c:816-824; PATH has no
588        // /bin/zf_rm). The previous zf_-strip silently ran the
589        // system binary instead.
590        let status = with_executor(|exec| exec.execute_external(name, &args, &[]))
591            .unwrap_or(127);
592        return status;
593    }
594    // c:Src/Modules/stat.c:637-638 — zsh/stat registers BOTH `stat`
595    // and `zstat`. `zstat` is in the module_bound 127-gate above (no
596    // /usr/bin/zstat exists), but the bare `stat` name must FALL
597    // THROUGH to PATH when zsh/stat isn't loaded — zsh -fc
598    // 'stat -f %Lp f' runs /usr/bin/stat, while bin_stat's parser
599    // rejects stat(1) flags ("bad option: -c"). Same fall-through
600    // shape as the zsh/files gate above.
601    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
602        && name == "stat"
603        && !crate::ported::module::MODULESTAB
604            .lock()
605            .map(|t| t.is_loaded("zsh/stat"))
606            .unwrap_or(false)
607    {
608        let status =
609            with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
610        return status;
611    }
612    // c:Src/exec.c:3050-3068 — builtin lookup hits `builtintab` (the
613    // merged table containing module-provided builtins). The previous
614    // port walked only the core `BUILTINS` slice, so per-module
615    // entries like `log` (Src/Modules/watch.c:693 `BUILTIN("log", …,
616    // bin_log, …)`) were registered into builtintab via
617    // createbuiltintable but never reached at dispatch — `log` fell
618    // through to PATH and ran `/usr/bin/log`. Bug #72 in docs/BUGS.md.
619    let tab = crate::ported::builtin::createbuiltintable();
620    if let Some(bn_static) = tab.get(name) {
621        let bn_ptr = *bn_static as *const _ as *mut _;
622        return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
623    }
624    1
625}
626
627/// Shadow-aware dispatch matching zsh's name-resolution order:
628/// alias → reserved word → **function (shadows builtin)** → builtin →
629/// external. All `BUILTIN_X` opcode handlers route through here so a
630/// user-defined `cd () { … }` (or `r`, `fc`, `which`, … anything in
631/// fusevm's name→opcode map) takes precedence over the C builtin —
632/// matching `Src/exec.c:execcmd_exec`'s dispatch at c:3050-3068.
633/// Without this, compile-time builtin resolution silently ignored
634/// user wrappers (e.g. ZPWR's `cd () { builtin cd "$@"; … }`).
635/// True for builtins that are bound by zsh/files's boot_/setup_
636/// chain (Src/Modules/files.c:806-824). These are the bare-name
637/// `mkdir`/`rm`/`mv`/`ln`/`chmod`/`chown`/`chgrp`/`sync`/`rmdir`
638/// AND their `zf_*` aliases at c:816-824. Without explicit
639/// `zmodload zsh/files`, the names fall through to PATH lookup
640/// (zsh's `type rm` reports `/bin/rm`). Bug #28.
641fn module_gated_files_builtin(name: &str) -> bool {
642    matches!(
643        name,
644        "mkdir" | "rmdir" | "rm" | "mv" | "ln" | "chmod" | "chown" | "chgrp" | "sync"
645            | "zf_mkdir" | "zf_rmdir" | "zf_rm" | "zf_mv" | "zf_ln" | "zf_chmod"
646            | "zf_chown" | "zf_chgrp" | "zf_sync"
647    )
648}
649
650pub(crate) fn dispatch_builtin(name: &str, args: Vec<String>) -> i32 {
651    // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close any
652    // `>(cmd)` write ends owned by this command once it finishes
653    // (drops on every return path below).
654    let _psub_fds = PsubFdGuard;
655    // c:Src/exec.c — when any redirect in the current scope failed
656    // (e.g. noclobber blocked a `>` overwrite), zsh refuses to
657    // execute the command and exits with status 1. The Rust port
658    // still applied the command (writing to the /dev/null sink
659    // installed by host_apply_redirect's noclobber arm) so the
660    // success status overwrote the intended 1. Short-circuit here
661    // for builtins (the external-exec equivalent lives in
662    // ZshrsHost::exec).
663    let redir_failed = with_executor(|exec| {
664        let f = exec.redirect_failed;
665        exec.redirect_failed = false;
666        f
667    });
668    if redir_failed {
669        // c:Src/exec.c:4367-4386 — POSIX special-builtin escalation:
670        // a failed redirect on a PSPECIAL builtin (set, readonly,
671        // typeset, ...) under POSIX_BUILTINS is FATAL in a
672        // non-interactive shell (`exit(1)` at c:4383). The `command`
673        // prefix resets this (BINF_COMMAND, c:4369) — that path
674        // dispatches through bin_command, not here.
675        if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
676            && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
677            && builtin_is_pspecial(name)
678        {
679            use std::sync::atomic::Ordering;
680            crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
681            crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
682        }
683        return 1;
684    }
685    // c:Src/glob.c:1876-1880 NOMATCH path — when expand_glob() failed
686    // on a no-match glob, zsh aborts the simple command after zerr()
687    // printed "no matches found". In C, this works because zerr()
688    // sets ERRFLAG_ERROR (Src/utils.c) and execcmd_exec()
689    // (Src/exec.c:3050+) checks errflag before invoking the builtin
690    // table. Rust's builtin dispatch doesn't sit on the same errflag
691    // gate, so we explicitly consume the per-command glob-fail cell
692    // and short-circuit with status 1. Mirrors the external-path
693    // guard at host_exec_external (line 5167). Without this:
694    // `echo /never/*` would print empty (silently rolled back to ""
695    // by the empty glob expansion). Parity bug #13.
696    consume_tilde_globsubst_carrier();
697    let glob_failed = with_executor(|exec| {
698        let f = exec.current_command_glob_failed.get();
699        exec.current_command_glob_failed.set(false); // c:1879 cleanup
700        f
701    });
702    if glob_failed {
703        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH zerr sets
704        // ERRFLAG_ERROR (via utils.c:184). For a BUILTIN command the
705        // expansion runs IN the shell process, so errflag stays set
706        // and the rest of the input aborts (zsh -fc 'echo /nope_*;
707        // echo after' prints nothing after the error — verified
708        // against zsh 5.9). The continue-after-nomatch behaviour
709        // belongs ONLY to externals: C forks BEFORE expansion there,
710        // so the child's zerr can't touch the parent's errflag (zsh
711        // -fc 'ls /nope_*; echo after' prints `after`) — that path's
712        // clear lives in fn exec / execute_external. Leave
713        // ERRFLAG_ERROR set here; BUILTIN_ERREXIT_CHECK trigger 4
714        // aborts the remaining script at the next command boundary.
715        return 1; // c:1880 — command aborted, status 1
716    }
717    // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the NOMATCH
718    // gate above: all of this command's globs failed silently (words
719    // dropped, badcshglob accumulated 1s and no 2s) → `no match`,
720    // skip the builtin, status 1. Like the NOMATCH path, ERRFLAG
721    // from zerr stays set for builtins so the rest of the script
722    // aborts (zsh -fc 'setopt cshnullglob; print *nope* x; print
723    // after' prints only the error — verified zsh 5.9.1).
724    if consume_badcshglob() {
725        // c:Src/exec.c:3380 — `lastval = 1;` so the shell's final
726        // exit status reflects the aborted command.
727        with_executor(|exec| exec.set_last_status(1));
728        return 1;
729    }
730    if let Some(status) = try_user_fn_override(name, &args) {
731        // c:Src/jobs.c:1748 waitonejob — canonical single-command
732        // pipestats update via the no-procs else-branch.
733        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
734        let mut synth = crate::ported::zsh_h::job::default();
735        crate::ported::jobs::waitonejob(&mut synth);
736        return status;
737    }
738    // c:Src/builtin.c:587 + Src/exec.c:3056 — a builtin disabled via
739    // `disable <name>` has its `DISABLED` flag set in `builtintab`;
740    // `builtintab->getnode` (the DISABLED-filtering accessor) returns
741    // NULL for it at lookup time, so execcmd_exec falls through to
742    // PATH lookup and runs the external. The Rust port stores the
743    // disabled set in `BUILTINS_DISABLED`; the previous dispatcher
744    // only checked the immutable `createbuiltintable` HashMap which
745    // never reflects disablement — so `disable echo; echo hi` kept
746    // running the bin_echo builtin. Bug #106 in docs/BUGS.md.
747    //
748    // dispatch_builtin (the high-level wrapper used by the BUILTIN_*
749    // opcode handlers and reg_passthru! callsites) is the correct
750    // gate: `dispatch_builtin_raw` is the low-level entry point
751    // used by `bin_builtin` itself which MUST bypass the disabled
752    // set (man zshbuiltins: `builtin name` runs the builtin
753    // regardless of disable state). Place the check here so the
754    // bypass path stays clean.
755    let disabled = crate::ported::builtin::BUILTINS_DISABLED
756        .lock()
757        .map(|s| s.contains(name))
758        .unwrap_or(false);
759    if disabled {
760        let status = with_executor(|exec| exec.execute_external(name, &args, &[]))
761            .unwrap_or(127);
762        crate::ported::builtin::LASTVAL
763            .store(status, std::sync::atomic::Ordering::Relaxed);
764        let mut synth = crate::ported::zsh_h::job::default();
765        crate::ported::jobs::waitonejob(&mut synth);
766        return status;
767    }
768    // c:Src/Modules/files.c:806-814 — `mkdir`, `rm`, `mv`, `ln`, `chmod`,
769    // `chown`, `chgrp`, `sync`, `rmdir` are bound by the `zsh/files`
770    // module's boot_/setup_ chain. Without explicit `zmodload zsh/files`,
771    // these bare names fall through to PATH (`/bin/rm`, `/usr/bin/chmod`,
772    // etc.) in zsh; `type rm` reports `rm is /bin/rm`. The `zf_*`
773    // aliases (`zf_rm`, `zf_chmod`, …) are bound by the same module
774    // and gated the same way. Bug #28 in docs/BUGS.md.
775    if module_gated_files_builtin(name) {
776        if !crate::ported::module::MODULESTAB.lock().unwrap().is_loaded("zsh/files") {
777            // PATH lookup uses the literal name. In --zsh parity mode
778            // `zf_rm` must 127 like zsh -fc (no /bin/zf_rm); default
779            // zshrs mode keeps the convenience zf_-strip so the alias
780            // still reaches the system binary.
781            let path_name = if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
782            {
783                name
784            } else {
785                name.strip_prefix("zf_").unwrap_or(name)
786            };
787            let status = with_executor(|exec| exec.execute_external(path_name, &args, &[]))
788                .unwrap_or(127);
789            crate::ported::builtin::LASTVAL
790                .store(status, std::sync::atomic::Ordering::Relaxed);
791            let mut synth = crate::ported::zsh_h::job::default();
792            crate::ported::jobs::waitonejob(&mut synth);
793            return status;
794        }
795    }
796    let status = dispatch_builtin_raw(name, args);
797    // c:Src/jobs.c:1748 waitonejob — canonical single-command pipestats update.
798    crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
799    let mut synth = crate::ported::zsh_h::job::default();
800    crate::ported::jobs::waitonejob(&mut synth);
801    // c:Src/exec.c:4367-4386 — done: tail. A PSPECIAL builtin that
802    // raised errflag under POSIX_BUILTINS exits the non-interactive
803    // shell with status 1 ("hard error in POSIX" — e.g. bin_dot's
804    // zerrnam at Src/builtin.c:6133). Arm the deferred-exit pair so
805    // the next ERREXIT_CHECK unwinds; EXIT_VAL=1 matches C's
806    // hardcoded exit(1), NOT the builtin's own status (dot returns
807    // 127 but POSIX exits 1).
808    if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
809        && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
810        && builtin_is_pspecial(name)
811        && (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
812            & crate::ported::zsh_h::ERRFLAG_ERROR)
813            != 0
814    {
815        use std::sync::atomic::Ordering;
816        crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
817        crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
818    }
819    status
820}
821
822/// c:Src/zsh.h:1467 BINF_PSPECIAL — true when `name` is a POSIX
823/// special builtin per the canonical builtin table flags
824/// (Src/builtin.c:48-129: `.`, `:`, break, continue, declare, eval,
825/// exit, export, float, integer, local, readonly, return, set,
826/// shift, source, times, trap, typeset, unset).
827fn builtin_is_pspecial(name: &str) -> bool {
828    crate::ported::builtin::createbuiltintable()
829        .get(name)
830        .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_PSPECIAL) != 0)
831        .unwrap_or(false)
832}
833
834// The former `install_exec_hooks()` fn-pointer registry is gone. Code
835// under `src/ported/` now reaches `ShellExecutor` operations
836// (array/assoc storage, script eval, function dispatch, command
837// substitution) through the `crate::ported::exec::*` accessor wrappers,
838// which resolve the live executor via `try_with_executor`
839// (`CURRENT_EXECUTOR`). The bridge lives in exec.rs — the sanctioned
840// fusevm-access exception — per `feedback_no_exec_script_from_ported` /
841// `feedback_no_shellexecutor_in_ported`.
842
843/// Register all zsh builtins with the VM.
844pub(crate) fn register_builtins(vm: &mut fusevm::VM) {
845    // src/ported/ reaches the live executor (param store, function
846    // dispatch, nested script/cmdsubst exec) through the
847    // `crate::ported::exec::*` accessor wrappers, which read
848    // `CURRENT_EXECUTOR` via `try_with_executor`. No install step is
849    // needed: the executor is in scope for the duration of any VM run
850    // (set by `ExecutorContext::enter`), so the wrappers resolve it
851    // directly. (Replaces the former `exec_hooks` OnceLock fn-ptr
852    // registry, now deleted.)
853    // Engage fusevm's tiered JIT (block + tracing) so hot, fully-eligible
854    // numeric chunks run in native code and — with the `jit-disk-cache`
855    // feature (on by default) — persist that native code to
856    // `~/.cache/fusevm-jit`, letting repeated zsh invocations skip Cranelift
857    // codegen. fusevm gates the JIT on per-chunk eligibility and warms up by
858    // an invocation threshold, falling back to the interpreter for any chunk
859    // it cannot compile (e.g. host-builtin/`Extended` command dispatch), so
860    // enabling it here never changes observable behaviour — it only caches
861    // the numeric hot path. Idempotent: re-enabling on each VM is a no-op.
862    vm.enable_tracing_jit();
863    // Macro for builtins that user functions are allowed to shadow.
864    // zsh dispatch order is alias → function → builtin; without the
865    // try_user_fn_override probe a `cat() { ... }; cat` would silently
866    // run the C builtin and ignore the user function.
867    macro_rules! reg_overridable {
868        ($vm:expr, $id:expr, $name:literal, $method:ident) => {
869            $vm.register_builtin($id, |vm, argc| {
870                let args = pop_args(vm, argc);
871                // c:Src/exec.c getproc + Src/jobs.c deletefilelist —
872                // close `>(cmd)` write ends owned by this command
873                // once it finishes (shadows bypass dispatch_builtin
874                // and ZshrsHost::exec, so they need their own guard:
875                // `tee >(wc -c) </dev/null` left wc blocked).
876                let _psub_fds = PsubFdGuard;
877                if let Some(s) = try_user_fn_override($name, &args) {
878                    return Value::Status(s);
879                }
880                // c:Src/exec.c — redirect failure in the current
881                // scope means the command must NOT run. coreutils
882                // shadows (cat / head / tail / etc.) take a separate
883                // dispatch path from dispatch_builtin, so they need
884                // their own gate. Without this `cat <&3` after a
885                // closed-fd diagnostic still ran the shadow and
886                // overwrote $? from the forced 1.
887                let redir_failed = with_executor(|exec| {
888                    let f = exec.redirect_failed;
889                    exec.redirect_failed = false;
890                    f
891                });
892                if redir_failed {
893                    return Value::Status(1);
894                }
895                // `[builtins].coreutils_shadows = off` in
896                // ~/.zshrs/zshrs.toml (or `ZSHRS_NO_COREUTILS_SHADOWS=1`
897                // env override) bypasses the in-process shadow and
898                // fork-execs the real /bin/X. Safety valve for any
899                // script that hits an edge-case divergence between
900                // the zshrs shadow and system coreutils. Cached
901                // after first call, so the hot path is one atomic
902                // load per shadowed-builtin invocation.
903                if !crate::daemon_presence::coreutils_shadows_enabled() {
904                    return Value::Status(exec_system_command($name, &args));
905                }
906                let status = with_executor(|exec| exec.$method(&args));
907                Value::Status(status)
908            });
909        };
910    }
911
912    // Pure-passthru builtin: pops args, routes to canonical
913    // `dispatch_builtin(name, args)` (which goes via execbuiltin →
914    // BUILTINS[name] → bin_X). No pre/post bridge work. Used by
915    // ~25 handlers that were 4-line copy-paste boilerplate.
916    macro_rules! reg_passthru {
917        ($vm:expr, $id:expr, $name:literal) => {
918            $vm.register_builtin($id, |vm, argc| {
919                Value::Status(dispatch_builtin($name, pop_args(vm, argc)))
920            });
921        };
922    }
923
924    // Core builtins
925    vm.register_builtin(BUILTIN_CD, |vm, argc| {
926        let args = pop_args(vm, argc);
927        if let Some(s) = try_user_fn_override("cd", &args) {
928            return Value::Status(s);
929        }
930        let status = dispatch_builtin("cd", args);
931        // c:Src/builtin.c:1258 — `callhookfunc("chpwd", NULL, 1, NULL)`
932        // after cd succeeds. The canonical port at
933        // src/ported/utils.rs:1532 handles both the `chpwd` shfunc
934        // dispatch AND the `chpwd_functions` array walk.
935        if status == 0 {
936            crate::ported::utils::callhookfunc("chpwd", None, 1, std::ptr::null_mut());
937        }
938        Value::Status(status)
939    });
940
941    vm.register_builtin(BUILTIN_PWD, |vm, argc| {
942        let args = pop_args(vm, argc);
943        if let Some(s) = try_user_fn_override("pwd", &args) {
944            return Value::Status(s);
945        }
946        // Route through the canonical execbuiltin path so the `rLP`
947        // optstr at BUILTINS["pwd"] is parsed into `ops`.
948        let status = dispatch_builtin("pwd", args);
949        Value::Status(status)
950    });
951
952    vm.register_builtin(BUILTIN_ECHO, |vm, argc| {
953        let args = pop_args(vm, argc);
954        if let Some(s) = try_user_fn_override("echo", &args) {
955            return Value::Status(s);
956        }
957        // Update `$_` to the last arg before running. C zsh sets
958        // zunderscore in execcmd_exec for every simple command,
959        // including builtins.
960        crate::ported::params::set_zunderscore(&args);
961        let status = dispatch_builtin("echo", args);
962        Value::Status(status)
963    });
964
965    vm.register_builtin(BUILTIN_PRINT, |vm, argc| {
966        let args = pop_args(vm, argc);
967        if let Some(s) = try_user_fn_override("print", &args) {
968            return Value::Status(s);
969        }
970        crate::ported::params::set_zunderscore(&args);
971        let status = dispatch_builtin("print", args);
972        Value::Status(status)
973    });
974
975    reg_passthru!(vm, BUILTIN_PRINTF, "printf");
976    reg_passthru!(vm, BUILTIN_EXPORT, "export");
977    reg_passthru!(vm, BUILTIN_UNSET, "unset");
978    // `source` (Src/builtin.c c:116) wired to bin_dot via BUILTINS.
979    reg_passthru!(vm, BUILTIN_SOURCE, "source");
980    reg_passthru!(vm, BUILTIN_DOT, ".");
981    reg_passthru!(vm, BUILTIN_LOGOUT, "logout");
982
983    vm.register_builtin(BUILTIN_EXIT, |vm, argc| {
984        let args = pop_args(vm, argc);
985        let status = dispatch_builtin("exit", args);
986        Value::Status(status)
987    });
988
989    vm.register_builtin(BUILTIN_RETURN, |vm, argc| {
990        let args = pop_args(vm, argc);
991        // zsh: bare `return` (no arg) returns with the status of
992        // the most recently executed command — `false; return`
993        // returns 1, not 0. Direct port of zsh's bin_break/RETURN.
994        // The executor's `last_status` is stale here (synced at
995        // statement boundaries, not after each VM op), so read
996        // the live `vm.last_status` instead.
997        let live_status = vm.last_status;
998        let status = {
999            // Sync canonical LASTVAL to the VM's view BEFORE
1000            // bin_break("return") reads it for the no-arg fallback.
1001            with_executor(|exec| exec.set_last_status(live_status));
1002            dispatch_builtin("return", args)
1003        };
1004        Value::Status(status)
1005    });
1006
1007    vm.register_builtin(BUILTIN_TRUE, |vm, argc| {
1008        let args = pop_args(vm, argc);
1009        if let Some(s) = try_user_fn_override("true", &args) {
1010            return Value::Status(s);
1011        }
1012        // c:Src/exec.c:1257 — zsh sets `zunderscore` AT THE END of
1013        // each command (the `if (!noerrs)` block runs `zsfree(prev_argv0); …;
1014        // zunderscore = …`). For no-arg `true`, $_ becomes the
1015        // command name itself. Set DIRECTLY (not via pending_underscore)
1016        // so the NEXT command's argv-expansion of `$_` reads "true",
1017        // not the stale prior value — pending_underscore is consumed
1018        // by pop_args which runs AFTER argv expansion, too late.
1019        // c:Src/exec.c:1257 — `zunderscore = …` at end-of-command.
1020        // With args, $_ = args.last(). Without args, $_ = command name.
1021        // Write DIRECTLY to the canonical zunderscore static (the
1022        // underscoregetfn at params.rs:7003 reads from there); the
1023        // paramtab "_" slot is shadowed by lookup_special_var so
1024        // set_scalar on it has no effect on `$_` reads.
1025        if args.is_empty() {
1026            crate::ported::params::set_zunderscore(&["true".to_string()]);
1027        } else {
1028            crate::ported::params::set_zunderscore(&args);
1029        }
1030        // Route through canonical execbuiltin so PS4 xtrace fires
1031        // via the c:442 printprompt4 path.
1032        Value::Status(dispatch_builtin("true", args))
1033    });
1034    vm.register_builtin(BUILTIN_FALSE, |vm, argc| {
1035        let args = pop_args(vm, argc);
1036        if let Some(s) = try_user_fn_override("false", &args) {
1037            return Value::Status(s);
1038        }
1039        // Direct set; see BUILTIN_TRUE above for rationale.
1040        if args.is_empty() {
1041            crate::ported::params::set_zunderscore(&["false".to_string()]);
1042        } else {
1043            crate::ported::params::set_zunderscore(&args);
1044        }
1045        // Route through canonical execbuiltin — see BUILTIN_TRUE
1046        // above for the same rationale (xtrace + fast-path removal).
1047        let status = dispatch_builtin("false", args);
1048        Value::Status(status)
1049    });
1050    vm.register_builtin(BUILTIN_COLON, |vm, argc| {
1051        let args = pop_args(vm, argc);
1052        // Direct set; see BUILTIN_TRUE above for rationale.
1053        if args.is_empty() {
1054            crate::ported::params::set_zunderscore(&[":".to_string()]);
1055        } else {
1056            crate::ported::params::set_zunderscore(&args);
1057        }
1058        let status = dispatch_builtin(":", args);
1059        Value::Status(status)
1060    });
1061
1062    vm.register_builtin(BUILTIN_TEST, |vm, argc| {
1063        let args = pop_args(vm, argc);
1064        // Distinguish `[ … ]` from `test …` by sniffing the trailing
1065        // `]` — `[` requires it (c:Src/builtin.c:7241), `test` rejects
1066        // it. The compile path emits BUILTIN_TEST for both, so the
1067        // dispatch name carries the `[` vs `test` semantic for
1068        // execbuiltin's funcid (BIN_BRACKET=21 vs BIN_TEST=20). Without
1069        // this, bin_test's `if func == BIN_BRACKET` arm (which pops
1070        // the trailing `]`) never fired for `[` calls, so the `]`
1071        // leaked into evalcond as a positional and silently changed
1072        // the result. Bug surfaced via test_test_dashdash_unknown_condition.
1073        let name = if args.last().map(|s| s.as_str()) == Some("]") {
1074            "["
1075        } else {
1076            "test"
1077        };
1078        let status = dispatch_builtin(name, args);
1079        Value::Status(status)
1080    });
1081
1082    // Variable declaration. `local` (Src/builtin.c bin_local) handles
1083    // the scope chain (`pm->old = oldpm` at Src/params.c:1137 inside
1084    // createparam, `pm->level = locallevel` at Src/builtin.c:2576).
1085    // `typeset` / `declare` are aliases — fusevm maps both to
1086    // BUILTIN_TYPESET; compile_zsh special-cases `declare` to keep
1087    // the `declare:` error prefix.
1088    reg_passthru!(vm, BUILTIN_LOCAL, "local");
1089    reg_passthru!(vm, BUILTIN_TYPESET, "typeset");
1090
1091    reg_passthru!(vm, BUILTIN_DECLARE, "declare");
1092    reg_passthru!(vm, BUILTIN_READONLY, "readonly");
1093    reg_passthru!(vm, BUILTIN_INTEGER, "integer");
1094    reg_passthru!(vm, BUILTIN_FLOAT, "float");
1095    reg_passthru!(vm, BUILTIN_READ, "read");
1096    // c:Bug #504 — fusevm reserves BUILTIN_MAPFILE for the bash
1097    // mapfile/readarray builtins. Neither exists in zsh; in --zsh
1098    // parity mode the dispatch must emit "command not found" + rc=127
1099    // matching zsh's external-command-lookup miss. The previous wiring
1100    // left BUILTIN_MAPFILE unregistered, so fusevm's VM treated the op
1101    // as a no-op rc=0 — `mapfile` (and `readarray`) silently succeeded
1102    // in --zsh mode. The host gate in `dispatch_builtin_raw` never
1103    // fired because the compile path emitted `Op::CallBuiltin(31, ..)`
1104    // directly. Register the slot so the gate runs (or a future
1105    // non-zsh mode can wire in a real impl).
1106    vm.register_builtin(fusevm::shell_builtins::BUILTIN_MAPFILE, |vm, argc| {
1107        let args = pop_args(vm, argc);
1108        // The fusevm name→id map collapses both `mapfile` and
1109        // `readarray` to the same opcode; pick the right diagnostic
1110        // by sniffing the user's actual invocation. The xtrace ARGS
1111        // push earlier records the cmd-prefix as the bottom of the
1112        // popped argv, but `args` here excludes the prefix — so we
1113        // can't recover the user-typed name from the stack. Default
1114        // to `mapfile` (the more-common spelling); both produce
1115        // identical diagnostics in any case.
1116        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1117            eprintln!("zsh:1: command not found: mapfile");
1118            let _ = args;
1119            return Value::Status(127);
1120        }
1121        // Non-zsh modes (bash drop-in) get a passthru to the canonical
1122        // dispatcher in case a future port adds a real mapfile builtin
1123        // — until then the rc=1 unknown-command default applies.
1124        Value::Status(dispatch_builtin("mapfile", args))
1125    });
1126    reg_passthru!(vm, BUILTIN_BREAK, "break");
1127    reg_passthru!(vm, BUILTIN_CONTINUE, "continue");
1128    reg_passthru!(vm, BUILTIN_SHIFT, "shift");
1129
1130    vm.register_builtin(BUILTIN_EVAL, |vm, argc| {
1131        // Direct port of `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))` body from Src/builtin.c:6151:
1132        //   `if (!*argv) return 0;`
1133        //   `prog = parse_string(zjoin(argv, ' ', 1), 1);`
1134        //   `execode(prog, 1, 0, "eval");`
1135        // The execode invocation lives here (not in the canonical
1136        // free-fn) because it must run through the bytecode VM's
1137        // current executor — the same VM that's mid-dispatch.
1138        let mut args = pop_args(vm, argc);
1139        // c:Src/builtin.c:407-411 — generic `--` end-of-options
1140        // strip applied by `execbuiltin` for builtins that have
1141        // NULL optstr AND no BINF_HANDLES_OPTS. `eval` qualifies
1142        // (Src/builtin.c:65 `BUILTIN("eval", BINF_PSPECIAL, ...,
1143        // NULL, NULL)`). The BUILTIN_EVAL fast-path bypasses
1144        // execbuiltin, so we mirror the strip inline. Bug #319.
1145        if args.first().is_some_and(|s| s == "--") {
1146            args.remove(0);
1147        }
1148        if args.is_empty() {
1149            return Value::Status(0); // c:6160
1150        }
1151        let src = args.join(" "); // c:6166
1152        // c:Src/builtin.c:6164-6165 — `if (!ineval) scriptname =
1153        // "(eval)";`. Diagnostics emitted while the eval body runs
1154        // (command-not-found, parse errors, etc.) use scriptname as
1155        // the source-context prefix. Without setting it here the
1156        // BUILTIN_EVAL fast-path leaked the outer "zsh" prefix
1157        // through, breaking the `(eval):N:` convention zsh uses
1158        // for in-eval errors. Bug #420.
1159        let oscriptname = crate::ported::utils::scriptname_get();
1160        crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
1161        let mut status = with_executor(|exec| {
1162            // c:6175 execode
1163            exec.execute_script(&src).unwrap_or(1)
1164        });
1165        // c:Src/builtin.c:6211-6212 — `if (errflag && !lastval)
1166        //   lastval = errflag;`
1167        // c:Src/builtin.c:6221 — `errflag &= ~ERRFLAG_ERROR;`
1168        // eval is a CONTAINMENT boundary: an error inside the eval
1169        // body (readonly reassign, bad assoc set, ${unset?msg}, …)
1170        // breaks the eval body's lists via errflag, then eval clears
1171        // the flag and returns lastval, and the CALLER's next list
1172        // runs. zsh 5.9: `eval 'assoc=(odd)'; echo "after $?"`
1173        // prints `after 1` in -c, script, and stdin contexts.
1174        {
1175            use std::sync::atomic::Ordering;
1176            let ef = crate::ported::utils::errflag.load(Ordering::Relaxed)
1177                & crate::ported::zsh_h::ERRFLAG_ERROR;
1178            if ef != 0 && status == 0 {
1179                status = ef; // c:6212 lastval = errflag
1180            }
1181            crate::ported::utils::errflag
1182                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
1183        }
1184        crate::ported::utils::set_scriptname(oscriptname);
1185        Value::Status(status)
1186    });
1187
1188    // `builtin foo args…`: precmd-modifier that forces builtin dispatch,
1189    // bypassing alias AND function lookup. Without this, `builtin cd /`
1190    // inside a user `cd () { … }` wrapper recurses (real-world ZPWR pattern).
1191    // Handler pops argc args from the stack, treats args[0] as the builtin
1192    // name, and dispatches the rest via `dispatch_builtin` → `execbuiltin`
1193    // → `bin_*` directly. No function/alias lookup happens.
1194    vm.register_builtin(BUILTIN_BUILTIN, |vm, argc| {
1195        let args = pop_args(vm, argc);
1196        let Some((name, rest)) = args.split_first() else {
1197            // `builtin` with no args → list builtins (zsh emits nothing,
1198            // exit 0). Match that behavior; the BIN_BUILTIN bin_* in C
1199            // does the same default-list-nothing.
1200            return Value::Status(0);
1201        };
1202        // c:Src/exec.c:3435-3436 — `builtin NAME` with NAME not in
1203        // builtintab emits `zwarn("no such builtin: %s", cmdarg)`
1204        // and returns 1. zshrs's dispatch_builtin_raw bare-returned 1
1205        // silently. Probe the table here so the diagnostic fires
1206        // before dispatch.
1207        let tab = crate::ported::builtin::createbuiltintable();
1208        if !tab.contains_key(name.as_str()) {
1209            eprintln!("zshrs:1: no such builtin: {}", name);
1210            return Value::Status(1);
1211        }
1212        // `builtin foo` MUST bypass function shadow — that's the whole
1213        // point of the prefix. Use the _raw helper, not the shadow-aware
1214        // one. Without this, `cd () { builtin cd "$@"; }` recurses.
1215        Value::Status(dispatch_builtin_raw(name, rest.to_vec()))
1216    });
1217
1218    // `command foo args…` — BINF_COMMAND prefix (Src/builtin.c:44). Zsh
1219    // semantic: bypass alias+function lookup, search builtin then $PATH.
1220    // Without this, `cd () { command cd "$@" }` would re-invoke the user
1221    // wrapper (same root cause as the `builtin` bug). Flags `-p`/`-v`/`-V`
1222    // route to bin_whence with BIN_COMMAND funcid; bare `command foo`
1223    // dispatches builtin if present, else external (no fork — direct
1224    // spawn via execute_external since zshrs is non-forking).
1225    // BUILTIN_COMMAND — `command [-p] [-v|-V] cmd args…` BIN_PREFIX
1226    // (Src/builtin.c:45). PURE PASSTHRU: prepend "command" and hand
1227    // to `exec::execcmd_compile_head` (the fusevm-bytecode-time head
1228    // resolver mirroring `Src/exec.c::execcmd_exec` precommand-modifier
1229    // walk at c:3104-3187). That helper already does the -p / -v / -V
1230    // option parsing, surfaces `has_command_vv` for the whence
1231    // redirect, and reports the dispatch shape (is_builtin vs external).
1232    vm.register_builtin(BUILTIN_COMMAND, |vm, argc| {
1233        let args = pop_args(vm, argc);
1234        let mut full = Vec::with_capacity(args.len() + 1);
1235        full.push("command".to_string());
1236        full.extend(args.clone());
1237        let dispatch =
1238            crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
1239        let post = &full[dispatch.precmd_skip..];
1240        // c:Src/builtin.c:4500 — `command -p` resets PATH for the
1241        // exec to the POSIX-defined default (`getconf PATH`), so
1242        // standard utilities resolve even when the caller has
1243        // emptied $PATH. zsh restores the original PATH after the
1244        // command returns. Mirror via a scoped env::set_var.
1245        //
1246        // command's OWN options end at the first non-flag arg —
1247        // everything after the command name belongs to IT. The
1248        // previous `.any()` scan over ALL args stole `-p` from
1249        // `command mkdir -p DIR` (zconvey.plugin.zsh:44), stripping
1250        // the flag before /bin/mkdir ran → "File exists" errors on
1251        // every re-source.
1252        let mut lead = 0usize;
1253        let mut dash_p = false;
1254        let mut kept_flags: Vec<String> = Vec::new();
1255        for a in post.iter() {
1256            let s = a.as_str();
1257            if s == "--" {
1258                lead += 1;
1259                break;
1260            }
1261            if s.starts_with('-')
1262                && s.len() >= 2
1263                && s[1..].chars().all(|c| c == 'p' || c == 'v' || c == 'V')
1264            {
1265                if s.contains('p') {
1266                    dash_p = true;
1267                }
1268                // -v / -V drive the whence-style lookup downstream —
1269                // keep them in post (only the PATH-reset `p` is
1270                // consumed here).
1271                let rest: String = s[1..].chars().filter(|c| *c != 'p').collect();
1272                if !rest.is_empty() {
1273                    kept_flags.push(format!("-{}", rest));
1274                }
1275                lead += 1;
1276                continue;
1277            }
1278            break;
1279        }
1280        let mut post: Vec<String> = {
1281            let mut v = kept_flags;
1282            v.extend(post[lead..].iter().cloned());
1283            v
1284        };
1285        // c:Src/exec.c:3176-3177 — `BINF_COMMAND` arm strips a single
1286        // leading `--` end-of-options marker.
1287        // `execcmd_compile_head` (src/ported/exec.rs:1042) performs
1288        // this removal on its LOCAL `preargs` Vec but doesn't surface
1289        // the modified args; the caller still sees `--` in `full` and
1290        // tried to dispatch it as the command name. Bug #251. Mirror
1291        // the C strip here so `command -- echo hi` and
1292        // `command -p -- echo hi` route correctly.
1293        if let Some(first) = post.first() {
1294            if first == "--" {
1295                post.remove(0);
1296            }
1297        }
1298        let post = post.as_slice();
1299        let _path_guard = if dash_p {
1300            let saved = env::var("PATH").ok();
1301            let default_path = std::process::Command::new("getconf")
1302                .arg("PATH")
1303                .output()
1304                .ok()
1305                .and_then(|o| String::from_utf8(o.stdout).ok())
1306                .map(|s| s.trim().to_string())
1307                .filter(|s| !s.is_empty())
1308                .unwrap_or_else(|| "/usr/bin:/bin:/usr/sbin:/sbin".to_string());
1309            env::set_var("PATH", &default_path);
1310            crate::ported::params::setsparam("PATH", &default_path);
1311            Some(saved)
1312        } else {
1313            None
1314        };
1315        struct PathGuard {
1316            saved: Option<String>,
1317            active: bool,
1318        }
1319        impl Drop for PathGuard {
1320            fn drop(&mut self) {
1321                if !self.active {
1322                    return;
1323                }
1324                match self.saved.take() {
1325                    Some(p) => {
1326                        env::set_var("PATH", &p);
1327                        crate::ported::params::setsparam("PATH", &p);
1328                    }
1329                    None => {
1330                        env::remove_var("PATH");
1331                        crate::ported::params::setsparam("PATH", "");
1332                    }
1333                }
1334            }
1335        }
1336        let _restore = PathGuard {
1337            saved: _path_guard.unwrap_or(None),
1338            active: dash_p,
1339        };
1340        if dispatch.has_command_vv {
1341            // `-v` / `-V` → bin_whence with BIN_COMMAND funcid.
1342            let mut ops = options {
1343                ind: [0u8; MAX_OPS],
1344                args: Vec::new(),
1345                argscount: 0,
1346                argsalloc: 0,
1347            };
1348            let mut name_pos = 0usize;
1349            let mut flag_byte = b'v';
1350            for (i, a) in post.iter().enumerate() {
1351                if a.starts_with('-') && a.len() >= 2 {
1352                    let body = &a.as_bytes()[1..];
1353                    if body.contains(&b'V') {
1354                        flag_byte = b'V';
1355                    }
1356                    name_pos = i + 1;
1357                } else {
1358                    name_pos = i;
1359                    break;
1360                }
1361            }
1362            ops.ind[flag_byte as usize] = 1;
1363            let whence_args: Vec<String> = post[name_pos..].to_vec();
1364            return Value::Status(crate::ported::builtin::bin_whence(
1365                "command",
1366                &whence_args,
1367                &ops,
1368                crate::ported::hashtable_h::BIN_COMMAND,
1369            ));
1370        }
1371        if dispatch.is_empty_command {
1372            return Value::Status(0);
1373        }
1374        let Some((name, rest)) = post.split_first() else {
1375            return Value::Status(0);
1376        };
1377        // c:Src/exec.c:3275-3278 — `execcmd_compile_head` cleared
1378        // hn for the BINF_COMMAND + !POSIXBUILTINS case, surfacing
1379        // is_builtin=false. Run as external. Under POSIXBUILTINS
1380        // dispatch.is_builtin would be true; honour it.
1381        let n = name.clone();
1382        let r = rest.to_vec();
1383        if dispatch.is_builtin
1384            && crate::ported::builtin::BUILTINS
1385                .iter()
1386                .any(|b| b.node.nam == n.as_str())
1387        {
1388            return Value::Status(dispatch_builtin_raw(&n, r));
1389        }
1390        Value::Status(with_executor(|exec| exec.execute_external(&n, &r, &[])).unwrap_or(127))
1391    });
1392
1393    // `exec cmd args…` — BINF_EXEC prefix (Src/builtin.c:45). Zsh
1394    // semantic: replace the current shell process with `cmd`. On Unix
1395    // this is `execvp(2)`; the call only returns on error. zshrs is
1396    // non-forking, so the shell process IS the calling process —
1397    // execvp here directly replaces it. Options `-a name` (override
1398    // argv[0]), `-c` (clean env), `-l` (login shell — prepend `-`)
1399    // ported minimally; advanced redirect-only `exec >file` is handled
1400    // upstream by compile_zsh and never reaches this handler.
1401    vm.register_builtin(BUILTIN_EXEC, |vm, argc| {
1402        let mut args = pop_args(vm, argc);
1403        let mut argv0_override: Option<String> = None;
1404        let mut clean_env = false;
1405        let mut login = false;
1406        let mut i = 0;
1407        // c:Src/builtin.c:1075-1080 — track if any flag was consumed.
1408        // `exec -c`, `exec -l`, `exec -a NAME` without a following
1409        // command emit "exec requires a command to execute" rc=1.
1410        // Bare `exec` (no args at all) is the silent-redirect-apply
1411        // form per POSIX.
1412        let mut saw_flag = false;
1413        while i < args.len() {
1414            let a = &args[i];
1415            if a == "--" {
1416                args.remove(i);
1417                break;
1418            }
1419            // c:Src/builtin.c:42 `BIN_PREFIX("-", BINF_DASH)`. A bare
1420            // `-` is its own BINF_PREFIX builtin (BINF_DASH flag —
1421            // "login shell, prepend `-` to argv[0]"). In the canonical
1422            // precmd-walk at Src/exec.c:3056-3091 a bare `-` after
1423            // `exec` is recognized AS a builtin and stripped from
1424            // preargs (precmd_skip++), accumulating BINF_DASH into
1425            // cflags. The fast-path here bypasses execcmd_compile_head,
1426            // so we mirror the strip locally: bare `-` → set login,
1427            // remove, continue. Without this `exec -` (with no command
1428            // following) tried to exec `-` as a literal command and
1429            // exited the shell. Bug #252.
1430            if a == "-" {
1431                saw_flag = true;
1432                login = true;
1433                args.remove(i);
1434                continue;
1435            }
1436            if !a.starts_with('-') || a.len() < 2 {
1437                break;
1438            }
1439            match a.as_str() {
1440                "-a" => {
1441                    saw_flag = true;
1442                    args.remove(i);
1443                    if i < args.len() {
1444                        argv0_override = Some(args.remove(i));
1445                    }
1446                }
1447                "-c" => {
1448                    saw_flag = true;
1449                    clean_env = true;
1450                    args.remove(i);
1451                }
1452                "-l" => {
1453                    saw_flag = true;
1454                    login = true;
1455                    args.remove(i);
1456                }
1457                _ => {
1458                    // c:Src/exec.c:3196-3208 — when an unrecognized
1459                    // `-X`-style arg has NO following arg, the lexer's
1460                    // IS_DASH walk hits the "no next node" branch at
1461                    // c:3199 before the unknown-flag-letter switch at
1462                    // c:3249, so the canonical message is "exec
1463                    // requires a command to execute" rc=1 (verified vs
1464                    // `/opt/homebrew/bin/zsh -fc 'exec --bad'`).
1465                    // Consume the lone flag so the post-loop check
1466                    // fires. When a following arg exists, leave the
1467                    // unknown-flag arg in place — that arg becomes
1468                    // the command name and execution proceeds.
1469                    if args.len() == 1 {
1470                        saw_flag = true;
1471                        args.remove(i);
1472                        continue;
1473                    }
1474                    break;
1475                }
1476            }
1477        }
1478        let Some(cmd) = args.first().cloned() else {
1479            if saw_flag {
1480                // c:Src/builtin.c:1078-1080 — flags consumed but no
1481                // command follows → "exec requires a command to
1482                // execute" rc=1.
1483                eprintln!("zshrs:1: exec requires a command to execute");
1484                return Value::Status(1);
1485            }
1486            // `exec` with no command + no redirects = no-op success.
1487            return Value::Status(0);
1488        };
1489        let rest: Vec<String> = args[1..].to_vec();
1490        let display_argv0 = match argv0_override {
1491            Some(a) => a,
1492            None => {
1493                if login {
1494                    format!("-{}", cmd)
1495                } else {
1496                    cmd.clone()
1497                }
1498            }
1499        };
1500        // c:Src/exec.c::execcmd — `exec funcname` runs the function
1501        // in-process as the shell's last act, then exits with the
1502        // function's status. zsh's dispatcher falls through from the
1503        // BINF_EXEC prefix into the normal Builtin/External/Function
1504        // resolution and only execvp's if the target ISN'T a
1505        // function. Bug #101 in docs/BUGS.md: zshrs's exec went
1506        // straight to execvp and errored `not found` for shell
1507        // functions.
1508        //
1509        // For both subshell and top-level contexts: dispatch through
1510        // the function/builtin lookup first; only fall through to
1511        // execvp/spawn if the name isn't shell-resolvable.
1512        let has_user_fn = with_executor(|exec| exec.functions_compiled.contains_key(&cmd));
1513        if has_user_fn {
1514            let status = with_executor(|exec| {
1515                exec.dispatch_function_call(&cmd, &rest).unwrap_or(127)
1516            });
1517            // Top-level `exec funcname` — exit the shell with the
1518            // function's status (mirrors C's "exec replaces shell as
1519            // last act"). Subshell `(exec funcname)` — return through
1520            // the EXIT_PENDING path so the subshell body aborts and
1521            // the parent resumes via subshell_end.
1522            let in_subshell_now =
1523                with_executor(|exec| !exec.subshell_snapshots.is_empty());
1524            if in_subshell_now {
1525                crate::ported::builtin::EXIT_VAL
1526                    .store(status, std::sync::atomic::Ordering::Relaxed);
1527                crate::ported::builtin::EXIT_PENDING
1528                    .store(1, std::sync::atomic::Ordering::Relaxed);
1529                return Value::Status(status);
1530            }
1531            std::process::exit(status);
1532        }
1533        // c:Src/exec.c — builtin path: `exec builtin` runs the
1534        // builtin in-process and exits.
1535        let bn_in_tab = crate::ported::builtin::createbuiltintable().contains_key(&cmd);
1536        if bn_in_tab {
1537            let status = dispatch_builtin_raw(&cmd, rest.clone());
1538            let in_subshell_now =
1539                with_executor(|exec| !exec.subshell_snapshots.is_empty());
1540            if in_subshell_now {
1541                crate::ported::builtin::EXIT_VAL
1542                    .store(status, std::sync::atomic::Ordering::Relaxed);
1543                crate::ported::builtin::EXIT_PENDING
1544                    .store(1, std::sync::atomic::Ordering::Relaxed);
1545                return Value::Status(status);
1546            }
1547            std::process::exit(status);
1548        }
1549        // c:Src/exec.c — `exec` inside a subshell (`(exec cmd)`)
1550        // replaces ONLY the subshell child process; the parent shell
1551        // continues. C zsh always forks for `(...)`, so the actual
1552        // execvp lands in the forked child. zshrs runs subshells via
1553        // a snapshot/restore pattern in the SAME process — calling
1554        // execvp here would replace the parent too. Bug #94 in
1555        // docs/BUGS.md.
1556        //
1557        // Detect subshell context via the non-empty
1558        // `subshell_snapshots` stack. When in a subshell: spawn the
1559        // command as a child, wait for it, then signal the subshell
1560        // body to abort (return Status(N) and the caller's
1561        // subshell_end will pop the snapshot and resume the parent).
1562        let in_subshell = with_executor(|exec| !exec.subshell_snapshots.is_empty());
1563        if in_subshell {
1564            let mut command = std::process::Command::new(&cmd);
1565            command.arg0(&display_argv0);
1566            command.args(&rest);
1567            if clean_env {
1568                command.env_clear();
1569            }
1570            let status = match command.spawn() {
1571                Ok(mut child) => match child.wait() {
1572                    Ok(s) => s.code().unwrap_or(127),
1573                    Err(_) => 127,
1574                },
1575                Err(e) => {
1576                    // c:Src/exec.c:797 — `zerr("%e: %s", lerrno, arg0)`
1577                    //                     when arg0 contains `/`.
1578                    // c:872-876 — when arg0 has no `/` (PATH search
1579                    //              path), C tracks the "good" errno
1580                    //              via `isgooderr`; if all PATH entries
1581                    //              were ENOENT-not-good, eno stays 0
1582                    //              and C emits `command not found: %s`
1583                    //              instead of strerror.
1584                    // %e expands to strerror(errno) with the first
1585                    // letter lowercased (unless errno == EIO; see
1586                    // Src/utils.c:362-368). `zerr` prepends the
1587                    // scriptname:lineno: prefix — matching zsh's
1588                    // canonical `zsh:N: <errmsg>: <cmd>` pattern.
1589                    // Previously emitted `zshrs: exec: {}: not found`
1590                    // (wrong prefix, hardcoded message, missing
1591                    // lineno). Bug #140 in docs/BUGS.md.
1592                    let errno = e.raw_os_error().unwrap_or(libc::ENOENT);
1593                    let has_slash = cmd.contains('/');
1594                    if !has_slash && errno == libc::ENOENT {
1595                        // c:876 — PATH search exhausted with no good
1596                        // errno → `command not found: arg0`.
1597                        crate::ported::utils::zerr(&format!(
1598                            "command not found: {}",
1599                            cmd
1600                        ));
1601                    } else {
1602                        let mut errmsg = crate::ported::compat::strerror(errno);
1603                        if errno != libc::EIO {
1604                            if let Some(c) = errmsg.chars().next() {
1605                                errmsg = format!(
1606                                    "{}{}",
1607                                    c.to_ascii_lowercase(),
1608                                    &errmsg[c.len_utf8()..]
1609                                );
1610                            }
1611                        }
1612                        crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
1613                    }
1614                    // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
1615                    if errno == libc::EACCES || errno == libc::ENOEXEC {
1616                        126
1617                    } else {
1618                        127
1619                    }
1620                }
1621            };
1622            // Mark the subshell as exec-replaced so subsequent body
1623            // commands skip — mirrors the post-execvp "child process
1624            // is gone" reality in C. EXIT_PENDING + EXIT_VAL drive
1625            // the next ERREXIT_CHECK to unwind to the subshell-end
1626            // patch.
1627            crate::ported::builtin::EXIT_VAL
1628                .store(status, std::sync::atomic::Ordering::Relaxed);
1629            crate::ported::builtin::EXIT_PENDING
1630                .store(1, std::sync::atomic::Ordering::Relaxed);
1631            return Value::Status(status);
1632        }
1633        let mut command = std::process::Command::new(&cmd);
1634        command.arg0(&display_argv0);
1635        command.args(&rest);
1636        if clean_env {
1637            command.env_clear();
1638        }
1639        use std::os::unix::process::CommandExt;
1640        // `exec` returns the OS error iff exec(2) failed; on success
1641        // it never returns. Match zsh: print the error to stderr with
1642        // the `exec` prefix and exit 127 (cmd not found) or 126 (not
1643        // executable).
1644        let err = command.exec();
1645        // c:Src/exec.c:797 / c:872-876 — same format as in-subshell
1646        // branch. arg0-has-/ → `<strerror>: <cmd>`; arg0-no-/ +
1647        // ENOENT → `command not found: <cmd>`. Lowercase strerror
1648        // first letter unless EIO. Bug #140 in docs/BUGS.md.
1649        let errno = err.raw_os_error().unwrap_or(libc::ENOENT);
1650        let has_slash = cmd.contains('/');
1651        if !has_slash && errno == libc::ENOENT {
1652            crate::ported::utils::zerr(&format!("command not found: {}", cmd));
1653        } else {
1654            let mut errmsg = crate::ported::compat::strerror(errno);
1655            if errno != libc::EIO {
1656                if let Some(c) = errmsg.chars().next() {
1657                    errmsg = format!(
1658                        "{}{}",
1659                        c.to_ascii_lowercase(),
1660                        &errmsg[c.len_utf8()..]
1661                    );
1662                }
1663            }
1664            crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
1665        }
1666        // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
1667        let code = if errno == libc::EACCES || errno == libc::ENOEXEC {
1668            126
1669        } else {
1670            127
1671        };
1672        std::process::exit(code);
1673    });
1674
1675    reg_passthru!(vm, BUILTIN_LET, "let");
1676
1677    // Job control
1678    reg_passthru!(vm, BUILTIN_JOBS, "jobs");
1679    reg_passthru!(vm, BUILTIN_FG, "fg");
1680    reg_passthru!(vm, BUILTIN_BG, "bg");
1681    reg_passthru!(vm, BUILTIN_KILL, "kill");
1682    reg_passthru!(vm, BUILTIN_DISOWN, "disown");
1683    reg_passthru!(vm, BUILTIN_WAIT, "wait");
1684    reg_passthru!(vm, BUILTIN_SUSPEND, "suspend");
1685
1686    // History — `fc` / `history` / `r` all route to `bin_fc` (zsh
1687    // registers them as aliases of the same builtin per Src/builtin.c).
1688    reg_passthru!(vm, BUILTIN_FC, "fc");
1689    reg_passthru!(vm, BUILTIN_HISTORY, "history");
1690    reg_passthru!(vm, BUILTIN_R, "r");
1691
1692    // Aliases — alias is `BINF_MAGICEQUALS` per Src/builtin.c:50.
1693    // c:Src/exec.c:3298-3304 — when a builtin has BINF_MAGICEQUALS,
1694    // execcmd_exec sets esprefork = PREFORK_TYPESET and calls
1695    // `prefork(args, esprefork, NULL)` on the argv. prefork (subst.c:
1696    // 100) drives `filesub` on each word (c:133), which (c:677-686)
1697    // looks for the assignment Equals and runs `filesubstr` on the
1698    // VALUE side. That's how `alias bad===` triggers equalsubstr's
1699    // "= not found" via the inner Equals after the first `=`.
1700    //
1701    // The fusevm dispatch path doesn't go through execcmd_exec, so
1702    // BUILTIN_ALIAS previously passed args straight to bin_alias with
1703    // no expansion — `alias x=~/foo` stored literal `~/foo` (no tilde
1704    // expand), `alias bad===` stored a broken entry without firing
1705    // the "= not found" diagnostic. The prefork(PREFORK_TYPESET) runs
1706    // per arg word via BUILTIN_MAGIC_EQUALS_PREFORK ops that
1707    // compile_simple emits BEFORE the redirect scope opens (matching
1708    // c:3304 prefork-before-addfd order), so the dispatch here is a
1709    // plain passthrough — re-running prefork would double-fire the
1710    // "= not found" diagnostic.
1711    reg_passthru!(vm, BUILTIN_ALIAS, "alias");
1712    // c:Src/exec.c:3298-3304 — per-word magic-equals prefork; see the
1713    // const doc at BUILTIN_MAGIC_EQUALS_PREFORK. prefork's filesub
1714    // trigger (subst.c:678 `strchr(*namptr+1, Equals)`) looks for the
1715    // EQUALS TOKEN, not literal `=`. The fusevm path delivers args
1716    // already-untokenized, so re-tokenize each element via
1717    // `shtokenize` (the same call C's lexer makes implicitly when
1718    // assembling the word) so prefork sees Equals tokens at `=`
1719    // boundaries and Tilde tokens at `~` starts. After prefork
1720    // expands, untokenize for storage.
1721    vm.register_builtin(BUILTIN_MAGIC_EQUALS_PREFORK, |vm, _argc| {
1722        let raw = vm.pop();
1723        let inputs: Vec<String> = match raw {
1724            Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
1725            other => vec![other.to_str()],
1726        };
1727        let mut as_linklist: crate::ported::linklist::LinkList<String> =
1728            Default::default();
1729        for s in &inputs {
1730            let mut tokd = s.clone();
1731            crate::ported::glob::shtokenize(&mut tokd);
1732            as_linklist.push_back(tokd);
1733        }
1734        let mut rf = 0i32;
1735        crate::ported::subst::prefork(
1736            &mut as_linklist,
1737            crate::ported::zsh_h::PREFORK_TYPESET,
1738            &mut rf,
1739        );
1740        let mut expanded: Vec<String> = Vec::with_capacity(inputs.len());
1741        while let Some(s) = as_linklist.pop_front() {
1742            expanded.push(crate::ported::lex::untokenize(&s).to_string());
1743        }
1744        if expanded.len() == 1 {
1745            Value::str(expanded.into_iter().next().unwrap())
1746        } else {
1747            Value::Array(expanded.into_iter().map(Value::str).collect())
1748        }
1749    });
1750
1751    // Options. `setopt` (BIN_SETOPT=0) / `unsetopt` (BIN_UNSETOPT=1)
1752    // share bin_setopt (options.c:580) — funcid bit discriminates
1753    // the polarity via BUILTINS table entries.
1754    reg_passthru!(vm, BUILTIN_SET, "set");
1755    reg_passthru!(vm, BUILTIN_SETOPT, "setopt");
1756    reg_passthru!(vm, BUILTIN_UNSETOPT, "unsetopt");
1757
1758    vm.register_builtin(BUILTIN_SHOPT, |vm, argc| {
1759        let args = pop_args(vm, argc);
1760        let status = crate::extensions::ext_builtins::shopt(&args);
1761        Value::Status(status)
1762    });
1763
1764    reg_passthru!(vm, BUILTIN_EMULATE, "emulate");
1765    reg_passthru!(vm, BUILTIN_GETOPTS, "getopts");
1766    reg_passthru!(vm, BUILTIN_AUTOLOAD, "autoload");
1767    reg_passthru!(vm, BUILTIN_FUNCTIONS, "functions");
1768    reg_passthru!(vm, BUILTIN_TRAP, "trap");
1769    reg_passthru!(vm, BUILTIN_DIRS, "dirs");
1770    // pushd / popd dispatch through canonical bin_cd via execbuiltin
1771    // — the BUILTINS table at src/ported/builtin.rs:9298 wires
1772    // `pushd` to bin_cd with funcid=BIN_PUSHD, and `popd` similarly
1773    // with BIN_POPD. Without these reg_passthru lines the fusevm
1774    // BUILTIN_PUSHD/POPD opcodes had no handler installed, so the
1775    // emitted CallBuiltin(110, …) silently returned a no-op and the
1776    // dirstack/$dirstack/pwd all stayed unchanged.
1777    reg_passthru!(vm, BUILTIN_PUSHD, "pushd");
1778    reg_passthru!(vm, BUILTIN_POPD, "popd");
1779    // type / whence / where / which all route through `bin_whence`
1780    // (canonical port at `src/ported/builtin.rs:3734` of
1781    // `Src/builtin.c:3975`). Each gets its own opcode so funcid +
1782    // defopts come from the BUILTINS table entry — execbuiltin
1783    // applies them correctly via the module-level dispatch_builtin.
1784    reg_passthru!(vm, BUILTIN_WHENCE, "whence");
1785    reg_passthru!(vm, BUILTIN_TYPE, "type");
1786    reg_passthru!(vm, BUILTIN_WHICH, "which");
1787    reg_passthru!(vm, BUILTIN_WHERE, "where");
1788    reg_passthru!(vm, BUILTIN_HASH, "hash");
1789    reg_passthru!(vm, BUILTIN_REHASH, "rehash");
1790
1791    // `unhash`/`unalias`/`unfunction` share `bin_unhash` (Src/builtin.c
1792    // c:4350) but each carries its own funcid (BIN_UNHASH /
1793    // BIN_UNALIAS / BIN_UNFUNCTION) in the BUILTINS table.
1794    reg_passthru!(vm, BUILTIN_UNHASH, "unhash");
1795    vm.register_builtin(BUILTIN_UNALIAS, |vm, argc| {
1796        let args = pop_args(vm, argc);
1797        Value::Status(dispatch_builtin("unalias", args))
1798    });
1799    vm.register_builtin(BUILTIN_UNFUNCTION, |vm, argc| {
1800        let args = pop_args(vm, argc);
1801        Value::Status(dispatch_builtin("unfunction", args))
1802    });
1803
1804    // Completion
1805    vm.register_builtin(BUILTIN_COMPGEN, |vm, argc| {
1806        let args = pop_args(vm, argc);
1807        // c:Bug #475/#555 — `compgen` is a bash-only builtin. In
1808        // `--zsh` mode emit "command not found" matching zsh's
1809        // external-command lookup miss.
1810        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1811            eprintln!("zsh:1: command not found: compgen");
1812            let _ = args;
1813            return Value::Status(127);
1814        }
1815        let status = with_executor(|exec| exec.builtin_compgen(&args));
1816        Value::Status(status)
1817    });
1818
1819    vm.register_builtin(BUILTIN_COMPLETE, |vm, argc| {
1820        let args = pop_args(vm, argc);
1821        // c:Bug #475 — `complete` is a bash-only builtin. Same gate
1822        // as BUILTIN_COMPGEN above.
1823        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1824            eprintln!("zsh:1: command not found: complete");
1825            let _ = args;
1826            return Value::Status(127);
1827        }
1828        let status = with_executor(|exec| exec.builtin_complete(&args));
1829        Value::Status(status)
1830    });
1831
1832    reg_passthru!(vm, BUILTIN_COMPADD, "compadd");
1833    reg_passthru!(vm, BUILTIN_COMPSET, "compset");
1834
1835    // See the const's doc comment for the contract. Stack (bottom→top):
1836    // base, e1, …, eN — argc = N + 1.
1837    vm.register_builtin(BUILTIN_TYPESET_PAREN_PACK, |vm, argc| {
1838        let mut vals: Vec<Value> = Vec::with_capacity(argc as usize);
1839        for _ in 0..argc {
1840            vals.push(vm.pop());
1841        }
1842        vals.reverse();
1843        let mut it = vals.into_iter();
1844        let mut out = it.next().map(|v| v.to_str()).unwrap_or_default();
1845        for v in it {
1846            match v {
1847                // Array → splice items as separate elements (splat);
1848                // empty array contributes nothing (empty elision).
1849                Value::Array(items) => {
1850                    for item in items {
1851                        out.push('\u{1f}');
1852                        out.push_str(&item.to_str());
1853                    }
1854                }
1855                other => {
1856                    out.push('\u{1f}');
1857                    out.push_str(&other.to_str());
1858                }
1859            }
1860        }
1861        Value::str(out)
1862    });
1863
1864    vm.register_builtin(BUILTIN_TYPESET_PAREN_CLOSE, |vm, _argc| {
1865        let base = vm.pop().to_str();
1866        Value::str(format!("{}\u{1f})", base))
1867    });
1868
1869    vm.register_builtin(BUILTIN_COMPDEF, |vm, argc| {
1870        let args = pop_args(vm, argc);
1871        // A compsys-defined `compdef` FUNCTION (autoload compinit →
1872        // compinit defines compdef) wins over the extension builtin
1873        // in every mode.
1874        if let Some(s) = try_user_fn_override("compdef", &args) {
1875            return Value::Status(s);
1876        }
1877        // c:zsh -f — compdef is a FUNCTION defined by compinit, not
1878        // a builtin; without compinit it's command-not-found (127).
1879        // openshift-aliases sources `<(oc completion zsh)` whose
1880        // compdef calls must error exactly like the oracle. Same
1881        // gate shape as BUILTIN_COMPGEN/BUILTIN_COMPLETE above.
1882        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1883            eprintln!("zsh:1: command not found: compdef");
1884            return Value::Status(127);
1885        }
1886        Value::Status(with_executor(|exec| exec.builtin_compdef(&args)))
1887    });
1888
1889    vm.register_builtin(BUILTIN_COMPINIT, |vm, argc| {
1890        let args = pop_args(vm, argc);
1891        Value::Status(with_executor(|exec| exec.builtin_compinit(&args)))
1892    });
1893
1894    vm.register_builtin(BUILTIN_CDREPLAY, |vm, argc| {
1895        let args = pop_args(vm, argc);
1896        Value::Status(with_executor(|exec| exec.builtin_cdreplay(&args)))
1897    });
1898
1899    // Zsh-specific
1900    reg_passthru!(vm, BUILTIN_ZSTYLE, "zstyle");
1901    reg_passthru!(vm, BUILTIN_ZMODLOAD, "zmodload");
1902    reg_passthru!(vm, BUILTIN_BINDKEY, "bindkey");
1903    reg_passthru!(vm, BUILTIN_ZLE, "zle");
1904    reg_passthru!(vm, BUILTIN_VARED, "vared");
1905    reg_passthru!(vm, BUILTIN_ZCOMPILE, "zcompile");
1906    reg_passthru!(vm, BUILTIN_ZFORMAT, "zformat");
1907    reg_passthru!(vm, BUILTIN_ZPARSEOPTS, "zparseopts");
1908    reg_passthru!(vm, BUILTIN_ZREGEXPARSE, "zregexparse");
1909
1910    // Resource limits
1911    reg_passthru!(vm, BUILTIN_ULIMIT, "ulimit");
1912    reg_passthru!(vm, BUILTIN_LIMIT, "limit");
1913    reg_passthru!(vm, BUILTIN_UNLIMIT, "unlimit");
1914    reg_passthru!(vm, BUILTIN_UMASK, "umask");
1915
1916    // Misc
1917    reg_passthru!(vm, BUILTIN_TIMES, "times");
1918
1919    vm.register_builtin(BUILTIN_CALLER, |vm, argc| {
1920        let args = pop_args(vm, argc);
1921        // c:Bug #475 — `caller` is a bash-only builtin. In `--zsh`
1922        // mode emit the canonical "command not found" diagnostic
1923        // and rc=127 matching zsh's external-command-lookup miss.
1924        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1925            eprintln!("zsh:1: command not found: caller");
1926            let _ = args;
1927            return Value::Status(127);
1928        }
1929        Value::Status(with_executor(|exec| exec.builtin_caller(&args)))
1930    });
1931
1932    vm.register_builtin(BUILTIN_HELP, |vm, argc| {
1933        let args = pop_args(vm, argc);
1934        // c:Bug #475 — `help` is a bash-only builtin. Same gate as
1935        // BUILTIN_CALLER above.
1936        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1937            eprintln!("zsh:1: command not found: help");
1938            let _ = args;
1939            return Value::Status(127);
1940        }
1941        Value::Status(with_executor(|exec| exec.builtin_help(&args)))
1942    });
1943
1944    reg_passthru!(vm, BUILTIN_ENABLE, "enable");
1945    reg_passthru!(vm, BUILTIN_DISABLE, "disable");
1946    reg_passthru!(vm, BUILTIN_TTYCTL, "ttyctl");
1947    reg_passthru!(vm, BUILTIN_SYNC, "sync");
1948    reg_passthru!(vm, BUILTIN_MKDIR, "mkdir");
1949    reg_passthru!(vm, BUILTIN_STRFTIME, "strftime");
1950
1951    vm.register_builtin(BUILTIN_ZSLEEP, |vm, argc| {
1952        Value::Status(crate::extensions::ext_builtins::zsleep(&pop_args(vm, argc)))
1953    });
1954
1955    reg_passthru!(vm, BUILTIN_ZSYSTEM, "zsystem");
1956
1957    // PCRE
1958    reg_passthru!(vm, BUILTIN_PCRE_COMPILE, "pcre_compile");
1959    reg_passthru!(vm, BUILTIN_PCRE_MATCH, "pcre_match");
1960    reg_passthru!(vm, BUILTIN_PCRE_STUDY, "pcre_study");
1961
1962    // Database (GDBM)
1963    reg_passthru!(vm, BUILTIN_ZTIE, "ztie");
1964    reg_passthru!(vm, BUILTIN_ZUNTIE, "zuntie");
1965    reg_passthru!(vm, BUILTIN_ZGDBMPATH, "zgdbmpath");
1966
1967    // Prompt
1968    vm.register_builtin(BUILTIN_PROMPTINIT, |vm, argc| {
1969        let args = pop_args(vm, argc);
1970        Value::Status(crate::extensions::ext_builtins::promptinit(&args))
1971    });
1972
1973    vm.register_builtin(BUILTIN_PROMPT, |vm, argc| {
1974        let args = pop_args(vm, argc);
1975        Value::Status(crate::extensions::ext_builtins::prompt(&args))
1976    });
1977
1978    // Async / Parallel (zshrs extensions)
1979    vm.register_builtin(BUILTIN_ASYNC, |vm, argc| {
1980        let args = pop_args(vm, argc);
1981        let status = with_executor(|exec| exec.builtin_async(&args));
1982        Value::Status(status)
1983    });
1984
1985    vm.register_builtin(BUILTIN_AWAIT, |vm, argc| {
1986        let args = pop_args(vm, argc);
1987        let status = with_executor(|exec| exec.builtin_await(&args));
1988        Value::Status(status)
1989    });
1990
1991    vm.register_builtin(BUILTIN_PMAP, |vm, argc| {
1992        let args = pop_args(vm, argc);
1993        let status = with_executor(|exec| exec.builtin_pmap(&args));
1994        Value::Status(status)
1995    });
1996
1997    vm.register_builtin(BUILTIN_PGREP, |vm, argc| {
1998        let args = pop_args(vm, argc);
1999        let status = with_executor(|exec| exec.builtin_pgrep(&args));
2000        Value::Status(status)
2001    });
2002
2003    vm.register_builtin(BUILTIN_PEACH, |vm, argc| {
2004        let args = pop_args(vm, argc);
2005        let status = with_executor(|exec| exec.builtin_peach(&args));
2006        Value::Status(status)
2007    });
2008
2009    vm.register_builtin(BUILTIN_BARRIER, |vm, argc| {
2010        let args = pop_args(vm, argc);
2011        let status = with_executor(|exec| exec.builtin_barrier(&args));
2012        Value::Status(status)
2013    });
2014
2015    // Intercept (AOP)
2016    vm.register_builtin(BUILTIN_INTERCEPT, |vm, argc| {
2017        let args = pop_args(vm, argc);
2018        let status = with_executor(|exec| exec.builtin_intercept(&args));
2019        Value::Status(status)
2020    });
2021
2022    vm.register_builtin(BUILTIN_INTERCEPT_PROCEED, |vm, argc| {
2023        let args = pop_args(vm, argc);
2024        let status = with_executor(|exec| exec.builtin_intercept_proceed(&args));
2025        Value::Status(status)
2026    });
2027
2028    // Debug / Profile
2029    vm.register_builtin(BUILTIN_DOCTOR, |vm, argc| {
2030        let args = pop_args(vm, argc);
2031        let status = with_executor(|exec| exec.builtin_doctor(&args));
2032        Value::Status(status)
2033    });
2034
2035    vm.register_builtin(BUILTIN_DBVIEW, |vm, argc| {
2036        let args = pop_args(vm, argc);
2037        let status = with_executor(|exec| exec.builtin_dbview(&args));
2038        Value::Status(status)
2039    });
2040
2041    vm.register_builtin(BUILTIN_PROFILE, |vm, argc| {
2042        let args = pop_args(vm, argc);
2043        let status = with_executor(|exec| exec.builtin_profile(&args));
2044        Value::Status(status)
2045    });
2046
2047    reg_passthru!(vm, BUILTIN_ZPROF, "zprof");
2048
2049    // ═══════════════════════════════════════════════════════════════════════
2050    // Coreutils builtins (anti-fork, gated by !posix_mode)
2051    //
2052    // All of these are routinely wrapped by user functions in real
2053    // dotfiles (zpwr, oh-my-zsh, etc.) — `cat() { ... }`, `ls() { ... }`,
2054    // `find() { ... }`. Each handler MUST consult try_user_fn_override
2055    // first (via reg_overridable!) so the user definition wins, matching
2056    // zsh's alias → function → builtin dispatch order.
2057    // ═══════════════════════════════════════════════════════════════════════
2058
2059    reg_overridable!(vm, BUILTIN_CAT, "cat", builtin_cat);
2060    reg_overridable!(vm, BUILTIN_HEAD, "head", builtin_head);
2061    reg_overridable!(vm, BUILTIN_TAIL, "tail", builtin_tail);
2062    reg_overridable!(vm, BUILTIN_WC, "wc", builtin_wc);
2063    reg_overridable!(vm, BUILTIN_BASENAME, "basename", builtin_basename);
2064    reg_overridable!(vm, BUILTIN_DIRNAME, "dirname", builtin_dirname);
2065    reg_overridable!(vm, BUILTIN_TOUCH, "touch", builtin_touch);
2066    reg_overridable!(vm, BUILTIN_REALPATH, "realpath", builtin_realpath);
2067    reg_overridable!(vm, BUILTIN_SORT, "sort", builtin_sort);
2068    reg_overridable!(vm, BUILTIN_FIND, "find", builtin_find);
2069    reg_overridable!(vm, BUILTIN_UNIQ, "uniq", builtin_uniq);
2070    reg_overridable!(vm, BUILTIN_CUT, "cut", builtin_cut);
2071    reg_overridable!(vm, BUILTIN_TR, "tr", builtin_tr);
2072    reg_overridable!(vm, BUILTIN_SEQ, "seq", builtin_seq);
2073    reg_overridable!(vm, BUILTIN_REV, "rev", builtin_rev);
2074    reg_overridable!(vm, BUILTIN_TEE, "tee", builtin_tee);
2075    reg_overridable!(vm, BUILTIN_SLEEP, "sleep", builtin_sleep);
2076    reg_overridable!(vm, BUILTIN_WHOAMI, "whoami", builtin_whoami);
2077    reg_overridable!(vm, BUILTIN_ID, "id", builtin_id);
2078
2079    reg_overridable!(vm, BUILTIN_HOSTNAME, "hostname", builtin_hostname);
2080    reg_overridable!(vm, BUILTIN_UNAME, "uname", builtin_uname);
2081    reg_overridable!(vm, BUILTIN_DATE, "date", builtin_date);
2082    reg_overridable!(vm, BUILTIN_MKTEMP, "mktemp", builtin_mktemp);
2083    // `cp` — zshrs extension (NOT in upstream zsh; upstream's
2084    // zsh/files module ships `ln`/`mv`/`rm`/`chmod`/`chown` but no
2085    // `cp`). In-process implementation in
2086    // `ext_builtins::cp_impl` — recursive copy with -r/-R, -f, -i,
2087    // -n, -p (chown + utimensat), -v. ID 263 is the first slot
2088    // past fusevm's built-in range (260-262) and before BUILTIN_MAX
2089    // (280).
2090    /// `BUILTIN_CP` constant.
2091    pub const BUILTIN_CP: u16 = 263;
2092    reg_overridable!(vm, BUILTIN_CP, "cp", builtin_cp);
2093
2094    // Pipeline execution — bytecode-native fork-per-stage. Pops N sub-chunk
2095    // indices, forks N children with stdin/stdout wired through N-1 pipes,
2096    // each child runs its stage's compiled bytecode and exits. Parent waits
2097    // and returns the last stage's status.
2098    //
2099    // Caveats: post-fork in a multi-threaded program, only async-signal-safe
2100    // ops are POSIX-safe. We violate this (running the bytecode VM after fork
2101    // touches mutexes like REGEX_CACHE). In practice, most pipeline stages
2102    // don't touch shared mutex state — externals fork/exec away, builtins do
2103    // pure I/O. Risks are bounded; if a stage does touch a held mutex, the
2104    // child deadlocks.
2105    vm.register_builtin(BUILTIN_RUN_PIPELINE, |vm, argc| {
2106        let n = argc as usize;
2107        if n == 0 {
2108            return Value::Status(0);
2109        }
2110
2111        // c:Src/exec.c — every pipeline stage forks from the current
2112        // shell state, so each stage observes the pre-pipeline $? until
2113        // it runs its own command. Stage sub-VMs start fresh with
2114        // last_status=0, so seed them with the parent's lastval; without
2115        // this `false; echo $? | cat` prints 0 instead of zsh's 1.
2116        let parent_status = vm.last_status;
2117
2118        // Pop N sub-chunk indices (LIFO → reverse to stage order)
2119        let mut indices: Vec<u16> = Vec::with_capacity(n);
2120        for _ in 0..n {
2121            indices.push(vm.pop().to_int() as u16);
2122        }
2123        indices.reverse();
2124
2125        // Clone each stage's sub-chunk
2126        let stages: Vec<fusevm::Chunk> = indices
2127            .iter()
2128            .filter_map(|&i| vm.chunk.sub_chunks.get(i as usize).cloned())
2129            .collect();
2130        if stages.len() != n {
2131            return Value::Status(1);
2132        }
2133
2134        // Single stage — no pipe, just run inline
2135        if n == 1 {
2136            let stage = stages.into_iter().next().unwrap();
2137            crate::fusevm_disasm::maybe_print_stdout("pipeline:single", &stage);
2138            let mut stage_vm = fusevm::VM::new(stage);
2139            stage_vm.last_status = parent_status;
2140            register_builtins(&mut stage_vm);
2141            let _ = stage_vm.run();
2142            return Value::Status(stage_vm.last_status);
2143        }
2144
2145        // Build N-1 pipes
2146        let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(n - 1);
2147        for _ in 0..n - 1 {
2148            let mut fds = [0i32; 2];
2149            if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
2150                // Cleanup any pipes we already created
2151                for (r, w) in &pipes {
2152                    unsafe {
2153                        libc::close(*r);
2154                        libc::close(*w);
2155                    }
2156                }
2157                return Value::Status(1);
2158            }
2159            pipes.push((fds[0], fds[1]));
2160        }
2161
2162        // zsh runs the LAST stage of a pipeline in the CURRENT shell
2163        // (not a forked child) so a trailing `read x` keeps its
2164        // assignment in the parent. Other shells (bash) fork every
2165        // stage. Honor zsh by leaving stage N-1 inline. Forks the
2166        // first N-1 stages with fork(); runs the last in this process
2167        // with stdin dup2'd to the last pipe's read end and stdout
2168        // restored after.
2169        let last_idx = n - 1;
2170        let stages_vec: Vec<fusevm::Chunk> = stages.into_iter().collect();
2171
2172        let mut child_pids: Vec<libc::pid_t> = Vec::with_capacity(n - 1);
2173        for (i, chunk) in stages_vec.iter().take(last_idx).enumerate() {
2174            match unsafe { libc::fork() } {
2175                -1 => {
2176                    // fork failed — kill any children we already started
2177                    for pid in &child_pids {
2178                        unsafe { libc::kill(*pid, libc::SIGTERM) };
2179                    }
2180                    for (r, w) in &pipes {
2181                        unsafe {
2182                            libc::close(*r);
2183                            libc::close(*w);
2184                        }
2185                    }
2186                    return Value::Status(1);
2187                }
2188                0 => {
2189                    // Reset SIGPIPE to default so a broken-pipe write
2190                    // kills the child cleanly instead of triggering a
2191                    // Rust println! panic. The parent shell ignores
2192                    // SIGPIPE so it can handle EPIPE itself, but child
2193                    // pipeline stages should die quietly when their
2194                    // downstream stage closes early (e.g. `seq | head -3`).
2195                    unsafe {
2196                        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
2197                    }
2198                    // c:Src/exec.c — pipeline children are forked
2199                    // subshells; their EXIT trap context is reset so
2200                    // the parent's `trap '...' EXIT` doesn't fire when
2201                    // the child exits. Mirror by dropping EXIT from
2202                    // the inherited traps_table inside the child.
2203                    if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
2204                        t.remove("EXIT");
2205                    }
2206                    // c:Src/exec.c:2862 → 1219 — pipeline children run
2207                    // entersubsh with ESUB_PGRP, which clears the job
2208                    // table (clearjobtab, Src/jobs.c:1780). Without
2209                    // this, `sleep 5 & jobs -p | wc -l` reports 1 in
2210                    // the forked stage where zsh reports 0. The fork
2211                    // already copy-isolates the statics, so mutating
2212                    // them here can't leak to the parent.
2213                    with_executor(|exec| {
2214                        let monitor = crate::ported::zsh_h::isset(
2215                            crate::ported::zsh_h::MONITOR,
2216                        ) as i32;
2217                        crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
2218                    });
2219                    *crate::ported::jobs::THISJOB
2220                        .get_or_init(|| std::sync::Mutex::new(-1))
2221                        .lock()
2222                        .unwrap() = -1;
2223                    // Child: wire stdin from previous pipe's read end
2224                    if i > 0 {
2225                        unsafe {
2226                            libc::dup2(pipes[i - 1].0, libc::STDIN_FILENO);
2227                        }
2228                    }
2229                    // Wire stdout to next pipe's write end
2230                    unsafe {
2231                        libc::dup2(pipes[i].1, libc::STDOUT_FILENO);
2232                    }
2233                    // (Pipe-output MULTIOS marking — c:Src/exec.c:3724 —
2234                    // is emitted INTO the stage chunk by compile_pipe
2235                    // via BUILTIN_PIPE_OUTPUT_MARK, gated on the stage's
2236                    // top-level command actually carrying redirects, so
2237                    // a nested `{ echo a > f; } | cat` body redirect
2238                    // does not wrongly join the pipe.)
2239                    // Close all original pipe fds (keeping stdin/stdout dups)
2240                    for (r, w) in &pipes {
2241                        unsafe {
2242                            libc::close(*r);
2243                            libc::close(*w);
2244                        }
2245                    }
2246
2247                    // Run this stage's bytecode on a fresh VM
2248                    crate::fusevm_disasm::maybe_print_stdout(
2249                        &format!("pipeline:child:stage:{i}"),
2250                        chunk,
2251                    );
2252                    let mut stage_vm = fusevm::VM::new(chunk.clone());
2253                    stage_vm.last_status = parent_status;
2254                    register_builtins(&mut stage_vm);
2255                    let _ = stage_vm.run();
2256                    // Flush any buffered output before exiting
2257                    let _ = std::io::stdout().flush();
2258                    let _ = std::io::stderr().flush();
2259                    std::process::exit(stage_vm.last_status);
2260                }
2261                pid => {
2262                    child_pids.push(pid);
2263                }
2264            }
2265        }
2266
2267        // Parent runs the LAST stage inline. Save stdin, dup the last
2268        // pipe's read end onto fd 0, run the chunk, restore stdin.
2269        // Close every other pipe fd so the producer side gets EOF
2270        // when the last upstream stage exits.
2271        let saved_stdin = unsafe { libc::dup(libc::STDIN_FILENO) };
2272        if last_idx > 0 {
2273            let read_fd = pipes[last_idx - 1].0;
2274            unsafe {
2275                libc::dup2(read_fd, libc::STDIN_FILENO);
2276            }
2277        }
2278        // Close all pipe fds in the parent now that stdin is wired.
2279        // (Children already have their own copies. The dup2 above
2280        // already gave us a fresh fd 0 if needed.)
2281        for (r, w) in &pipes {
2282            unsafe {
2283                libc::close(*r);
2284                libc::close(*w);
2285            }
2286        }
2287
2288        // Run the last stage's bytecode on a sub-VM with the host
2289        // wired up. The host points back at the executor so reads
2290        // (`read x`) update the parent's variables directly.
2291        let last_stage_status = {
2292            let last_chunk = stages_vec.into_iter().last().unwrap();
2293            crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
2294            let mut stage_vm = fusevm::VM::new(last_chunk);
2295            stage_vm.last_status = parent_status;
2296            register_builtins(&mut stage_vm);
2297            stage_vm.set_shell_host(Box::new(ZshrsHost));
2298            let _ = stage_vm.run();
2299            let _ = std::io::stdout().flush();
2300            let _ = std::io::stderr().flush();
2301            stage_vm.last_status
2302        };
2303
2304        // Restore stdin
2305        if saved_stdin >= 0 {
2306            unsafe {
2307                libc::dup2(saved_stdin, libc::STDIN_FILENO);
2308                libc::close(saved_stdin);
2309            }
2310        }
2311
2312        // Wait for all forked stages, capture per-stage statuses for PIPESTATUS.
2313        let mut pipestatus: Vec<i32> = Vec::with_capacity(n);
2314        for pid in child_pids {
2315            let mut status: i32 = 0;
2316            unsafe {
2317                libc::waitpid(pid, &mut status, 0);
2318            }
2319            let s = if libc::WIFEXITED(status) {
2320                libc::WEXITSTATUS(status)
2321            } else if libc::WIFSIGNALED(status) {
2322                128 + libc::WTERMSIG(status)
2323            } else {
2324                1
2325            };
2326            pipestatus.push(s);
2327        }
2328        // Append the in-parent last-stage status so `pipestatus` ends
2329        // with N entries (one per stage).
2330        pipestatus.push(last_stage_status);
2331        // Pipeline exit status: by default, the LAST stage's status.
2332        // With `setopt pipefail` (or `set -o pipefail`), use the
2333        // first non-zero stage status (so failures earlier in the
2334        // pipeline propagate even if the last stage succeeded).
2335        let pipefail_on = with_executor(|exec| opt_state_get("pipefail").unwrap_or(false));
2336        let last_status = if pipefail_on {
2337            pipestatus
2338                .iter()
2339                .copied()
2340                .rfind(|&s| s != 0)
2341                .or_else(|| pipestatus.last().copied())
2342                .unwrap_or(0)
2343        } else {
2344            *pipestatus.last().unwrap_or(&0)
2345        };
2346
2347        // c:Src/params.c:265,438 — only `pipestatus` (lowercase) is the
2348        // zsh special parameter; bash's `PIPESTATUS` doesn't exist in
2349        // zsh's special-params table. Prior port also populated
2350        // `PIPESTATUS` "for portability" — but that's a real divergence
2351        // from zsh: a script doing `[[ -z $PIPESTATUS ]]` to detect
2352        // zsh-vs-bash would mis-classify. Bug #64 in docs/BUGS.md.
2353        with_executor(|exec| {
2354            let strs: Vec<String> = pipestatus.iter().map(|s| s.to_string()).collect();
2355            exec.set_array("pipestatus".to_string(), strs);
2356        });
2357
2358        Value::Status(last_status)
2359    });
2360
2361    // Array→String join. Pops one value; if it's an Array (e.g. from Op::Glob),
2362    // joins string-coerced elements with a single space. Pass-through for
2363    // non-arrays so the op is safe to chain after any String-or-Array producer.
2364    vm.register_builtin(BUILTIN_ARRAY_JOIN, |vm, _argc| {
2365        let val = vm.pop();
2366        match val {
2367            Value::Array(items) => {
2368                let parts: Vec<String> = items.iter().map(|v| v.to_str()).collect();
2369                Value::str(parts.join(" "))
2370            }
2371            other => other,
2372        }
2373    });
2374
2375    // `cmd &` background execution. Compile_list emits this for any item
2376    // followed by ListOp::Amp: the job text + the cmd's sub-chunk index are
2377    // pushed, then this builtin pops both, looks up the chunk, forks. The
2378    // child detaches via setsid (so SIGINT to the foreground job doesn't kill
2379    // it), runs the bytecode on a fresh VM with builtins re-registered, exits
2380    // with the last status. The parent registers the job in the canonical
2381    // JOBTAB (c:Src/exec.c::execpline Z_ASYNC arm) and returns Status(0).
2382    vm.register_builtin(BUILTIN_RUN_BG, |vm, _argc| {
2383        let sub_idx = vm.pop().to_int() as usize;
2384        let job_text = vm.pop().to_str();
2385        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
2386            Some(c) => c,
2387            None => return Value::Status(1),
2388        };
2389
2390        match unsafe { libc::fork() } {
2391            -1 => Value::Status(1),
2392            0 => {
2393                // Child: detach and run.
2394                unsafe { libc::setsid() };
2395                crate::fusevm_disasm::maybe_print_stdout("background_job", &chunk);
2396                let mut bg_vm = fusevm::VM::new(chunk);
2397                register_builtins(&mut bg_vm);
2398                let _ = bg_vm.run();
2399                let _ = std::io::stdout().flush();
2400                let _ = std::io::stderr().flush();
2401                std::process::exit(bg_vm.last_status);
2402            }
2403            pid => {
2404                // Parent: record the PID into `$!` (most recent
2405                // backgrounded job's pid). zsh exposes this for any
2406                // script that needs `wait $!`. Also register the
2407                // bare-pid job so a no-args `wait` can synchronize.
2408                // c:Src/jobs.c:73 — `lastpid = pid;` after a
2409                // background fork. zshrs's `$!` getter
2410                // (params.rs::lookup_special_var "!") reads from
2411                // the same atomic, so a single store here is the
2412                // canonical writer.
2413                crate::ported::modules::clone::lastpid
2414                    .store(pid, std::sync::atomic::Ordering::Relaxed);
2415                // c:Src/exec.c:1700 — `thisjob = newjob = initjob()`:
2416                // allocate the canonical jobtab slot. c:Src/exec.c:2950
2417                // zfork path → addproc(pid, text, 0, &bgtime, ...) hangs
2418                // the proc entry (with its display text) off the job.
2419                // c:Src/exec.c:1744-1746 — `clearoldjobtab();
2420                // jobtab[thisjob].stat |= STAT_NOSTTY;` then c:1758
2421                // `spawnjob()` promotes it to curjob (top-level shell
2422                // only), marks STAT_LOCKED and resets thisjob.
2423                {
2424                    use crate::ported::jobs;
2425                    use std::sync::Mutex;
2426                    let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
2427                    let idx = {
2428                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
2429                        let idx = jobs::initjob(&mut tab); // c:exec.c:1700
2430                        jobs::addproc(
2431                            &mut tab[idx],
2432                            pid,
2433                            &job_text,
2434                            false,
2435                            Some(std::time::Instant::now()),
2436                            -1,
2437                            -1,
2438                        ); // c:exec.c:2950 addproc
2439                        tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
2440                        idx
2441                    };
2442                    jobs::clearoldjobtab(); // c:exec.c:1744
2443                    if let Ok(mut tj) = jobs::THISJOB
2444                        .get_or_init(|| Mutex::new(-1))
2445                        .lock()
2446                    {
2447                        *tj = idx as i32;
2448                    }
2449                    jobs::spawnjob(); // c:exec.c:1758
2450                }
2451                with_executor(|exec| {
2452                    exec.jobs
2453                        .add_pid_job(pid, job_text.clone(), JobState::Running);
2454                });
2455                Value::Status(0)
2456            }
2457        }
2458    });
2459
2460    // ── Indexed-array storage ─────────────────────────────────────────────
2461    //
2462    // Stack: pushed values then name (LAST). `arr=(a b c)` → 4 args
2463    // (a, b, c, arr). `arr=($(cmd))` → 2 args (FlatArray, arr).
2464    //
2465    // PURE PASSTHRU: pop name + values, dispatch to canonical
2466    // `setaparam` / `sethparam` (C port of `Src/params.c:3595/3602`).
2467    // assignaparam already handles PM_UNIQUE dedupe, type-flag flip,
2468    // PM_NAMEREF rejection, ASSPM_AUGMENT prepend, and createparam
2469    // for fresh names.
2470    vm.register_builtin(BUILTIN_SET_ARRAY, |vm, argc| {
2471        // `${~spec}` carrier: an assignment statement is a word-
2472        // pipeline boundary too — restore the user's GLOB_SUBST
2473        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
2474        // ${options[globsubst]}` must read the user value).
2475        consume_tilde_globsubst_carrier();
2476        let n = argc as usize;
2477        let mut popped: Vec<Value> = Vec::with_capacity(n);
2478        for _ in 0..n {
2479            popped.push(vm.pop());
2480        }
2481        popped.reverse();
2482        if popped.is_empty() {
2483            return Value::Status(1);
2484        }
2485        let name = popped.pop().unwrap().to_str();
2486        let mut values: Vec<String> = Vec::new();
2487        for v in popped {
2488            match v {
2489                Value::Array(items) => {
2490                    for it in items {
2491                        values.push(it.to_str());
2492                    }
2493                }
2494                other => values.push(other.to_str()),
2495            }
2496        }
2497        let blocked = with_executor(|exec| {
2498            // Assoc init `typeset -A m; m=(k v k v ...)` — route to
2499            // canonical sethparam (Src/params.c:3602) which parses the
2500            // flat (k,v) pair list internally.
2501            if exec.assoc(&name).is_some() {
2502                // `[k]=v` / `[k]+=v` elements arrive from the compiler
2503                // as Marker / key / value triples (compile_zsh's port
2504                // of keyvalpairelement, c:Src/subst.c:49-79).
2505                let marker = crate::ported::zsh_h::Marker;
2506                let values = if values.iter().any(|e| e.starts_with(marker)) {
2507                    // c:Src/params.c:3544-3560 — under ASSPM_KEY_VALUE
2508                    // assocs strictly enforce `[key]=value`: every
2509                    // stride-of-3 element must be a Marker. Mixing
2510                    // plain pairs with kv triads is an error.
2511                    let mut i = 0usize;
2512                    while i < values.len() {
2513                        if !values[i].starts_with(marker) {
2514                            crate::ported::utils::zerr(
2515                                "bad [key]=value syntax for associative array",
2516                            );
2517                            crate::ported::utils::errflag.fetch_or(
2518                                crate::ported::zsh_h::ERRFLAG_ERROR,
2519                                std::sync::atomic::Ordering::Relaxed,
2520                            );
2521                            exec.set_last_status(1);
2522                            return true;
2523                        }
2524                        i += 3;
2525                    }
2526                    if values.len() % 3 != 0 {
2527                        // c:Src/params.c:4124-4131 arrhashsetfn — a
2528                        // truncated triad leaves an odd non-Marker
2529                        // count → "bad set of key/value pairs".
2530                        crate::ported::utils::zerr(
2531                            "bad set of key/value pairs for associative array",
2532                        );
2533                        crate::ported::utils::errflag.fetch_or(
2534                            crate::ported::zsh_h::ERRFLAG_ERROR,
2535                            std::sync::atomic::Ordering::Relaxed,
2536                        );
2537                        exec.set_last_status(1);
2538                        return true;
2539                    }
2540                    // c:Src/params.c:4136-4168 arrhashsetfn — whole
2541                    // assignment builds a FRESH table; a `Marker +`
2542                    // triad (`[k]+=v`) appends to the value inserted
2543                    // EARLIER IN THIS SAME LITERAL (assignstrvalue
2544                    // with eltflags=ASSPM_AUGMENT against the new ht),
2545                    // so `h=([k]=a [k]+=b)` yields "ab". Resolve the
2546                    // appends here, then hand flat pairs to sethparam.
2547                    let mut order: Vec<String> = Vec::new();
2548                    let mut map: std::collections::HashMap<String, String> =
2549                        std::collections::HashMap::new();
2550                    for ch in values.chunks(3) {
2551                        let elt_append = ch[0].chars().nth(1) == Some('+');
2552                        let k = ch[1].clone();
2553                        let v = ch[2].clone();
2554                        let nv = if elt_append {
2555                            format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
2556                        } else {
2557                            v
2558                        };
2559                        if !map.contains_key(&k) {
2560                            order.push(k.clone());
2561                        }
2562                        map.insert(k, nv);
2563                    }
2564                    order
2565                        .into_iter()
2566                        .flat_map(|k| {
2567                            let v = map.get(&k).cloned().unwrap_or_default();
2568                            [k, v]
2569                        })
2570                        .collect()
2571                } else {
2572                    values
2573                };
2574                // Odd-count rejection lives in the canonical chain:
2575                // sethparam → setarrvalue (c:3651/c:2920) →
2576                // arrhashsetfn's zerr "bad set of key/value pairs"
2577                // (Src/params.c:4128-4131). zerr sets ERRFLAG_ERROR,
2578                // which aborts the remaining list at the next command
2579                // boundary (BUILTIN_ERREXIT_CHECK trigger 4) —
2580                // matching `zsh -fc 'typeset -A m; m=(odd); print x'`
2581                // printing nothing after the error. Like C's sethparam
2582                // (c:3652-3653 returns v->pm regardless), the Rust
2583                // port returns Some on the odd-count path — the
2584                // failure travels via errflag, so check BOTH.
2585                let pre_err = crate::ported::utils::errflag
2586                    .load(std::sync::atomic::Ordering::Relaxed)
2587                    & crate::ported::zsh_h::ERRFLAG_ERROR;
2588                let res = crate::ported::params::sethparam(&name, values.clone());
2589                let now_err = crate::ported::utils::errflag
2590                    .load(std::sync::atomic::Ordering::Relaxed)
2591                    & crate::ported::zsh_h::ERRFLAG_ERROR;
2592                if res.is_none() || (pre_err == 0 && now_err != 0) {
2593                    // c:Src/exec.c:2632-2633 addvars — `if
2594                    // (!assignaparam(name, arr, myflags)) lastval = 1;`
2595                    // — failed assignment sets lastval so the errflag
2596                    // abort exits 1 (init.c loop() breaks, zsh_main
2597                    // returns lastval).
2598                    exec.set_last_status(1);
2599                    return true;
2600                }
2601                #[cfg(feature = "recorder")]
2602                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2603                    let ctx = exec.recorder_ctx();
2604                    let attrs = exec.recorder_attrs_for(&name);
2605                    let mut pairs: Vec<(String, String)> = Vec::with_capacity(values.len() / 2);
2606                    let mut iter = values.iter().cloned();
2607                    while let Some(k) = iter.next() {
2608                        if let Some(v) = iter.next() {
2609                            pairs.push((k, v));
2610                        }
2611                    }
2612                    crate::recorder::emit_assoc_assign(&name, pairs, attrs, false, ctx);
2613                }
2614                return false;
2615            }
2616            // Indexed-array: setaparam (Src/params.c:3766) wraps
2617            // assignaparam with ASSPM_WARN — handles PM_UNIQUE dedupe,
2618            // type-flag flip, PM_READONLY rejection.
2619            //
2620            // `[k]=v` elements arrive as Marker / key / value triples
2621            // (compile_zsh's keyvalpairelement port). Mirror
2622            // c:Src/exec.c:2552-2553 — `if (prefork_ret &
2623            // PREFORK_KEY_VALUE) myflags |= ASSPM_KEY_VALUE;` — so
2624            // assignaparam runs its kv-resolution block (sparse fill
2625            // for PM_ARRAY, c:3447-3541; strict-triad enforcement for
2626            // special PM_HASHED targets like `options`, c:3544-3560).
2627            let values = values;
2628            let has_kv = values
2629                .iter()
2630                .any(|e| e.starts_with(crate::ported::zsh_h::Marker));
2631            // The tied-array mirror to a PM_TIED scalar
2632            // (`typeset -T PATH path`) lives canonically in
2633            // setarrvalue's dispatch in C zsh; until that wires
2634            // through assignaparam, mirror here so PATH stays in sync
2635            // after `path=(/x)`.
2636            if let Some((scalar_name, sep)) = exec.tied_array_to_scalar.get(&name).cloned() {
2637                let joined = values.join(&sep);
2638                exec.set_scalar(scalar_name, joined);
2639            }
2640            // c:Src/exec.c:2632-2633 addvars — `if (!assignaparam(...))
2641            // lastval = 1;` — a failed assignment (bad subscript, bad
2642            // [key]=value syntax, readonly) exits 1 and the errflag
2643            // abort stops the remaining list. Track errflag pre/post
2644            // like the assoc branch above.
2645            let kv_flag = if has_kv {
2646                crate::ported::zsh_h::ASSPM_KEY_VALUE
2647            } else {
2648                0
2649            };
2650            let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2651                & crate::ported::zsh_h::ERRFLAG_ERROR;
2652            let res = crate::ported::params::assignaparam(
2653                &name,
2654                values.clone(),
2655                crate::ported::zsh_h::ASSPM_WARN | kv_flag,
2656            );
2657            let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2658                & crate::ported::zsh_h::ERRFLAG_ERROR;
2659            if res.is_none() && pre_err == 0 && now_err != 0 {
2660                exec.set_last_status(1);
2661                return true;
2662            }
2663            #[cfg(feature = "recorder")]
2664            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2665                let ctx = exec.recorder_ctx();
2666                let attrs = exec.recorder_attrs_for(&name);
2667                emit_path_or_assign(&name, &values, attrs, false, &ctx);
2668            }
2669            false
2670        });
2671        let status = if blocked { 1 } else { 0 };
2672        // c:Src/jobs.c:1748-1757 waitonejob — in C an array-assignment
2673        // simple command goes through execpline → waitjobs; with no
2674        // procs the else-branch stores `pipestats[0] = lastval;
2675        // numpipestats = 1`. Bare SCALAR assignments never create a
2676        // job (no waitjobs), so this clobber is array/assoc-assignment
2677        // specific: `false|true; x=(1 2); echo $pipestatus` → `0` in
2678        // zsh while `x=1` preserves `1 0`. Bug #373.
2679        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
2680        let mut synth = crate::ported::zsh_h::job::default();
2681        crate::ported::jobs::waitonejob(&mut synth);
2682        Value::Status(status)
2683    });
2684    // `arr+=(d e f)` — array append. Same calling conventions as SET_ARRAY.
2685    //
2686    // PURE PASSTHRU shape: pop name + values, dispatch through the
2687    // canonical assoc / array setter. assignaparam's ASSPM_AUGMENT
2688    // flag handles the C-source-equivalent "preserve prior value"
2689    // semantics; for now we read the current array, extend with
2690    // new values, write through set_array (which routes to
2691    // setaparam → assignaparam where PM_UNIQUE dedupe lands).
2692    vm.register_builtin(BUILTIN_APPEND_ARRAY, |vm, argc| {
2693        let n = argc as usize;
2694        let mut popped: Vec<Value> = Vec::with_capacity(n);
2695        for _ in 0..n {
2696            popped.push(vm.pop());
2697        }
2698        popped.reverse();
2699        if popped.is_empty() {
2700            return Value::Status(1);
2701        }
2702        let name = popped.pop().unwrap().to_str();
2703        let mut values: Vec<String> = Vec::new();
2704        for v in popped {
2705            match v {
2706                Value::Array(items) => {
2707                    for it in items {
2708                        values.push(it.to_str());
2709                    }
2710                }
2711                other => values.push(other.to_str()),
2712            }
2713        }
2714        let blocked = with_executor(|exec| -> bool {
2715            // Assoc append `m+=(k1 v1 ...)`: merge the (k,v) pairs into
2716            // the existing map and write back via canonical sethparam
2717            // (Src/params.c:3602). The canonical C path would go
2718            // assignaparam(ASSPM_AUGMENT) → arrhashsetfn(ASSPM_AUGMENT)
2719            // at Src/params.c:3850, but the zshrs port of
2720            // arrhashsetfn doesn't yet implement value-storage
2721            // (pending Param.u_hash backend wireup) — until that
2722            // lands, do the augment + write here so the storage
2723            // actually mutates.
2724            if exec.assoc(&name).is_some() {
2725                // `[k]=v` / `[k]+=v` elements arrive as Marker / key /
2726                // value triples (compile_zsh's port of
2727                // keyvalpairelement, c:Src/subst.c:49-79).
2728                let marker = crate::ported::zsh_h::Marker;
2729                let mut map = exec.assoc(&name).unwrap_or_default();
2730                if values.iter().any(|e| e.starts_with(marker)) {
2731                    // c:Src/params.c:3544-3560 — strict triad rule.
2732                    let mut i = 0usize;
2733                    while i < values.len() {
2734                        if !values[i].starts_with(marker) {
2735                            crate::ported::utils::zerr(
2736                                "bad [key]=value syntax for associative array",
2737                            );
2738                            crate::ported::utils::errflag.fetch_or(
2739                                crate::ported::zsh_h::ERRFLAG_ERROR,
2740                                std::sync::atomic::Ordering::Relaxed,
2741                            );
2742                            exec.set_last_status(1);
2743                            return true;
2744                        }
2745                        i += 3;
2746                    }
2747                    if values.len() % 3 != 0 {
2748                        // c:Src/params.c:4124-4131 — odd pair count.
2749                        crate::ported::utils::zerr(
2750                            "bad set of key/value pairs for associative array",
2751                        );
2752                        crate::ported::utils::errflag.fetch_or(
2753                            crate::ported::zsh_h::ERRFLAG_ERROR,
2754                            std::sync::atomic::Ordering::Relaxed,
2755                        );
2756                        exec.set_last_status(1);
2757                        return true;
2758                    }
2759                    // c:Src/params.c:4133-4168 arrhashsetfn with
2760                    // ASSPM_AUGMENT — ht = the EXISTING table, so
2761                    // `[k]+=v` appends to the current value
2762                    // (assignstrvalue eltflags=ASSPM_AUGMENT,
2763                    // c:4144-4150) and `[k]=v` overwrites.
2764                    for ch in values.chunks(3) {
2765                        let elt_append = ch[0].chars().nth(1) == Some('+');
2766                        let k = ch[1].clone();
2767                        let v = ch[2].clone();
2768                        let nv = if elt_append {
2769                            format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
2770                        } else {
2771                            v
2772                        };
2773                        map.insert(k, nv);
2774                    }
2775                } else {
2776                    let mut it = values.iter().cloned();
2777                    while let Some(k) = it.next() {
2778                        if let Some(v) = it.next() {
2779                            map.insert(k, v);
2780                        }
2781                    }
2782                }
2783                exec.set_assoc(name, map);
2784                return false;
2785            }
2786            // Indexed-array append `arr+=(d e f)` — route directly
2787            // through canonical assignaparam with ASSPM_AUGMENT
2788            // (`Src/params.c:3570-3585` append-on-array branch).
2789            // assignaparam reads the prior array internally and
2790            // appends the new values, so the bridge no longer needs
2791            // to pre-concat manually. Marker triples from `[k]=v`
2792            // elements add ASSPM_KEY_VALUE (c:Src/exec.c:2552-2553)
2793            // so the kv sparse-fill block (c:Src/params.c:3447-3541)
2794            // resolves them against the existing elements.
2795            let kv_flag = if values
2796                .iter()
2797                .any(|e| e.starts_with(crate::ported::zsh_h::Marker))
2798            {
2799                crate::ported::zsh_h::ASSPM_KEY_VALUE
2800            } else {
2801                0
2802            };
2803            let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2804                & crate::ported::zsh_h::ERRFLAG_ERROR;
2805            let res = crate::ported::params::assignaparam(
2806                &name,
2807                values.clone(),
2808                crate::ported::zsh_h::ASSPM_AUGMENT | kv_flag,
2809            );
2810            let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2811                & crate::ported::zsh_h::ERRFLAG_ERROR;
2812            if res.is_none() && pre_err == 0 && now_err != 0 {
2813                // c:Src/exec.c:2632-2633 — failed assignment → lastval 1.
2814                exec.set_last_status(1);
2815                return true;
2816            }
2817            #[cfg(feature = "recorder")]
2818            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2819                let ctx = exec.recorder_ctx();
2820                let attrs = exec.recorder_attrs_for(&name);
2821                emit_path_or_assign(&name, &values, attrs, true, &ctx);
2822            }
2823            // Tied-scalar mirror — TODO faithful: should live in
2824            // setarrvalue's gsu dispatch once boot_ paramtab wiring
2825            // lands (Task #16). Re-read the canonical post-augment
2826            // array so the joined scalar matches.
2827            let tied_scalar = exec.tied_array_to_scalar.get(&name).cloned();
2828            if let Some((scalar_name, sep)) = tied_scalar {
2829                let merged = exec.array(&name).unwrap_or_default();
2830                let joined = merged.join(&sep);
2831                exec.set_scalar(scalar_name.clone(), joined.clone());
2832                let _ = crate::ported::params::zputenv(&format!("{}={}", &scalar_name, &joined)); // c:Src/params.c:5354
2833            }
2834            false
2835        });
2836        // c:Src/jobs.c:1748-1757 waitonejob — `arr+=(...)` is an
2837        // array-assignment simple command and clobbers pipestats to
2838        // `[lastval]` exactly like `arr=(...)` above. Bug #373.
2839        let status = if blocked { 1 } else { 0 };
2840        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
2841        let mut synth = crate::ported::zsh_h::job::default();
2842        crate::ported::jobs::waitonejob(&mut synth);
2843        Value::Status(status)
2844    });
2845    // `name[@]=(...)` / `name[*]=(...)` — whole-array SET with the assoc
2846    // guard (c:Src/params.c:3324-3327). Stack: [v0..vn, name].
2847    vm.register_builtin(BUILTIN_SET_ARRAY_AT, |vm, argc| {
2848        let (name, values) = pop_array_args_with_name(vm, argc);
2849        let status = with_executor(|exec| {
2850            if exec.assoc(&name).is_some() {
2851                // c:Src/params.c:3324-3327 — `[@]` (any slice) on a
2852                // PM_HASHED target is an error.
2853                crate::ported::utils::zerr(&format!(
2854                    "{}: attempt to set slice of associative array",
2855                    name
2856                ));
2857                crate::ported::utils::errflag.fetch_or(
2858                    crate::ported::zsh_h::ERRFLAG_ERROR,
2859                    std::sync::atomic::Ordering::Relaxed,
2860                );
2861                exec.set_last_status(1);
2862                return 1;
2863            }
2864            exec.set_array(name, values); // whole replace (c:3528 setarrvalue)
2865            0
2866        });
2867        Value::Status(status)
2868    });
2869    // `name[@]+=(...)` / `name[*]+=(...)` — whole-array APPEND (push) with
2870    // the same assoc guard.
2871    vm.register_builtin(BUILTIN_APPEND_ARRAY_AT, |vm, argc| {
2872        let (name, values) = pop_array_args_with_name(vm, argc);
2873        let status = with_executor(|exec| {
2874            if exec.assoc(&name).is_some() {
2875                crate::ported::utils::zerr(&format!(
2876                    "{}: attempt to set slice of associative array",
2877                    name
2878                ));
2879                crate::ported::utils::errflag.fetch_or(
2880                    crate::ported::zsh_h::ERRFLAG_ERROR,
2881                    std::sync::atomic::Ordering::Relaxed,
2882                );
2883                exec.set_last_status(1);
2884                return 1;
2885            }
2886            let mut cur = exec.array(&name).unwrap_or_default();
2887            cur.extend(values);
2888            exec.set_array(name, cur); // c:3511-3528 AUGMENT on array → push
2889            0
2890        });
2891        Value::Status(status)
2892    });
2893    vm.register_builtin(BUILTIN_RUN_SELECT, |vm, argc| {
2894        if argc < 2 {
2895            return Value::Status(1);
2896        }
2897        let n = argc as usize;
2898        let mut popped: Vec<Value> = Vec::with_capacity(n);
2899        for _ in 0..n {
2900            popped.push(vm.pop());
2901        }
2902        // popped: [sub_idx, name, word_N, ..., word_1] (popping from top)
2903        let sub_idx_val = popped.remove(0);
2904        let name_val = popped.remove(0);
2905        // c:Src/loop.c — `select` flattens Array values (from `$@`,
2906        // `${arr[@]}`, etc.) into the menu. Without per-element
2907        // splice, `select x do ... done` (bare, iterating $@)
2908        // collapsed all positionals into one joined entry.
2909        let mut words: Vec<String> = Vec::new();
2910        for v in popped.into_iter().rev() {
2911            match v {
2912                Value::Array(items) => {
2913                    for item in items {
2914                        words.push(item.to_str());
2915                    }
2916                }
2917                other => words.push(other.to_str()),
2918            }
2919        }
2920
2921        let sub_idx = sub_idx_val.to_int() as usize;
2922        let name = name_val.to_str();
2923
2924        // c:Src/loop.c:248-252 — `if (!args || empty(args)) {
2925        // state->pc = end; ... return 0; }`. An empty option list
2926        // skips the body entirely; without this gate the prompt loop
2927        // runs indefinitely (or twice on the EOF stdin case before
2928        // exiting). Bug #401.
2929        if words.is_empty() {
2930            return Value::Status(0);
2931        }
2932
2933        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
2934            Some(c) => c,
2935            None => return Value::Status(1),
2936        };
2937
2938        let prompt =
2939            with_executor(|exec| exec.scalar("PROMPT3").unwrap_or_else(|| "?# ".to_string()));
2940
2941        let stdin = std::io::stdin();
2942        let mut reader = stdin.lock();
2943        let mut last_status: i32 = 0;
2944
2945        loop {
2946            // Direct port of zsh's selectlist from
2947            // src/zsh/Src/loop.c:347-409. Layout is column-major
2948            // ("down columns, then across") — NOT row-major. With
2949            // 6 items in 3 cols zsh produces:
2950            //   1  3  5
2951            //   2  4  6
2952            // The previous Rust impl walked row-major which
2953            // produced 1 2 3 / 4 5 6 (visually similar but wrong
2954            // for prompts that mention ordering and breaks scripts
2955            // that rely on column count == ceil(N/rows)).
2956            //
2957            // C variable mapping:
2958            //   ct      -> word count (n)
2959            //   longest -> max item width + 1, then plus digits-of-ct
2960            //   fct     -> column count
2961            //   fw      -> per-column width
2962            //   colsz   -> row count = ceil(ct / fct)
2963            //   t1      -> row index, walks 0..colsz
2964            //   ap      -> item pointer; advances by colsz to step
2965            //              DOWN a column.
2966            let term_width: usize = env::var("COLUMNS")
2967                .ok()
2968                .and_then(|v| v.parse().ok())
2969                .unwrap_or(80);
2970            let ct = words.len();
2971            // loop.c:354-363 — find longest item width.
2972            let mut longest = 1usize;
2973            for w in &words {
2974                let aplen = w.chars().count();
2975                if aplen > longest {
2976                    longest = aplen;
2977                }
2978            }
2979            // loop.c:365-367 — `longest++` then add digits of `ct`.
2980            longest += 1;
2981            let mut t0 = ct;
2982            while t0 > 0 {
2983                t0 /= 10;
2984                longest += 1;
2985            }
2986            // loop.c:369-373 — fct = (cols - 1) / (longest + 3); if
2987            // 0, fct = 1; else fw = (cols - 1) / fct.
2988            let raw_fct = (term_width.saturating_sub(1)) / (longest + 3);
2989            let (fct, fw) = if raw_fct == 0 {
2990                (1, longest + 3)
2991            } else {
2992                (raw_fct, (term_width.saturating_sub(1)) / raw_fct)
2993            };
2994            // loop.c:374 — colsz = (ct + fct - 1) / fct.
2995            let colsz = ct.div_ceil(fct);
2996            // loop.c:375-395 — for each row t1, walk down columns.
2997            for t1 in 0..colsz {
2998                let mut ap_idx = t1;
2999                while ap_idx < ct {
3000                    let w = &words[ap_idx];
3001                    let n = ap_idx + 1;
3002                    let _ = write!(std::io::stderr(), "{}) {}", n, w);
3003                    let mut t2 = w.chars().count() + 2;
3004                    let mut t3 = n;
3005                    while t3 > 0 {
3006                        t2 += 1;
3007                        t3 /= 10;
3008                    }
3009                    // Pad to fw (loop.c:389-390).
3010                    while t2 < fw {
3011                        let _ = write!(std::io::stderr(), " ");
3012                        t2 += 1;
3013                    }
3014                    ap_idx += colsz;
3015                }
3016                let _ = writeln!(std::io::stderr());
3017            }
3018            let _ = write!(std::io::stderr(), "{}", prompt);
3019            let _ = std::io::stderr().flush();
3020
3021            let mut line = String::new();
3022            match reader.read_line(&mut line) {
3023                Ok(0) => {
3024                    // EOF — emit the final newline that zsh prints
3025                    // after the prompt-then-EOF sequence (c:Src/loop.c
3026                    // selectlist falls through to fputc('\n', stderr)
3027                    // at the end of the read failure path). Without
3028                    // this the next process's output runs directly
3029                    // after `-->>>> ` on the same line.
3030                    let _ = writeln!(std::io::stderr());
3031                    let _ = std::io::stderr().flush();
3032                    break;
3033                }
3034                Ok(_) => {}
3035                Err(_) => break,
3036            }
3037            let trimmed = line.trim_end_matches(['\n', '\r'][..].as_ref()).to_string();
3038
3039            with_executor(|exec| {
3040                exec.set_scalar("REPLY".to_string(), trimmed.clone());
3041            });
3042
3043            if trimmed.is_empty() {
3044                // Empty input → redraw menu without running body.
3045                continue;
3046            }
3047
3048            let chosen = match trimmed.parse::<usize>() {
3049                Ok(n) if n >= 1 && n <= words.len() => words[n - 1].clone(),
3050                _ => String::new(),
3051            };
3052
3053            with_executor(|exec| {
3054                exec.set_scalar(name.clone(), chosen);
3055            });
3056
3057            // Reset canonical BREAKS/CONTFLAG before running the body
3058            // so a stale value from a sibling construct doesn't leak in.
3059            crate::ported::builtin::BREAKS.store(0, SeqCst);
3060            crate::ported::builtin::CONTFLAG.store(0, SeqCst);
3061
3062            // c:Src/loop.c — `select` increments LOOPS for the body so
3063            // `break` / `continue` inside the body see loops > 0 and
3064            // don't emit `not in while, until, select, or repeat loop`.
3065            // Mirrors execwhile/execrepeat's `LOOPS.fetch_add` pattern.
3066            // The decrement happens after the body call so a body that
3067            // explicitly returns / errors still leaves the counter
3068            // balanced for the next iteration.
3069            crate::ported::builtin::LOOPS.fetch_add(1, SeqCst);
3070
3071            crate::fusevm_disasm::maybe_print_stdout("select:body", &chunk);
3072            let mut body_vm = fusevm::VM::new(chunk.clone());
3073            register_builtins(&mut body_vm);
3074            let _ = body_vm.run();
3075            last_status = body_vm.last_status;
3076
3077            crate::ported::builtin::LOOPS.fetch_sub(1, SeqCst);
3078
3079            // Drain the canonical BREAKS/CONTFLAG counters. Mirrors
3080            // loop.c:529-534's `if (breaks) { breaks--; if (breaks ||
3081            // !contflag) break; contflag = 0; }` drain pattern.
3082            // The legacy `BREAK_SELECT=1` env-var sentinel is still
3083            // honored for backward compat.
3084            let break_legacy = with_executor(|exec| {
3085                let v = exec.scalar("BREAK_SELECT");
3086                exec.unset_scalar("BREAK_SELECT");
3087                v.map(|s| s != "0" && !s.is_empty()).unwrap_or(false)
3088            });
3089            use std::sync::atomic::Ordering::SeqCst;
3090            let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
3091            if breaks > 0 {
3092                let cont = crate::ported::builtin::CONTFLAG.load(SeqCst);
3093                crate::ported::builtin::BREAKS.fetch_sub(1, SeqCst);
3094                if breaks - 1 > 0 || cont == 0 {
3095                    break;
3096                }
3097                crate::ported::builtin::CONTFLAG.store(0, SeqCst);
3098                continue;
3099            }
3100            if break_legacy {
3101                break;
3102            }
3103        }
3104
3105        Value::Status(last_status)
3106    });
3107
3108    // Magic special-parameter assoc lookup. Synthesizes values from
3109    // shell state for zsh's shell-introspection assocs:
3110    //   commands, aliases, galiases, saliases, dis_aliases, dis_galiases,
3111    //   dis_saliases, functions, dis_functions, builtins, dis_builtins,
3112    //   reswords, options, parameters, jobtexts, jobdirs, jobstates,
3113    //   nameddirs, userdirs, modules.
3114    // Returns None if `name` isn't a recognized magic name.
3115
3116    // `${arr[idx]}` — pop name, then idx_str. zsh is 1-based for positive
3117    // indices; we honor that. `@`/`*` return the whole array as Value::Array
3118    // so Op::Exec splice produces N argv slots. For `${foo[key]}` where foo
3119    // is an assoc, the idx is a string key — we check assoc_arrays first
3120    // when the idx isn't `@`/`*` and the name has an assoc binding.
3121    // BUILTIN_ARRAY_INDEX — `${name[idx]}` paramsubst dispatch.
3122    // PURE PASSTHRU: pops the idx + name, hands the canonical
3123    // `${name[idx]}` form to `subst::paramsubst` (C port of
3124    // `Src/subst.c::paramsubst`). All subscript-flag dispatch
3125    // ((I)pat / (R)pat / (i)/(r)/(K)/(k), range slices `[N,M]`,
3126    // negative indices, magic-assoc shape lookup, DQ-join collapse)
3127    // lives inside paramsubst → fetchvalue → getarg in params.rs.
3128    //
3129    // Outer-flag dispatch (`(@)` / `(@k)` / `(v)NAME[(I)pat]` / etc.)
3130    // routes through BUILTIN_BRIDGE_BRACE_ARRAY at the compile path
3131    // (canonical paramsubst flag parser owns dispatch at Src/subst.c:2147+),
3132    // so BUILTIN_ARRAY_INDEX receives clean name+key with no sentinel
3133    // prefixes.
3134    vm.register_builtin(BUILTIN_ARRAY_INDEX, |vm, _argc| {
3135        let idx = vm.pop().to_str();
3136        let name = vm.pop().to_str();
3137        array_index_lookup(&name, &idx)
3138    });
3139    // BUILTIN_ARRAY_INDEX_UNBRACED — bare `$name[idx]` (no braces).
3140    // Same subscript dispatch as BUILTIN_ARRAY_INDEX when KSHARRAYS
3141    // is unset, but under KSHARRAYS the UNBRACED form does NOT
3142    // subscript at all:
3143    //   c:Src/subst.c:2800-2802 — fetchvalue's bracket-parse arg is
3144    //     `(unset(KSHARRAYS) || inbrace) ? 1 : -1`; -1 inhibits
3145    //     subscript parsing for the bare form under KSHARRAYS.
3146    //   c:Src/subst.c:2867 — the bracket-consuming loop only runs
3147    //     `while (v || ((inbrace || (unset(KSHARRAYS) && vunset)) &&
3148    //     isbrack(*s)))` — bare + KSHARRAYS leaves `[...]` as literal
3149    //     trailing text.
3150    // The bare `$name` expands (first element for identifier-named
3151    // arrays per c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`),
3152    // the literal `[idx]` (+ any literal suffix) joins the last word,
3153    // and the word undergoes filename generation: `[...]` is a glob
3154    // char class, so unquoted it hits the c:Src/glob.c:1873-1886
3155    // nomatch/nullglob dispatch (reused via exec.expand_glob).
3156    // Operands: [name, idx, suffix, quoted] — `quoted` set when the
3157    // word carries DQ markers (no filename generation in DQ; zsh 5.9:
3158    // `setopt ksharrays; a=(x y z); print "$a[0]"` → `x[0]`).
3159    // Verbatim zsh 5.9 ground truth for the unquoted form:
3160    //   `setopt ksharrays; a=(x y z); print -- $a[0]` →
3161    //   stderr `zsh:1: no matches found: x[0]`, rc=1, empty stdout.
3162    vm.register_builtin(BUILTIN_ARRAY_INDEX_UNBRACED, |vm, _argc| {
3163        let quoted = vm.pop().to_str() == "1";
3164        let suffix = vm.pop().to_str();
3165        let idx = vm.pop().to_str();
3166        let name = vm.pop().to_str();
3167        if !crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
3168            let v = array_index_lookup(&name, &idx);
3169            if suffix.is_empty() {
3170                return v;
3171            }
3172            // Mirrors the previous compile-shape `ARRAY_INDEX +
3173            // Op::Concat` exactly: Concat stringifies via as_str_cow
3174            // (fusevm value.rs:132-146, arrays join with " ").
3175            return Value::str(format!("{}{}", v.to_str(), suffix));
3176        }
3177        // KSHARRAYS bare form: no subscript. Bare-`$name` words +
3178        // literal `[idx]suffix` glued onto the last word.
3179        let mut words = ksharrays_bare_words(&name);
3180        let last = format!(
3181            "{}[{}]{}",
3182            words.pop().unwrap_or_default(),
3183            idx,
3184            suffix
3185        );
3186        if quoted {
3187            // DQ context — no filename generation, bracket text stays
3188            // literal.
3189            words.push(last);
3190            return Value::str(words.join(" "));
3191        }
3192        // c:Src/glob.c:1873-1886 — expand_glob handles nullglob /
3193        // NOMATCH (zerr "no matches found" + errflag + the
3194        // current_command_glob_failed cell consumed at the command
3195        // dispatch boundary) / literal passthrough for glob-free text.
3196        let matches = with_executor(|exec| exec.expand_glob(&last));
3197        let mut out: Vec<Value> = words.into_iter().map(Value::str).collect();
3198        out.extend(matches.into_iter().map(Value::str));
3199        if out.len() == 1 {
3200            return out.pop().unwrap();
3201        }
3202        Value::Array(out)
3203    });
3204    // BUILTIN_ASSOC_HAS_KEY — `${(k)assoc[name]}` key-existence query.
3205    // Pops [assoc_name, key]; returns key (Str) if present in the
3206    // assoc, empty Str otherwise. Mirrors zsh's `${(k)h[name]}`
3207    // documented semantics in zshparam(1) "Parameter Expansion Flags".
3208    // Distinct from BUILTIN_ARRAY_INDEX (which returns the VALUE) and
3209    // from `${+h[name]}` (which returns "0"/"1"). Bug #145.
3210    vm.register_builtin(BUILTIN_ASSOC_HAS_KEY, |vm, _argc| {
3211        let key = vm.pop().to_str();
3212        let name = vm.pop().to_str();
3213        // c:Src/params.c getindex — the subscript text is substituted
3214        // (singsub) before the lookup; `${(k)H[$k]}` must resolve $k.
3215        // The compiler hands this opcode the RAW subscript text, so a
3216        // dynamic key arrived literally ("$k") and matched nothing.
3217        // singsub is identity for plain keys.
3218        let key = if key.contains('$') || key.contains('`') || key.contains('\u{8c}') {
3219            crate::ported::subst::singsub(&key)
3220        } else {
3221            key
3222        };
3223        // c:Src/params.c:3131 gethkparam covers ordinary PM_HASHED
3224        // paramtab entries only. Special/magic hashes (`parameters`,
3225        // `options`, … — the zsh/parameter module's partab-backed
3226        // params, Src/Modules/parameter.c) aren't in that storage, so
3227        // a None here doesn't mean "no such assoc". Route those
3228        // through paramsubst, whose assoc materialization handles the
3229        // magic hashes and whose getarg port (c:Src/params.c:1591 +
3230        // Src/subst.c:2922) returns the KEY for `(k)` on a plain
3231        // subscript. zsh 5.9: `${(k)parameters[PATH]}` → "PATH".
3232        match crate::ported::params::gethkparam(&name) {
3233            Some(keys) => {
3234                if keys.iter().any(|k| k == &key) {
3235                    Value::str(key)
3236                } else {
3237                    Value::str("")
3238                }
3239            }
3240            None => paramsubst_to_value(&format!("${{(k){}[{}]}}", name, key)),
3241        }
3242    });
3243    vm.register_builtin(BUILTIN_BRIDGE_BRACE_ARRAY, |vm, _argc| {
3244        // Inner body of `${(...)...}` (already stripped of `${`/`}` by
3245        // the caller). The compiler optionally prefixes Qstring
3246        // (\u{8c}) to signal "expanded in DQ context" — strip it
3247        // here and bump in_dq_context for the paramsubst call so the
3248        // SUB_ZIP and other qt-aware paths fire.
3249        let body = vm.pop().to_str();
3250        let (dq, inner) = if let Some(rest) = body.strip_prefix('\u{8c}') {
3251            (true, rest.to_string())
3252        } else {
3253            (false, body)
3254        };
3255        if dq {
3256            with_executor(|exec| exec.in_dq_context += 1);
3257        }
3258        let v = paramsubst_to_value(&format!("${{{}}}", inner));
3259        if dq {
3260            with_executor(|exec| exec.in_dq_context -= 1);
3261        }
3262        v
3263    });
3264
3265    // BUILTIN_PARAM_FLAG — `${(flags)name}` paramsubst dispatch.
3266    // PURE PASSTHRU: pops sentinel-tagged flags + name, hands the
3267    // canonical `${(flags)name}` form to `subst::paramsubst` (C port
3268    // of `Src/subst.c::paramsubst`). The bridge does no flag
3269    // walking, no DQ-context branching, no array/scalar shape
3270    // selection — all of that lives inside paramsubst. Compile-time
3271    // context (DQ / scalar-assign-RHS) flows through executor cells
3272    // (in_dq_context, in_scalar_assign) bumped by BUILTIN_EXPAND_TEXT.
3273    vm.register_builtin(BUILTIN_PARAM_FLAG, |vm, _argc| {
3274        let flags = vm.pop().to_str();
3275        let name = vm.pop().to_str();
3276        let body = format!("${{({}){}}}", flags, name);
3277        paramsubst_to_value(&body)
3278    });
3279
3280    // `foo[key]=val` — single-key set on an assoc array. Stack: [name, key, value].
3281    // PURE PASSTHRU: assignsparam with `name[key]` form (C port of
3282    // `Src/params.c::assignsparam` subscript path at c:3210-3231)
3283    // already does the indexed-array vs assoc decision, PM_HASHED
3284    // auto-vivification, numeric-subscript bounds handling, and
3285    // PM_READONLY rejection.
3286    vm.register_builtin(BUILTIN_SET_ASSOC, |vm, _argc| {
3287        // `${~spec}` carrier: an assignment statement is a word-
3288        // pipeline boundary too — restore the user's GLOB_SUBST
3289        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
3290        // ${options[globsubst]}` must read the user value).
3291        consume_tilde_globsubst_carrier();
3292        // argc 4 = compile flagged the subscript as DYNAMIC (`H[$k]`):
3293        // an EXPANDED-empty key is then a legal assoc key (C's
3294        // assignsparam isident gate sees the raw `$k` text and the
3295        // empty key stores at getindex time — zinit's
3296        // ZINIT_SICE[$1…$2] relies on it). argc 3 = source-literal
3297        // key; `H[]` stays the "not an identifier" error.
3298        let key_is_dynamic = if _argc == 4 {
3299            vm.pop().to_int() != 0
3300        } else {
3301            false
3302        };
3303        let value = vm.pop().to_str();
3304        let key = vm.pop().to_str();
3305        let name = vm.pop().to_str();
3306        if key_is_dynamic && key.is_empty() {
3307            with_executor(|exec| {
3308                let _ = exec;
3309            });
3310            // Mirror assignsparam's PM_HASHED tail directly (the
3311            // textual `name[]` reconstruction can't pass isident).
3312            if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
3313                let entry = store.entry(name.clone()).or_default();
3314                let newval = if let Some(old) = entry.get("") {
3315                    // `+=` arrives pre-concatenated by the compile
3316                    // read-modify-write; plain `=` overwrites.
3317                    let _ = old;
3318                    value.clone()
3319                } else {
3320                    value.clone()
3321                };
3322                entry.insert(String::new(), newval);
3323            }
3324            return Value::Status(0);
3325        }
3326        with_executor(|exec| {
3327            #[cfg(feature = "recorder")]
3328            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3329                let ctx = exec.recorder_ctx();
3330                let attrs = exec.recorder_attrs_for(&name);
3331                crate::recorder::emit_assoc_assign(
3332                    &name,
3333                    vec![(key.clone(), value.clone())],
3334                    attrs,
3335                    true,
3336                    ctx,
3337                );
3338            }
3339            let _ = exec;
3340        });
3341        // Build `name[key]=value` shape for assignsparam's subscript
3342        // dispatch. Arith-evaluate numeric subscripts on an existing
3343        // indexed array (`a[i+1]=v` form) before handing off — the
3344        // canonical port currently only handles literal int / string
3345        // keys, so pre-resolve here.
3346        let resolved_key = with_executor(|exec| {
3347            let is_indexed = exec.array(&name).is_some();
3348            let is_assoc = exec.assoc(&name).is_some();
3349            let is_scalar = !is_indexed && !is_assoc && exec.scalar(&name).is_some();
3350            // c:Src/params.c::getindex — `(i)pat` / `(I)pat` / `(R)pat`
3351            // / `(r)pat` subscript flags on an indexed array LHS resolve
3352            // to a numeric index (first / last match of pat). On a
3353            // SCALAR LHS the same flags resolve to a CHAR position
3354            // (1-based first/last match of pat in the scalar string)
3355            // for the c:2748+ char-splice assignment. zshrs's
3356            // read-form `${a[(i)pat]}` already implements both shapes;
3357            // the LHS assignment path silently stored the literal
3358            // "(i)pat" as an assoc key (for scalar: auto-vivified to
3359            // PM_HASHED via the assignsparam unknown-subscript
3360            // fallback). Bug #293 (array) / scalar sibling.
3361            //
3362            // Detect the `(flags)pat` shape and resolve to a numeric
3363            // index before assignsparam.
3364            if is_indexed || is_scalar {
3365                if let Some(rest) = key.strip_prefix('(') {
3366                    if let Some(close) = rest.find(')') {
3367                        let flags = &rest[..close];
3368                        let pat = &rest[close + 1..];
3369                        if !flags.is_empty()
3370                            && flags
3371                                .chars()
3372                                .all(|c| matches!(c, 'I' | 'R' | 'i' | 'r' | 'n' | 'e'))
3373                        {
3374                            // Resolve via the array's contents.
3375                            if let Some(arr) = exec.array(&name) {
3376                                let return_index = true; // LHS write — index needed
3377                                let down = flags.contains('I') || flags.contains('R');
3378                                let exact = flags.contains('e');
3379                                let iter: Box<dyn Iterator<Item = (usize, &String)>> = if down {
3380                                    Box::new(arr.iter().enumerate().rev())
3381                                } else {
3382                                    Box::new(arr.iter().enumerate())
3383                                };
3384                                let mut found: Option<usize> = None;
3385                                for (idx, elem) in iter {
3386                                    let matched = if exact {
3387                                        elem == pat
3388                                    } else {
3389                                        crate::ported::pattern::patcompile(&{ let mut __pat_tok = (pat).to_string(); crate::ported::glob::tokenize(&mut __pat_tok); __pat_tok },
3390                                            crate::ported::zsh_h::PAT_HEAPDUP as i32,
3391                                            None,
3392                                        )
3393                                        .map_or(false, |p| {
3394                                            crate::ported::pattern::pattry(&p, elem)
3395                                        })
3396                                    };
3397                                    if matched {
3398                                        found = Some(idx);
3399                                        break;
3400                                    }
3401                                }
3402                                let _ = return_index;
3403                                // (i)/(r) return 1-based index of match,
3404                                // arr.len()+1 (or 1 for I/R) on miss
3405                                // per zsh docs. We mirror the read-form
3406                                // semantics from subst.rs.
3407                                let idx_1based = match found {
3408                                    Some(i) => (i + 1) as i64,
3409                                    None => (arr.len() + 1) as i64,
3410                                };
3411                                return idx_1based.to_string();
3412                            }
3413                            // Scalar LHS — resolve to a CHAR position
3414                            // (1-based first/last match of pat in the
3415                            // string). c:Src/params.c:1411-1418 — the
3416                            // scalar path returns the char index from
3417                            // sliding-window pattern match against
3418                            // pm.u_str. Same algorithm as the read-form
3419                            // at subst.rs:5283-5306. Bug (scalar
3420                            // sibling of #293): `a=hello; a[(I)l]=X`
3421                            // previously auto-vivified `a` into
3422                            // PM_HASHED with key "(I)l" instead of
3423                            // splicing X at the last 'l' position
3424                            // (yielding "helXo").
3425                            if is_scalar {
3426                                let s = exec.scalar(&name).unwrap_or_default();
3427                                let s_chars: Vec<char> = s.chars().collect();
3428                                let n = s_chars.len();
3429                                let want_last = flags.contains('I') || flags.contains('R');
3430                                let exact = flags.contains('e');
3431                                let mut found: Option<usize> = None;
3432                                'outer: for start in 0..=n {
3433                                    let lengths: Box<dyn Iterator<Item = usize>> = if want_last {
3434                                        Box::new((1..=(n - start)).rev())
3435                                    } else {
3436                                        Box::new(1..=(n - start))
3437                                    };
3438                                    for len in lengths {
3439                                        let cand: String =
3440                                            s_chars[start..start + len].iter().collect();
3441                                        let matched = if exact {
3442                                            cand == pat
3443                                        } else {
3444                                            crate::ported::pattern::patcompile(&{ let mut __pat_tok = (pat).to_string(); crate::ported::glob::tokenize(&mut __pat_tok); __pat_tok },
3445                                                crate::ported::zsh_h::PAT_HEAPDUP as i32,
3446                                                None,
3447                                            )
3448                                            .map_or(false, |p| {
3449                                                crate::ported::pattern::pattry(&p, &cand)
3450                                            })
3451                                        };
3452                                        if matched {
3453                                            found = Some(start);
3454                                            if !want_last {
3455                                                break 'outer;
3456                                            }
3457                                            break;
3458                                        }
3459                                    }
3460                                }
3461                                // (I)/(R): scan again to find LAST.
3462                                if want_last {
3463                                    let mut last_found: Option<usize> = found;
3464                                    for start in (0..=n).rev() {
3465                                        for len in 1..=(n - start) {
3466                                            let cand: String =
3467                                                s_chars[start..start + len].iter().collect();
3468                                            let matched = if exact {
3469                                                cand == pat
3470                                            } else {
3471                                                crate::ported::pattern::patcompile(&{ let mut __pat_tok = (pat).to_string(); crate::ported::glob::tokenize(&mut __pat_tok); __pat_tok },
3472                                                    crate::ported::zsh_h::PAT_HEAPDUP as i32,
3473                                                    None,
3474                                                )
3475                                                .map_or(false, |p| {
3476                                                    crate::ported::pattern::pattry(&p, &cand)
3477                                                })
3478                                            };
3479                                            if matched {
3480                                                last_found = Some(start);
3481                                                break;
3482                                            }
3483                                        }
3484                                        if last_found.is_some()
3485                                            && last_found.unwrap() >= start
3486                                        {
3487                                            break;
3488                                        }
3489                                    }
3490                                    found = last_found;
3491                                }
3492                                let idx_1based = match found {
3493                                    Some(i) => (i + 1) as i64,
3494                                    // (i) miss → len+1 (one past end).
3495                                    None => (n + 1) as i64,
3496                                };
3497                                return idx_1based.to_string();
3498                            }
3499                        }
3500                    }
3501                }
3502            }
3503            if is_indexed && key.trim().parse::<i64>().is_err() {
3504                crate::ported::math::mathevali(&crate::ported::subst::singsub(&key))
3505                    .map(|n| n.to_string())
3506                    .unwrap_or(key.clone())
3507            } else {
3508                key.clone()
3509            }
3510        });
3511        let subscripted = format!("{}[{}]", name, resolved_key);
3512        crate::ported::params::assignsparam(&subscripted, &value, crate::ported::zsh_h::ASSPM_WARN);
3513        Value::Status(0)
3514    });
3515
3516    // Brace expansion. Routes through executor.xpandbraces (already
3517    // implemented for the pre-fusevm executor). Returns Value::Array.
3518    // BUILTIN_ARRAY_DROP_EMPTY — filter out empty Value::Str entries
3519    // from a Value::Array on the stack. Used by `for x in $@` /
3520    // `for x in $*` unquoted forms which drop empty positionals
3521    // (POSIX-like) but do NOT IFS-split each element internally
3522    // (zsh-specific — scalar word splitting is off by default).
3523    // Distinct from BUILTIN_WORD_SPLIT which routes through
3524    // multsub PREFORK_SPLIT (full IFS-split). Bug #166.
3525    vm.register_builtin(BUILTIN_ARRAY_DROP_EMPTY, |vm, _argc| {
3526        let v = vm.pop();
3527        match v {
3528            Value::Array(items) => {
3529                let filtered: Vec<Value> = items
3530                    .into_iter()
3531                    .filter(|x| !x.to_str().is_empty())
3532                    .collect();
3533                Value::Array(filtered)
3534            }
3535            Value::Str(s) if s.is_empty() => Value::Array(Vec::new()),
3536            other => other,
3537        }
3538    });
3539
3540    // BUILTIN_QUOTEDZPUTS — re-wrap top-of-stack scalar via the
3541    // canonical quotedzputs (Src/utils.c:6464). Non-printable bytes
3542    // come back as `$'…'` C-string form so the cond xtrace prefix
3543    // line preserves the source-quoting form for `[[ -n $'\C-[OP' ]]`
3544    // instead of leaking raw ESC + "OP" bytes through the terminal.
3545    vm.register_builtin(BUILTIN_QUOTEDZPUTS, |vm, _argc| {
3546        let s = vm.pop().to_str();
3547        Value::str(crate::ported::utils::quotedzputs(&s))
3548    });
3549
3550    // BUILTIN_QUOTE_TOKENIZED_OUTPUT — char-aware mirror of
3551    // c:Src/exec.c:2114 `quote_tokenized_output`. The canonical
3552    // port at exec::quote_tokenized_output operates on bytes
3553    // (zsh's metafied encoding); zshrs strings are UTF-8 so
3554    // `\u{87}` Star is `[0xC2, 0x87]`, and a byte walk writes
3555    // 0xC2 raw (invalid UTF-8 lead → U+FFFD on lossy decode).
3556    // Walk by char and dispatch the same switch the byte port
3557    // uses, but with the token chars matching the UTF-8 form.
3558    vm.register_builtin(BUILTIN_QUOTE_TOKENIZED_OUTPUT, |vm, _argc| {
3559        let s = vm.pop().to_str();
3560        let mut out = String::with_capacity(s.len());
3561        let chars: Vec<char> = s.chars().collect();
3562        let mut i = 0;
3563        while i < chars.len() {
3564            let c = chars[i];
3565            // c:2120 — Meta-quoted byte: emit `*++s ^ 32`.
3566            // In UTF-8 strings Meta is `\u{83}`; the next char is
3567            // the metafied payload.
3568            if c == '\u{83}' {
3569                if let Some(&n) = chars.get(i + 1) {
3570                    if (n as u32) < 0x80 {
3571                        out.push(((n as u8) ^ 32) as char);
3572                    } else {
3573                        out.push(n);
3574                    }
3575                    i += 2;
3576                    continue;
3577                }
3578                i += 1;
3579                continue;
3580            }
3581            // c:2124 — Nularg: skip.
3582            if c == '\u{a1}' {
3583                i += 1;
3584                continue;
3585            }
3586            // c:2128-2143 — ASCII specials get backslash-prefixed
3587            // then fall through to emit the literal char.
3588            match c {
3589                '\\' | '<' | '>' | '(' | '|' | ')' | '^' | '#' | '~'
3590                | '[' | ']' | '*' | '?' | '$' | ' ' => {
3591                    out.push('\\');
3592                    out.push(c);
3593                    i += 1;
3594                    continue;
3595                }
3596                '\t' => {
3597                    out.push_str("$'\\t'");
3598                    i += 1;
3599                    continue;
3600                }
3601                '\n' => {
3602                    out.push_str("$'\\n'");
3603                    i += 1;
3604                    continue;
3605                }
3606                '\r' => {
3607                    out.push_str("$'\\r'");
3608                    i += 1;
3609                    continue;
3610                }
3611                '=' => {
3612                    if i == 0 {
3613                        out.push('\\');
3614                    }
3615                    out.push(c);
3616                    i += 1;
3617                    continue;
3618                }
3619                _ => {}
3620            }
3621            // c:2163 — `if (itok(*s)) putc(ztokens[*s - Pound]);`
3622            // Map zsh token chars (`\u{84}`..`\u{a1}` range, the
3623            // ones the lexer emits for `#$^*()…`) back to their
3624            // source ASCII via the `ztokens` table.
3625            let cp = c as u32;
3626            if (0x84..=0xa1).contains(&cp) {
3627                let idx = (cp - 0x84) as usize;
3628                let ztokens = crate::ported::lex::ztokens.as_bytes();
3629                if idx < ztokens.len() {
3630                    out.push(ztokens[idx] as char);
3631                    i += 1;
3632                    continue;
3633                }
3634            }
3635            out.push(c);
3636            i += 1;
3637        }
3638        Value::str(out)
3639    });
3640
3641    // BUILTIN_WORD_SPLIT — `${=var}` IFS-split runtime.
3642    // PURE PASSTHRU: route through canonical `subst::multsub` with
3643    // PREFORK_SPLIT flag (C port of `Src/subst.c::multsub` at c:544
3644    // — the IFS-split walker with whitespace-vs-non-whitespace
3645    // gating, quote-aware parsing, and empty-field handling).
3646    vm.register_builtin(BUILTIN_WORD_SPLIT, |vm, _argc| {
3647        let s = vm.pop().to_str();
3648        let (_joined, parts, _isarr, _flags) =
3649            crate::ported::subst::multsub(&s, crate::ported::zsh_h::PREFORK_SPLIT);
3650        // Empty single-string special case → empty Array (drop empty arg).
3651        if parts.len() == 1 && parts[0].is_empty() {
3652            return Value::Array(Vec::new());
3653        }
3654        nodes_to_value(parts)
3655    });
3656
3657    vm.register_builtin(BUILTIN_BRACE_EXPAND, |vm, _argc| {
3658        // c:Src/glob.c::xpandbraces — brace expansion runs per word.
3659        // When the upstream produced an array (e.g. `${a:e}` splat),
3660        // expand braces on each element separately so the splat
3661        // survives. `pop().to_str()` would join with space and lose
3662        // the array shape. Parity bug #28 cousin: the BRACE_EXPAND
3663        // emit always fires for any word containing `{` (including
3664        // `${...}` param-expansion braces), so its collapse hit even
3665        // pure-paramsubst args.
3666        let raw = vm.pop();
3667        // c:Src/options.c — `no_brace_expand` (negated braceexpand)
3668        // disables brace expansion entirely. When set, `{a,b}` stays
3669        // literal. Mirror by short-circuiting xpandbraces; pass the
3670        // input through unchanged.
3671        let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
3672        let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
3673        let inputs: Vec<String> = match raw {
3674            Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
3675            other => vec![other.to_str()],
3676        };
3677        if !brace_expand {
3678            return nodes_to_value(inputs);
3679        }
3680        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
3681        for s in inputs {
3682            for w in crate::ported::glob::xpandbraces(&s, brace_ccl) {
3683                out.push(w);
3684            }
3685        }
3686        nodes_to_value(out)
3687    });
3688
3689    // `*(qual)` glob qualifier filter. Stack: [pattern, qualifier].
3690    // Pattern is glob-expanded normally, then each result is filtered by the
3691    // qualifier predicate. Common qualifiers:
3692    //   .  — regular files only
3693    //   /  — directories only
3694    //   @  — symlinks
3695    //   x  — executable
3696    //   r/w/x — readable/writable/executable
3697    //   N  — nullglob (no error if no match)
3698    //   L+N / L-N — size > N / size < N (in bytes)
3699    //   mh-N / mh+N — modified within N hours / older than N hours
3700    //   md-N / md+N — modified within N days / older than N days
3701    //   on/On — sort by name asc/desc (default)
3702    //   oL/OL — sort by length
3703    //   om/Om — sort by mtime
3704    // Pop a scalar pattern, run expand_glob, push Value::Array. Used
3705    // by the segment-concat compile path for `$D/*`-style words.
3706    vm.register_builtin(BUILTIN_GLOB_EXPAND, |vm, _argc| {
3707        // c:Src/glob.c:1872 — honour `setopt noglob` / `noglob CMD`
3708        // precommand. When the option is on, the word stays literal
3709        // (zsh skips the glob expansion entirely). Without this, the
3710        // segment-fast-path BUILTIN_GLOB_EXPAND fired even after
3711        // `noglob` set the option, so `noglob echo *.xyz` saw the
3712        // NOMATCH error instead of the literal pass-through.
3713        let raw = vm.pop();
3714        let noglob =
3715            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
3716        glob_expand_word_value(raw, noglob)
3717    });
3718    // Redirect-target variant of BUILTIN_GLOB_EXPAND. c:Src/glob.c:
3719    // 2161-2167 xpandredir — `prefork(&fake, isset(MULTIOS) ? 0 :
3720    // PREFORK_SINGLE, NULL)` then "Globbing is only done for
3721    // multios.": a redirect target word is only globbed when the
3722    // MULTIOS option is set. With it unset, `echo hi > *.txt`
3723    // creates the literal file `*.txt`, and `wc -c < *.txt` errors
3724    // "no such file or directory: *.txt". Bug #36 follow-up in
3725    // docs/BUGS.md.
3726    vm.register_builtin(BUILTIN_REDIR_GLOB_EXPAND, |vm, _argc| {
3727        let raw = vm.pop();
3728        let noglob =
3729            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
3730        let multios = opt_state_get("multios").unwrap_or(true);
3731        glob_expand_word_value(raw, noglob || !multios)
3732    });
3733    // Clear the default-word glob-pending carrier before the word's
3734    // expansion runs, so a flag set by a prior word never leaks in.
3735    vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB_RESET, |_vm, _argc| {
3736        crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| c.set(false));
3737        Value::Status(0)
3738    });
3739    // After the word is assembled, run filename generation ONLY if the
3740    // default/alternate paramsubst arm flagged a source-glob default
3741    // (DEFAULT_WORD_GLOB_PENDING). Otherwise pass the word through
3742    // literally — a parameter VALUE must not glob. c:Src/subst.c globlist.
3743    vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB, |vm, _argc| {
3744        let raw = vm.pop();
3745        let pending = crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| {
3746            let v = c.get();
3747            c.set(false); // read + clear
3748            v
3749        });
3750        if !pending {
3751            return raw;
3752        }
3753        let noglob =
3754            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
3755        glob_expand_word_value(raw, noglob)
3756    });
3757
3758    // `break`/`continue` from a sub-VM body. The compile path emits
3759    // these when the keyword appears at chunk top-level (no enclosing
3760    // for/while in the current chunk's patch lists). Outer-loop
3761    // builtins (BUILTIN_RUN_SELECT and any future loop-via-builtin
3762    // construct) drain canonical BREAKS/CONTFLAG after each iteration.
3763    //
3764    // Writes match `bin_break`'s c:5836+ pattern:
3765    //   continue: contflag = 1; breaks++   (Src/builtin.c::bin_break)
3766    //   break:    breaks++
3767    vm.register_builtin(BUILTIN_SET_BREAK, |_vm, _argc| {
3768        use std::sync::atomic::Ordering::SeqCst;
3769        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
3770        Value::Status(0)
3771    });
3772    vm.register_builtin(BUILTIN_SET_CONTINUE, |_vm, _argc| {
3773        use std::sync::atomic::Ordering::SeqCst;
3774        crate::ported::builtin::CONTFLAG.store(1, SeqCst);
3775        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
3776        Value::Status(0)
3777    });
3778
3779    // `${arr[*]}` — join array elements with the first IFS char into
3780    // a single string. Matches zsh: in DQ context this preserves the
3781    // join; in array context too the result is one Value::Str.
3782    // Set or clear a shell option directly. Used by `noglob CMD ...`
3783    // precommand wrapping — the compiler emits SET_RAW_OPT to flip the
3784    // option ON before compiling the inner words and OFF after, so glob
3785    // expansion of the inner args sees the temporary state.
3786    vm.register_builtin(BUILTIN_SET_RAW_OPT, |vm, _argc| {
3787        let on = vm.pop().to_int() != 0;
3788        let opt = vm.pop().to_str();
3789        // Pure passthru: canonical port lives in
3790        // src/ported/options.rs::opt_state_set_via_alias and
3791        // handles negation-alias resolution per c:Src/options.c.
3792        crate::ported::options::opt_state_set_via_alias(&opt, on);
3793        Value::Status(0)
3794    });
3795
3796    // c:Src/options.c GLOB_SUBST — runtime glob expansion of
3797    // substituted words. Pop a Value (Str or Array); when
3798    // GLOB_SUBST is ON, run expand_glob on each string element;
3799    // when OFF, pass through unchanged. Bug #119 in docs/BUGS.md.
3800    vm.register_builtin(BUILTIN_GLOB_SUBST_EXPAND, |vm, _argc| {
3801        let raw = vm.pop();
3802        let glob_subst =
3803            crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
3804        if !glob_subst {
3805            return raw;
3806        }
3807        // Collect input strings (Str → vec![s]; Array → multiple).
3808        let inputs: Vec<String> = match raw {
3809            Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
3810            other => vec![other.to_str()],
3811        };
3812        // Run expand_glob on each. Empty matches collapse to a
3813        // single literal pass-through to mirror nullglob-off default.
3814        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
3815        for pattern in inputs {
3816            let matches = with_executor(|exec| exec.expand_glob(&pattern));
3817            if matches.is_empty() {
3818                // No match: keep the literal (like nullglob off).
3819                out.push(pattern);
3820            } else {
3821                for m in matches {
3822                    out.push(m);
3823                }
3824            }
3825        }
3826        if out.len() == 1 {
3827            Value::str(out.into_iter().next().unwrap())
3828        } else {
3829            Value::Array(out.into_iter().map(Value::str).collect())
3830        }
3831    });
3832
3833    // c:Src/math.c:337 — `getmathparam` for ArithCompiler pre-load.
3834    // Pop a variable name, return its math-coerced value. Mirrors
3835    // the routing in math::getmathparam: try i64, then f64, then
3836    // recursive arith-eval, else 0. Bug #118 in docs/BUGS.md.
3837    vm.register_builtin(BUILTIN_GET_MATH_VAR, |vm, _argc| {
3838        let name = vm.pop().to_str();
3839        let raw = crate::ported::params::getsparam(&name).unwrap_or_default();
3840        // Empty / unset → 0.
3841        if raw.is_empty() {
3842            return Value::Int(0);
3843        }
3844        // Direct int / float parse.
3845        if let Ok(n) = raw.parse::<i64>() {
3846            return Value::Int(n);
3847        }
3848        if let Ok(f) = raw.parse::<f64>() {
3849            return Value::Float(f);
3850        }
3851        // Recursive arith eval (matches getmathparam fallback at
3852        // Src/math.c:337). If that fails too, return 0 — C's
3853        // mathevall returns 0 with errflag set on parse failure.
3854        match crate::ported::math::mathevali(&raw) {
3855            Ok(n) => Value::Int(n),
3856            Err(_) => Value::Int(0),
3857        }
3858    });
3859
3860    // c:Src/options.c GLOB_SUBST + Src/cond.c:552 cond_match.
3861    // Pop pattern string; when GLOB_SUBST is OFF, escape every glob
3862    // metachar with `\` so the downstream StrMatch + patcompile
3863    // treat them as literals (matching C's tokenization-based
3864    // gate). When GLOB_SUBST is ON, pass through unchanged.
3865    // See BUILTIN_GLOB_SUBST_GUARD docs above for full rationale.
3866    vm.register_builtin(BUILTIN_GLOB_SUBST_GUARD, |vm, _argc| {
3867        let p = vm.pop().to_str();
3868        let glob_subst =
3869            crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
3870        if glob_subst {
3871            return Value::str(p);
3872        }
3873        let mut out = String::with_capacity(p.len() * 2);
3874        for c in p.chars() {
3875            match c {
3876                '*' | '?' | '[' | ']' | '(' | ')' | '|' | '<' | '>' | '#' | '^'
3877                | '~' | '\\' => {
3878                    out.push('\\');
3879                    out.push(c);
3880                }
3881                _ => out.push(c),
3882            }
3883        }
3884        Value::str(out)
3885    });
3886
3887    vm.register_builtin(BUILTIN_ARRAY_JOIN_STAR, |vm, _argc| {
3888        let name = vm.pop().to_str();
3889        let (joined, ifs_full, in_dq) = with_executor(|exec| {
3890            // c:Src/params.c — `"$*"` joins by IFS[0]. zsh
3891            // distinguishes IFS=unset (→ default `" "`) from
3892            // IFS="" (→ EMPTY separator → fields concatenate).
3893            // chars().next() collapsed both into the default, so
3894            // IFS="" was treated as IFS=" ".
3895            let ifs_full = exec
3896                .scalar("IFS")
3897                .unwrap_or_else(|| " \t\n".to_string());
3898            let sep = ifs_full
3899                .chars()
3900                .next()
3901                .map(|c| c.to_string())
3902                .unwrap_or_default();
3903            let in_dq = exec.in_dq_context > 0;
3904            let joined = if name == "@" || name == "*" || name == "argv" {
3905                exec.pparams().join(&sep)
3906            } else if let Some(assoc_map) = exec.assoc(&name) {
3907                // c:Src/params.c — assoc-splat values for
3908                // `"${h[@]}"` / `"${h[*]}"`. Bug #109 in
3909                // docs/BUGS.md.
3910                assoc_map.values().cloned().collect::<Vec<_>>().join(&sep)
3911            } else if let Some(arr) = exec.array(&name) {
3912                arr.join(&sep)
3913            } else {
3914                exec.get_variable(&name)
3915            };
3916            (joined, ifs_full, in_dq)
3917        });
3918        // c:Src/subst.c — UNQUOTED `${name[*]}` (or `$*`) goes
3919        // through the canonical "join via IFS[0], then word-split
3920        // via IFS" pipeline. The fast-path bypassed paramsubst
3921        // entirely so it never word-split, producing one joined
3922        // string instead of N argv entries. Bug #428.
3923        //
3924        // In QUOTED (`"${name[*]}"`) context, the result IS a
3925        // single scalar — return it as Str without splitting.
3926        if in_dq {
3927            return Value::str(joined);
3928        }
3929        if joined.is_empty() {
3930            return Value::Array(Vec::new());
3931        }
3932        // IFS word-split — every IFS char is a separator. Empty
3933        // resulting fields are dropped (the canonical
3934        // "remove empty unquoted words" pass from
3935        // Src/subst.c::prefork c:184-187).
3936        let parts: Vec<String> = joined
3937            .split(|c: char| ifs_full.contains(c))
3938            .filter(|s| !s.is_empty())
3939            .map(String::from)
3940            .collect();
3941        if parts.is_empty() {
3942            Value::Array(Vec::new())
3943        } else if parts.len() == 1 {
3944            Value::str(parts.into_iter().next().unwrap())
3945        } else {
3946            Value::Array(parts.into_iter().map(Value::str).collect())
3947        }
3948    });
3949
3950    vm.register_builtin(BUILTIN_ARRAY_ALL, |vm, _argc| {
3951        let name = vm.pop().to_str();
3952        with_executor(|exec| {
3953            // Special positional names — splice the positional list.
3954            if name == "@" || name == "*" || name == "argv" {
3955                return Value::Array(exec.pparams().iter().map(Value::str).collect());
3956            }
3957            // c:Src/Modules/parameter.c — funcstack/funcfiletrace/
3958            // funcsourcetrace/functrace are PM_ARRAY|PM_READONLY
3959            // specials backed by the canonical FUNCSTACK Vec.
3960            // `${funcstack[@]}` inside a function call should splat
3961            // the innermost-first names; without this branch the
3962            // runtime fell to the scalar fallback (get_variable
3963            // returns empty for these specials) and `[@]` came out
3964            // empty. Bug #276 in docs/BUGS.md. Mirrors the parallel
3965            // arrays_get handler at src/ported/subst.rs ~10685.
3966            // c:Src/Modules/datetime.c:256 — `epochtime` PM_ARRAY|
3967            // PM_READONLY backed by getcurrenttime(). Same parallel
3968            // arrangement as the FUNCSTACK-backed specials below.
3969            if name == "epochtime" {
3970                let arr = crate::ported::modules::datetime::getcurrenttime();
3971                return Value::Array(arr.into_iter().map(Value::str).collect());
3972            }
3973            if matches!(
3974                name.as_str(),
3975                "funcstack" | "funcfiletrace" | "funcsourcetrace" | "functrace"
3976            ) {
3977                // Route the three trace arrays through the canonical
3978                // ported getfns (Src/Modules/parameter.c:648/:679/:711)
3979                // — the previous inline copy emitted wrong shapes
3980                // (bare filename for funcfiletrace, `name:lineno` for
3981                // functrace instead of `caller:lineno`); same dedup as
3982                // the parallel arrays_get handler in subst.rs.
3983                let vals: Vec<String> = match name.as_str() {
3984                    "funcstack" => crate::ported::modules::parameter::FUNCSTACK
3985                        .lock()
3986                        .map(|f| f.iter().rev().map(|fs| fs.name.clone()).collect())
3987                        .unwrap_or_default(),
3988                    "funcfiletrace" => crate::ported::modules::parameter::funcfiletracegetfn(
3989                        std::ptr::null_mut(),
3990                    ),
3991                    "funcsourcetrace" => crate::ported::modules::parameter::funcsourcetracegetfn(
3992                        std::ptr::null_mut(),
3993                    ),
3994                    _ => crate::ported::modules::parameter::functracegetfn(std::ptr::null_mut()),
3995                };
3996                return Value::Array(vals.into_iter().map(Value::str).collect());
3997            }
3998            // c:Src/params.c — `${assoc[@]}` enumerates VALUES (per
3999            // params.c:1696-1750 hashparam splat). Check assoc
4000            // storage BEFORE the scalar fallback so an associative
4001            // array named X resolves `${X[@]}` to the values, not
4002            // empty. Bug #109 in docs/BUGS.md: `${h[@]}` on an
4003            // assoc routed through BUILTIN_ARRAY_ALL, which only
4004            // consulted `exec.array(name)` (the indexed-array map)
4005            // — that lookup missed for assocs, fell through to
4006            // `get_variable("h")` (also empty for an assoc-only
4007            // name), and returned `Array(vec![])`. zsh's expected
4008            // behavior is to enumerate values.
4009            if let Some(assoc_map) = exec.assoc(&name) {
4010                return Value::Array(
4011                    assoc_map.values().cloned().map(Value::str).collect(),
4012                );
4013            }
4014            match exec.array(&name) {
4015                Some(v) => Value::Array(v.iter().map(Value::str).collect()),
4016                None => {
4017                    // Fall back to scalar lookup. zsh (unlike bash)
4018                    // does NOT IFS-split a scalar variable in a for
4019                    // list — `for w in $scalar` iterates ONCE with the
4020                    // scalar value. Word-splitting requires either
4021                    // sh_word_split option or explicit `${(s.,.)scalar}`.
4022                    let val = exec.get_variable(&name);
4023                    if val.is_empty() && !exec.has_scalar(&name) && env::var(&name).is_err() {
4024                        Value::Array(vec![])
4025                    } else if opt_state_get("shwordsplit").unwrap_or(false) {
4026                        // bash-compat: under setopt sh_word_split, do
4027                        // split scalars on IFS chars.
4028                        let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
4029                        let parts: Vec<Value> = val
4030                            .split(|c: char| ifs.contains(c))
4031                            .filter(|s| !s.is_empty())
4032                            .map(Value::str)
4033                            .collect();
4034                        Value::Array(parts)
4035                    } else {
4036                        Value::Array(vec![Value::str(val)])
4037                    }
4038                }
4039            }
4040        })
4041    });
4042
4043    // BUILTIN_ARRAY_FLATTEN(N): pops N values, flattens one level of Array
4044    // nesting, pushes the resulting Array AND its length as a separate Int.
4045    // The two-value return shape lets the caller (for-loop compile path)
4046    // SetSlot the length before SetSlot'ing the array, without re-deriving
4047    // the length from the array via a second builtin call.
4048    // `coproc [name] { body }` — bidirectional pipe to backgrounded body.
4049    // Stack discipline (top first): [name (str, "" for default), sub_idx (int)].
4050    // On success: parent's `executor.arrays[name]` becomes [write_fd, read_fd]
4051    // and Status(0) is returned. The caller writes to the child's stdin via
4052    // write_fd, reads its stdout via read_fd, and closes both when done.
4053    //
4054    // Bash's coproc convention is `${NAME[0]}` = read_fd, `${NAME[1]}` =
4055    // write_fd. We follow that: arrays[name] = [read_fd_str, write_fd_str].
4056    vm.register_builtin(BUILTIN_RUN_COPROC, |vm, _argc| {
4057        let sub_idx = vm.pop().to_int() as usize;
4058        let job_text = vm.pop().to_str();
4059        let raw_name = vm.pop().to_str();
4060        let name = if raw_name.is_empty() {
4061            "COPROC".to_string()
4062        } else {
4063            raw_name
4064        };
4065        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
4066            Some(c) => c,
4067            None => return Value::Status(1),
4068        };
4069
4070        // c:Src/exec.c:1710-1712 — starting a new coproc closes the
4071        // previous one's fds FIRST:
4072        //     if (coprocin >= 0) { zclose(coprocin); zclose(coprocout); }
4073        // The old coproc child then sees EOF on its stdin and exits on
4074        // its own schedule (its job-table entry stays until it's
4075        // reaped) — zsh does NOT deletejob it here. This is also what
4076        // makes the `exec 4<&p; coproc exit; read -u4` EOF idiom work:
4077        // the replacement coproc closes the shell's write end to the
4078        // old one.
4079        {
4080            use std::sync::atomic::Ordering;
4081            let old_in = crate::ported::modules::clone::coprocin.load(Ordering::Relaxed);
4082            if old_in >= 0 {
4083                let old_out =
4084                    crate::ported::modules::clone::coprocout.load(Ordering::Relaxed);
4085                unsafe {
4086                    libc::close(old_in);
4087                    if old_out >= 0 {
4088                        libc::close(old_out);
4089                    }
4090                }
4091                crate::ported::modules::clone::coprocin.store(-1, Ordering::Relaxed);
4092                crate::ported::modules::clone::coprocout.store(-1, Ordering::Relaxed);
4093            }
4094        }
4095
4096        // (parent_read ← child_stdout)
4097        let mut p2c = [0i32; 2]; // parent writes, child reads
4098        let mut c2p = [0i32; 2]; // child writes, parent reads
4099        if unsafe { libc::pipe(p2c.as_mut_ptr()) } < 0 {
4100            return Value::Status(1);
4101        }
4102        if unsafe { libc::pipe(c2p.as_mut_ptr()) } < 0 {
4103            unsafe {
4104                libc::close(p2c[0]);
4105                libc::close(p2c[1]);
4106            }
4107            return Value::Status(1);
4108        }
4109        // c:Src/exec.c:5160 mpipe — both pipes' fds are moved above
4110        // the user-visible range (movefd → F_DUPFD ≥ 10) so the
4111        // coproc fds never collide with explicit user fds like
4112        // `exec 3>&p`.
4113        for fd in p2c.iter_mut().chain(c2p.iter_mut()) {
4114            *fd = crate::ported::utils::movefd(*fd);
4115        }
4116
4117        match unsafe { libc::fork() } {
4118            -1 => {
4119                unsafe {
4120                    libc::close(p2c[0]);
4121                    libc::close(p2c[1]);
4122                    libc::close(c2p[0]);
4123                    libc::close(c2p[1]);
4124                }
4125                Value::Status(1)
4126            }
4127            0 => {
4128                // Child: stdin from p2c[0], stdout to c2p[1]. Close all
4129                // unused fds. setsid so SIGINT to fg doesn't hit us.
4130                unsafe {
4131                    libc::dup2(p2c[0], libc::STDIN_FILENO);
4132                    libc::dup2(c2p[1], libc::STDOUT_FILENO);
4133                    libc::close(p2c[0]);
4134                    libc::close(p2c[1]);
4135                    libc::close(c2p[0]);
4136                    libc::close(c2p[1]);
4137                    libc::setsid();
4138                }
4139                crate::fusevm_disasm::maybe_print_stdout("coproc:child", &chunk);
4140                let mut co_vm = fusevm::VM::new(chunk);
4141                register_builtins(&mut co_vm);
4142                let _ = co_vm.run();
4143                let _ = std::io::stdout().flush();
4144                let _ = std::io::stderr().flush();
4145                std::process::exit(co_vm.last_status);
4146            }
4147            pid => {
4148                // Parent: close child ends, store [read_fd, write_fd] in NAME.
4149                unsafe {
4150                    libc::close(p2c[0]);
4151                    libc::close(c2p[1]);
4152                }
4153                let read_fd = c2p[0];
4154                let write_fd = p2c[1];
4155                with_executor(|exec| {
4156                    exec.unset_scalar(&name);
4157                    exec.set_array(name, vec![read_fd.to_string(), write_fd.to_string()]);
4158                });
4159                // c:Src/exec.c — `coprocin`/`coprocout` are the
4160                // canonical globals that bin_read's `-p` arm
4161                // (Src/builtin.c:6510) and bin_print's `-p` arm
4162                // (Src/builtin.c:4827) read to find the
4163                // coprocess fds. The Rust port has the atomic
4164                // declarations at src/ported/modules/clone.rs:262
4165                // but the coproc-launch path never updated them,
4166                // so `read -p` / `print -p` always errored with
4167                // "-p: no coprocess" even when a coproc was
4168                // running. Bug #388 in docs/BUGS.md. Update them
4169                // here so the canonical builtins find the live
4170                // pipe.
4171                crate::ported::modules::clone::coprocin
4172                    .store(read_fd, std::sync::atomic::Ordering::Relaxed);
4173                crate::ported::modules::clone::coprocout
4174                    .store(write_fd, std::sync::atomic::Ordering::Relaxed);
4175                // c:Src/exec.c:1725 — `fdtable[coprocin] =
4176                // fdtable[coprocout] = FDT_UNUSED;`: the two kept ends
4177                // are user-reachable (via `>&p` / `<&p`), so they drop
4178                // the FDT_INTERNAL mark movefd gave them.
4179                crate::ported::utils::fdtable_set(
4180                    read_fd,
4181                    crate::ported::zsh_h::FDT_UNUSED,
4182                );
4183                crate::ported::utils::fdtable_set(
4184                    write_fd,
4185                    crate::ported::zsh_h::FDT_UNUSED,
4186                );
4187                // c:Src/exec.c:2837 — `lastpid = (zlong) pid;`. zsh
4188                // sets the `$!` global to the coproc child's PID so
4189                // subsequent `$!` reads return it. The Rust port at
4190                // exec.rs:6773 mirrors this for regular background
4191                // jobs but the coproc launch path was missing the
4192                // assignment, leaving `$!` at 0 after `coproc cmd`.
4193                crate::ported::modules::clone::lastpid
4194                    .store(pid, std::sync::atomic::Ordering::Relaxed);
4195                // c:Src/exec.c:1700-1758 — the coproc rides the SAME
4196                // Z_ASYNC job-table path as `cmd &`: `thisjob = newjob
4197                // = initjob()` (c:1700), addproc hangs the pid+text
4198                // proc entry off the job, `jobtab[thisjob].stat |=
4199                // STAT_NOSTTY` (c:1746), `clearoldjobtab()` (c:1744)
4200                // and `spawnjob()` (c:1758) promote it to curjob. This
4201                // is what makes `jobs` list the coproc as
4202                // `[1]  + running    cat` and `kill %1` resolve it.
4203                // Mirrors the BUILTIN_RUN_BG parent arm exactly.
4204                {
4205                    use crate::ported::jobs;
4206                    use std::sync::Mutex;
4207                    let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
4208                    let idx = {
4209                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
4210                        let idx = jobs::initjob(&mut tab); // c:exec.c:1700
4211                        jobs::addproc(
4212                            &mut tab[idx],
4213                            pid,
4214                            &job_text,
4215                            false,
4216                            Some(std::time::Instant::now()),
4217                            -1,
4218                            -1,
4219                        );
4220                        tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
4221                        idx
4222                    };
4223                    jobs::clearoldjobtab(); // c:exec.c:1744
4224                    if let Ok(mut tj) = jobs::THISJOB
4225                        .get_or_init(|| Mutex::new(-1))
4226                        .lock()
4227                    {
4228                        *tj = idx as i32;
4229                    }
4230                    jobs::spawnjob(); // c:exec.c:1758
4231                }
4232                with_executor(|exec| {
4233                    exec.jobs
4234                        .add_pid_job(pid, job_text.clone(), JobState::Running);
4235                });
4236                Value::Status(0)
4237            }
4238        }
4239    });
4240
4241    vm.register_builtin(BUILTIN_ARRAY_FLATTEN, |vm, argc| {
4242        let n = argc as usize;
4243        let start = vm.stack.len().saturating_sub(n);
4244        let raw: Vec<Value> = vm.stack.drain(start..).collect();
4245        let mut flat: Vec<Value> = Vec::with_capacity(raw.len());
4246        for v in raw {
4247            match v {
4248                Value::Array(items) => flat.extend(items),
4249                other => flat.push(other),
4250            }
4251        }
4252        let len = flat.len() as i64;
4253        // Push the array first; the Int(len) becomes the builtin's return
4254        // value (which CallBuiltin already pushes). Caller consumes in
4255        // reverse: SetSlot(len_slot) pops Int, SetSlot(arr_slot) pops Array.
4256        vm.push(Value::Array(flat));
4257        Value::Int(len)
4258    });
4259
4260    // Shell variable get/set — routes through executor.variables so nested
4261    // VMs (function calls) and tree-walker callers see the same storage.
4262    vm.register_builtin(BUILTIN_GET_VAR, |vm, argc| {
4263        let args = pop_args(vm, argc);
4264        let name = args.into_iter().next().unwrap_or_default();
4265        let live_status = vm.last_status;
4266        // Suppress sync when a deferred subshell exit just landed:
4267        // LASTVAL holds the correct deferred status, vm.last_status
4268        // is stale (post-subshell vm doesn't propagate status). See
4269        // SUBSHELL_EXIT_STATUS_PENDING TLS declaration for rationale.
4270        let suppress_sync = SUBSHELL_EXIT_STATUS_PENDING.with(|c| {
4271            let prev = c.get();
4272            c.set(false);
4273            prev
4274        });
4275        // `$@` and `$*` need splice semantics — return Value::Array of
4276        // positional params so for-loop's BUILTIN_ARRAY_FLATTEN spreads them
4277        // and pop_args splits them into argv slots. zsh's `"$@"` bslashquote-each-
4278        // word semantics matches: each pos-param becomes its own arg.
4279        // Same for arrays accessed by name (e.g. `$arr` in some contexts).
4280        let sync_status = |exec: &mut ShellExecutor| {
4281            if !suppress_sync {
4282                exec.set_last_status(live_status);
4283            }
4284        };
4285        if name == "@" || name == "*" {
4286            // Quoting decides empty-word retention (c:Src/subst.c:
4287            // 184-187): the COMPILE site knows it and emits
4288            // BUILTIN_ARRAY_DROP_EMPTY after this read for the
4289            // unquoted form only — in_dq_context is NOT a valid
4290            // discriminator here (the quoted "$@" fast path emits
4291            // GET_VAR directly without an EXPAND_TEXT wrapper).
4292            return with_executor(|exec| {
4293                sync_status(exec);
4294                Value::Array(exec.pparams().iter().map(Value::str).collect())
4295            });
4296        }
4297        // RC_EXPAND_PARAM: when the option is set and `name` refers to
4298        // an array, return Value::Array so the enclosing word's
4299        // BUILTIN_CONCAT_DISTRIBUTE distributes element-wise. Without
4300        // the option, arrays still join to a space-separated scalar
4301        // (zsh's default unquoted-array-as-scalar semantics).
4302        let rc_expand = with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
4303        if rc_expand {
4304            let arr_val = with_executor(|exec| {
4305                sync_status(exec);
4306                exec.array(&name)
4307            });
4308            if let Some(arr) = arr_val {
4309                return Value::Array(arr.into_iter().map(Value::str).collect());
4310            }
4311        }
4312        // Magic-assoc fallback FIRST — `${aliases}` / `${functions}`
4313        // / `${commands}` / etc. should return the value list per
4314        // zsh's bare-assoc semantics. Without this, those names fell
4315        // through to `get_variable` which is empty (they live in
4316        // separate executor tables, not `assoc_arrays`). Return as
4317        // a Value::Array so `arr=(${aliases})` distributes into
4318        // multiple elements, matching zsh's array-context word
4319        // splitting for assoc-bare references.
4320        let magic_vals = with_executor(|exec| {
4321            sync_status(exec);
4322            // Canonical PARTAB dispatch (Src/Modules/parameter.c:2235-
4323            // 2298 + SPECIALPMDEFs in mapfile/terminfo/termcap/system/
4324            // zleparameter): PARTAB_ARRAY entries → whole-array getfn;
4325            // PARTAB entries → scan keys + per-key getpm/scanpm fn
4326            // pointers.
4327            let _ = exec;
4328            if let Some(values) = partab_array_get(&name) {
4329                Some(values)
4330            } else if let Some(keys) = partab_scan_keys(&name) {
4331                Some(
4332                    keys.iter()
4333                        .map(|k| partab_get(&name, k).unwrap_or_default())
4334                        .collect::<Vec<_>>(),
4335                )
4336            } else {
4337                None
4338            }
4339        });
4340        if let Some(vals) = magic_vals {
4341            // Distinguish "name IS a magic-assoc with no entries"
4342            // (return Array(empty)) from "name is unknown — fall
4343            // through to get_variable".
4344            // c:Src/params.c:2293-2296 — KSHARRAYS bare reference
4345            // collapses to the FIRST element in scan order
4346            // (`v->end = 1, v->isarr = 0`). For `options` the scan
4347            // order is optiontab bucket order (OPTIONTAB), so zsh 5.9
4348            // prints `off` (posixargzero) for
4349            // `setopt ksharrays; print $options`.
4350            if opt_state_get("ksharrays").unwrap_or(false) {
4351                return Value::str(vals.into_iter().next().unwrap_or_default());
4352            }
4353            return Value::Array(vals.into_iter().map(Value::str).collect());
4354        }
4355        // Indexed-array path: return Value::Array so pop_args splats
4356        // each element into its own argv slot. Direct port of zsh's
4357        // unquoted `$arr` semantics — each element becomes a separate
4358        // word in command-arg position.
4359        //
4360        // DQ context exception: inside `"...$arr..."`, zsh joins with
4361        // the first char of $IFS (default space) so the DQ word stays
4362        // a single argv slot. Detect via in_dq_context (bumped by
4363        // BUILTIN_EXPAND_TEXT mode 1) and return the joined scalar.
4364        // Direct port of Src/subst.c:1759-1813 nojoin/sepjoin: in DQ
4365        // (qt=1) without explicit `(@)`, sepjoin runs and the result
4366        // is one word.
4367        let arr_assoc_data = with_executor(|exec| {
4368            sync_status(exec);
4369            let in_dq = exec.in_dq_context > 0;
4370            // KSH_ARRAYS: bare `$arr` returns ONLY arr[0] (zero-
4371            // based first-element-only semantics). Direct port of
4372            // Src/params.c getstrvalue's KSH_ARRAYS gate which
4373            // returns aval[0] instead of the whole array.
4374            let ksh_arrays = opt_state_get("ksharrays").unwrap_or(false);
4375            if let Some(arr) = exec.array(&name) {
4376                if ksh_arrays {
4377                    return Some((vec![arr.first().cloned().unwrap_or_default()], in_dq));
4378                }
4379                return Some((arr.clone(), in_dq));
4380            }
4381            if let Some(map) = exec.assoc(&name) {
4382                let mut keys: Vec<&String> = map.keys().collect();
4383                keys.sort();
4384                let values: Vec<String> =
4385                    keys.iter().filter_map(|k| map.get(*k).cloned()).collect();
4386                if ksh_arrays {
4387                    return Some((vec![values.into_iter().next().unwrap_or_default()], in_dq));
4388                }
4389                return Some((values, in_dq));
4390            }
4391            None
4392        });
4393        if let Some((items, in_dq)) = arr_assoc_data {
4394            // c:Src/subst.c:184-187 — prefork's `else if (!keep)
4395            // uremnode(list, node)`: UNQUOTED expansion drops empty
4396            // list nodes before they reach argv, so `a=(y '' x);
4397            // print -- $a` passes TWO args in zsh (`y x`), while the
4398            // quoted "${a[@]}" splat keeps the empty slot. The
4399            // paramsubst splat path already does this (Bug #578
4400            // retain); this GET_VAR fast path bypassed it and leaked
4401            // empty argv slots (visible double-space, wrong arg
4402            // counts in `for`/`print -l`).
4403            let items: Vec<String> = if in_dq {
4404                items
4405            } else {
4406                items.into_iter().filter(|s| !s.is_empty()).collect()
4407            };
4408            if in_dq {
4409                // c:Src/utils.c:3936-3945 sepjoin default-sep rule:
4410                // set-but-empty IFS joins with "" (`IFS=""; echo
4411                // "$arr"` concatenates); only unset / space-leading
4412                // IFS yields " ". The previous get_variable read
4413                // couldn't distinguish unset from set-empty.
4414                return Value::str(crate::ported::utils::sepjoin(&items, None));
4415            }
4416            return Value::Array(items.into_iter().map(Value::str).collect());
4417        }
4418        let (val, in_dq, is_known) = with_executor(|exec| {
4419            sync_status(exec);
4420            let v = exec.get_variable(&name);
4421            // For nounset detection: a name is "known" when it has a
4422            // paramtab/array/assoc/env entry. Special chars ($?, $#,
4423            // $@, $*, $-, $$, $!, $_, $0) always count as known
4424            // regardless of value. Pure-digit positional params
4425            // count as known iff index <= $# (set -- has populated
4426            // that slot). c:Src/subst.c:1689 — NOUNSET fires on
4427            // unset positional param too: `set --; echo "$1"` with
4428            // nounset must diagnose.
4429            let is_special_single = name.len() == 1
4430                && matches!(
4431                    name.chars().next().unwrap(),
4432                    '?' | '#' | '@' | '*' | '-' | '$' | '!' | '_' | '0'
4433                );
4434            let is_pure_digit = !name.is_empty() && name.chars().all(|c| c.is_ascii_digit());
4435            let positional_known = if is_pure_digit {
4436                let idx: usize = name.parse().unwrap_or(0);
4437                if idx == 0 {
4438                    true // $0 always set
4439                } else {
4440                    idx <= exec.pparams().len()
4441                }
4442            } else {
4443                false
4444            };
4445            let known = !v.is_empty()
4446                || name.is_empty()
4447                || is_special_single
4448                || positional_known
4449                || crate::ported::params::paramtab()
4450                    .read()
4451                    .ok()
4452                    .map(|t| t.contains_key(&name))
4453                    .unwrap_or(false)
4454                || env::var(&name).is_ok();
4455            (v, exec.in_dq_context > 0, known)
4456        });
4457        // c:Src/subst.c:1689 — NO_UNSET / nounset: reading an unset
4458        // parameter fires "parameter not set" diagnostic and aborts
4459        // the substitution. Direct port of the noerrs gate at c:1689
4460        // (zerr + errflag). Matches `set -u` POSIX semantics.
4461        if !is_known && opt_state_get("nounset").unwrap_or(false) {
4462            crate::ported::utils::zerr(&format!("{}: parameter not set", name));
4463            crate::ported::utils::errflag.fetch_or(
4464                crate::ported::zsh_h::ERRFLAG_ERROR,
4465                std::sync::atomic::Ordering::Relaxed,
4466            );
4467            with_executor(|exec| exec.set_last_status(1));
4468            return Value::str("");
4469        }
4470        // Empty unquoted scalar → drop the arg (zsh "remove empty
4471        // unquoted words" rule). Returning empty Value::Array makes
4472        // pop_args contribute zero items. DQ context keeps the empty
4473        // string so "$a" stays a single empty arg. Direct port of
4474        // subst.c's elide-empty pass.
4475        if val.is_empty() && !in_dq {
4476            return Value::Array(Vec::new());
4477        }
4478        // c:Src/subst.c:1759 SH_WORD_SPLIT — when shwordsplit is set and
4479        // we're in unquoted command-arg position (not DQ), split scalar
4480        // value on IFS into multiple words. Matches BUILTIN_ARRAY_ALL's
4481        // shwordsplit arm (fusevm_bridge.rs:2200). Without this, bare
4482        // `$s` in `print $s` stayed a single arg even with the option
4483        // set, breaking POSIX-style scalar word-splitting.
4484        if !in_dq && opt_state_get("shwordsplit").unwrap_or(false) {
4485            let ifs =
4486                with_executor(|exec| exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string()));
4487            let parts: Vec<Value> = val
4488                .split(|c: char| ifs.contains(c))
4489                .filter(|s| !s.is_empty())
4490                .map(|s| Value::str(s.to_string()))
4491                .collect();
4492            if parts.is_empty() {
4493                return Value::Array(Vec::new());
4494            } else if parts.len() == 1 {
4495                return parts.into_iter().next().unwrap();
4496            } else {
4497                return Value::Array(parts);
4498            }
4499        }
4500        Value::str(val)
4501    });
4502
4503    // `name+=val` (no parens) — runtime dispatch:
4504    //   - if `name` is in `arrays` → push `val` as new element
4505    //   - if `name` is in `assoc_arrays` → refuse (zsh errors here)
4506    //   - else → scalar concat (existing behavior)
4507    // Stack: [name, value].
4508    vm.register_builtin(BUILTIN_APPEND_SCALAR_OR_PUSH, |vm, argc| {
4509        let args = pop_args(vm, argc);
4510        let mut iter = args.into_iter();
4511        let name = iter.next().unwrap_or_default();
4512        let value = iter.next().unwrap_or_default();
4513        with_executor(|exec| {
4514            // Array form: `arr+=elem` pushes a single element.
4515            // Routes through canonical assignaparam(name, [value],
4516            // ASSPM_AUGMENT) — Src/params.c:3357 c:3402-3412 augment
4517            // path prepends prior scalar / appends to existing array.
4518            if exec.array(&name).is_some() {
4519                // c:Src/params.c — under KSHARRAYS a bare array name
4520                // addresses element 0 (ksh), so `a+=X` (scalar augment)
4521                // CONCATENATES onto the first element ("firstlast second"),
4522                // it does NOT push a new element. C routes scalar `+=`
4523                // through assignsparam (which targets the elem-0 value);
4524                // zshrs's APPEND_SCALAR_OR_PUSH would otherwise push.
4525                if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
4526                    let mut arr = exec.array(&name).unwrap_or_default();
4527                    if arr.is_empty() {
4528                        arr.push(value.clone());
4529                    } else {
4530                        arr[0] = format!("{}{}", arr[0], value);
4531                    }
4532                    exec.set_array(name.clone(), arr);
4533                    #[cfg(feature = "recorder")]
4534                    if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
4535                        let ctx = exec.recorder_ctx();
4536                        let attrs = exec.recorder_attrs_for(&name);
4537                        emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
4538                    }
4539                    return;
4540                }
4541                let _ = crate::ported::params::assignaparam(
4542                    &name,
4543                    vec![value.clone()],
4544                    crate::ported::zsh_h::ASSPM_AUGMENT,
4545                );
4546                #[cfg(feature = "recorder")]
4547                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
4548                    let ctx = exec.recorder_ctx();
4549                    let attrs = exec.recorder_attrs_for(&name);
4550                    emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
4551                }
4552                return;
4553            }
4554            if exec.assoc(&name).is_some() {
4555                eprintln!("zshrs: {}: cannot use += on assoc without (key val)", name);
4556                return;
4557            }
4558            // Scalar / integer / float form: route through canonical
4559            // assignsparam(name, value, ASSPM_AUGMENT) which
4560            // dispatches PM_TYPE — PM_SCALAR concats, PM_INTEGER
4561            // arith-adds (c:2775-2778), PM_FLOAT float-adds.
4562            let _ = crate::ported::params::assignsparam(
4563                &name,
4564                &value,
4565                crate::ported::zsh_h::ASSPM_AUGMENT,
4566            );
4567            #[cfg(feature = "recorder")]
4568            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
4569                let ctx = exec.recorder_ctx();
4570                let attrs = exec.recorder_attrs_for(&name);
4571                // Re-read the canonical value via get_variable for the
4572                // recorder bundle (assignsparam may have transformed it
4573                // through integer/float arithmetic).
4574                let final_val = exec.get_variable(&name);
4575                let lower = name.to_ascii_lowercase();
4576                if matches!(
4577                    lower.as_str(),
4578                    "path" | "fpath" | "manpath" | "module_path" | "cdpath"
4579                ) {
4580                    emit_path_or_assign(&name, std::slice::from_ref(&final_val), attrs, true, &ctx);
4581                } else {
4582                    crate::recorder::emit_assign_typed(&name, &final_val, attrs, ctx);
4583                }
4584            }
4585        });
4586        Value::Status(0)
4587    });
4588
4589    // BUILTIN_SET_VAR — `name=value` runtime scalar assignment.
4590    // PURE PASSTHRU: hand to canonical `setsparam` (C port of
4591    // `Src/params.c::setsparam`). That walks assignsparam →
4592    // assignstrvalue which already does:
4593    //   - readonly rejection (zerr + errflag at c:2701)
4594    //   - PM_INTEGER math evaluation (mathevali at c:3590)
4595    //   - PM_EFLOAT / PM_FFLOAT float coercion (c:3608)
4596    //   - PM_LOWER / PM_UPPER case fold (via setstrvalue)
4597    //   - GSU special-param dispatch (homesetfn / ifssetfn / etc.)
4598    //   - allexport env mirror via the PM_EXPORTED setfn
4599    //
4600    // Bridge-only concerns kept here:
4601    //   - inline_env_stack (zsh `X=foo cmd` scoped env)
4602    //   - recorder emission (PFA-SMR)
4603    //   - vm.last_status propagation for `a=$(cmd)` exit-code chaining
4604    vm.register_builtin(BUILTIN_SET_VAR, |vm, argc| {
4605        // `${~spec}` carrier: an assignment statement is a word-
4606        // pipeline boundary too — restore the user's GLOB_SUBST
4607        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
4608        // ${options[globsubst]}` must read the user value).
4609        consume_tilde_globsubst_carrier();
4610        // Snapshot the raw Values BEFORE pop_args's to_str
4611        // flattening — needed to distinguish Int (arith assignment,
4612        // integer-typed param) from Str (scalar assignment).
4613        let mut raw_values: Vec<fusevm::Value> = Vec::with_capacity(argc as usize);
4614        for _ in 0..argc {
4615            raw_values.push(vm.pop());
4616        }
4617        raw_values.reverse();
4618        let name = raw_values.first().map(|v| v.to_str()).unwrap_or_default();
4619        let value_raw = raw_values.get(1).cloned();
4620        let value = value_raw.as_ref().map(|v| v.to_str()).unwrap_or_default();
4621        // c:Src/params.c — when the bytecode hands us an Int value
4622        // (only the arith assignment paths emit this — `(( X = N ))`
4623        // is the canonical site), route through setiparam so the
4624        // param ends up PM_INTEGER + inherits the math layer's
4625        // `lastbase` for display formatting (`(( X = 16#ff ));
4626        // echo \$X` → `16#FF`). Scalar `X=val` and `$((expr))`
4627        // assignments still take the setsparam path below.
4628        let int_assign = matches!(value_raw, Some(fusevm::Value::Int(_)));
4629        let float_assign = matches!(value_raw, Some(fusevm::Value::Float(_)));
4630        let mut assign_failed = false;
4631        with_executor(|exec| {
4632            // c:Src/params.c assignsparam — PM_READONLY rejection
4633            // BEFORE any env mutation. The inline-env-prefix path
4634            // (`X=2 env`) called env::set_var unconditionally before
4635            // the readonly check fired in setsparam, so the OS env
4636            // got X=2 even though the assignment errored. env then
4637            // inherited the polluted env from fork, leaking the
4638            // attempted override past the readonly guard. Mirror
4639            // C's order: readonly check → zerr → bail; only mutate
4640            // env when the assignment is admissible. Bug #551
4641            // (security-relevant).
4642            if exec.is_readonly_param(&name) {
4643                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
4644                return;
4645            }
4646            // Inline-assignment frame tracking (`X=foo cmd` reverts on
4647            // command return).
4648            if !exec.inline_env_stack.is_empty() {
4649                let prev_var = crate::ported::params::getsparam(&name);
4650                let prev_env = env::var(&name).ok();
4651                exec.inline_env_stack
4652                    .last_mut()
4653                    .unwrap()
4654                    .push((name.clone(), prev_var, prev_env));
4655                let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value)); // c:Src/params.c:5354
4656            }
4657            // Canonical setsparam handles readonly, integer math, case
4658            // fold, GSU dispatch. For Int values (arith assigns) route
4659            // through setiparam so the param is PM_INTEGER + inherits
4660            // the math layer's lastbase for display formatting. For
4661            // Float (arith assigns producing MN_FLOAT) route through
4662            // setnparam so the param is PM_FFLOAT — `(( b = a * 2 ))`
4663            // with scalar `a="3.14"` should create b as typeset -F,
4664            // not a scalar holding "6.28".
4665            if int_assign {
4666                if let Some(fusevm::Value::Int(i)) = value_raw {
4667                    crate::ported::params::setiparam(&name, i);
4668                } else {
4669                    assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
4670                }
4671            } else if float_assign {
4672                if let Some(fusevm::Value::Float(f)) = value_raw {
4673                    // ArithCompiler returns Value::Float whenever any
4674                    // operand came through Str (BUILTIN_GET_VAR yields
4675                    // Value::Str even for integer-shaped scalars). To
4676                    // avoid forcing every `(( b = a + 3 ))` to PM_FFLOAT
4677                    // when `a="5"` (integer-shaped), detect integer-
4678                    // valued floats and route through setiparam instead.
4679                    // True floats (non-integral) reach setnparam →
4680                    // PM_FFLOAT so `typeset -p b` shows `typeset -F …`.
4681                    if f.fract() == 0.0 && f.is_finite() && f.abs() <= i64::MAX as f64 {
4682                        crate::ported::params::setiparam(&name, f as i64);
4683                    } else {
4684                        let mnval = crate::ported::math::mnumber {
4685                            l: 0,
4686                            d: f,
4687                            type_: crate::ported::math::MN_FLOAT,
4688                        };
4689                        crate::ported::params::setnparam(&name, mnval);
4690                    }
4691                } else {
4692                    assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
4693                }
4694            } else {
4695                // c:Src/exec.c addvars — a NULL return from
4696                // assignsparam (e.g. nameref resolving out of scope,
4697                // createparam refusal at c:1108-1118) fails the
4698                // assignment with status 1.
4699                assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
4700            }
4701            // PM_EXPORTED / allexport env mirror — read AFTER setsparam
4702            // so the flag bit reflects any GSU setfn side-effects.
4703            let allexport = opt_state_get("allexport").unwrap_or(false);
4704            let already_exported =
4705                (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
4706            if allexport || already_exported {
4707                let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value)); // c:Src/params.c:5354
4708            }
4709            #[cfg(feature = "recorder")]
4710            if crate::recorder::is_enabled()
4711                && exec.local_scope_depth == 0
4712                && !matches!(
4713                    name.as_str(),
4714                    "PPID" | "LINENO" | "ZSH_ARGZERO" | "argv0" | "ARGC" | "?" | "_" | "RANDOM"
4715                )
4716            {
4717                let ctx = exec.recorder_ctx();
4718                let attrs = exec.recorder_attrs_for(&name);
4719                crate::recorder::emit_assign_typed(&name, &value, attrs, ctx);
4720            }
4721        });
4722        Value::Status(vm.last_status)
4723    });
4724
4725    // c:Src/exec.c execfor → Src/params.c:6362 setloopvar — the
4726    // for-loop variable bind. Distinct from BUILTIN_SET_VAR because a
4727    // PM_NAMEREF loop variable REBINDS (new refname) instead of
4728    // assigning through the resolved chain.
4729    vm.register_builtin(BUILTIN_SET_LOOP_VAR, |vm, argc| {
4730        let args = pop_args(vm, argc);
4731        let name = args.first().cloned().unwrap_or_default();
4732        let value = args.get(1).cloned().unwrap_or_default();
4733        if crate::vm_helper::is_nameref(&name) {
4734            let ef_before = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
4735            crate::ported::params::setloopvar(&name, &value); // c:6362
4736            let ef_after = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
4737            if (ef_after & crate::ported::utils::ERRFLAG_ERROR) != 0 && ef_after != ef_before {
4738                // zerr fired (read-only reference / invalid self
4739                // reference) — abort the loop, status 1 (C errflag).
4740                vm.last_status = 1;
4741                return Value::Bool(false);
4742            }
4743            return Value::Bool(true);
4744        }
4745        // Plain loop var — canonical scalar path (same shape as
4746        // BUILTIN_SET_VAR's setsparam arm).
4747        with_executor(|exec| {
4748            if exec.is_readonly_param(&name) {
4749                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
4750                return;
4751            }
4752            crate::ported::params::setsparam(&name, &value);
4753            let allexport = opt_state_get("allexport").unwrap_or(false);
4754            let already_exported =
4755                (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
4756            if allexport || already_exported {
4757                let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value)); // c:Src/params.c:5354
4758            }
4759        });
4760        Value::Bool(true)
4761    });
4762
4763    // Pre-compiled function registration — used by compile_zsh.rs's
4764    // FuncDef path. Stack: [name, base64-bincode-of-Chunk]. We decode
4765    // the base64, deserialize the Chunk, and store directly in
4766    // executor.functions_compiled. Bypasses the ShellCommand JSON layer.
4767    // BUILTIN_VAR_EXISTS — `[[ -v name ]]` set-test.
4768    // PURE PASSTHRU: build `${+name}` and route through canonical
4769    // `subst::paramsubst` which returns "1" for set / "0" for unset
4770    // (C port of `Src/subst.c::paramsubst` plus-prefix arm).
4771    // paramsubst handles all the shapes the 48-line hand-roll did:
4772    //   - bare scalar / array / assoc
4773    //   - subscripted `a[N]` / `h[key]`
4774    //   - positional params (any digit-only name)
4775    //   - env-var fallback (`HOME` set via getsparam → lookup_special_var)
4776    vm.register_builtin(BUILTIN_VAR_EXISTS, |vm, _argc| {
4777        let name = vm.pop().to_str();
4778        let body = format!("${{+{}}}", name);
4779        let mut ret_flags: i32 = 0;
4780        let (_full, _pos, nodes) =
4781            crate::ported::subst::paramsubst(&body, 0, false, 0i32, &mut ret_flags);
4782        let result = nodes.into_iter().next().unwrap_or_default();
4783        Value::Bool(result == "1")
4784    });
4785
4786    // `time { compound; ... }` — runs the sub-chunk and prints elapsed
4787    // wall-clock time. zsh's full `time` also tracks user/system CPU via
4788    // getrusage on the *child*; we approximate via wall-time only since
4789    // the sub-chunk runs in-process (no fork). Output format matches
4790    // `time simple-cmd` (already implemented elsewhere via exectime).
4791    vm.register_builtin(BUILTIN_TIME_SUBLIST, |vm, argc| {
4792        let sub_idx = vm.pop().to_int() as usize;
4793        // c:Src/jobs.c:1028-1029 — `pn->text` arg to printtime. argc==2
4794        // means the compiler also pushed a desc string (bug #66 fix);
4795        // older callers with argc==1 push only sub_idx and we synthesize
4796        // an empty desc for backward compat with cached bytecode that
4797        // predates the desc-threading patch.
4798        let desc = if argc >= 2 {
4799            vm.pop().to_str().to_string()
4800        } else {
4801            String::new()
4802        };
4803        let chunk_opt = vm.chunk.sub_chunks.get(sub_idx).cloned();
4804        let Some(chunk) = chunk_opt else {
4805            return Value::Status(0);
4806        };
4807        // c:Src/jobs.c:1968 — `getrusage(RUSAGE_CHILDREN, &ti)` before
4808        // and after the timed sublist gives accurate per-stage user/sys
4809        // CPU. Wall-time-only approximation (0.7×/0.1× fudge factors)
4810        // produced bogus user/sys columns and ignored TIMEFMT. Bug #66
4811        // in docs/BUGS.md.
4812        let ru_before: libc::rusage = unsafe {
4813            let mut r: libc::rusage = std::mem::zeroed();
4814            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
4815            r
4816        };
4817        // c:Src/jobs.c — zsh's `time` reports only for JOBS (forked
4818        // work). Builtins/brace-groups/functions run in the shell
4819        // process with no job, so `zsh -fc 'time true'` emits NOTHING.
4820        // Snapshot the fork-event counter; report only if the timed
4821        // body forked (external command or subshell).
4822        let forks_before =
4823            crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed);
4824        let start = Instant::now();
4825        crate::fusevm_disasm::maybe_print_stdout("time_sublist", &chunk);
4826        let mut sub_vm = fusevm::VM::new(chunk);
4827        register_builtins(&mut sub_vm);
4828        let _ = sub_vm.run();
4829        let status = sub_vm.last_status;
4830        let elapsed = start.elapsed();
4831        let ru_after: libc::rusage = unsafe {
4832            let mut r: libc::rusage = std::mem::zeroed();
4833            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
4834            r
4835        };
4836        // Delta children rusage = timed work's CPU.
4837        let mut delta = ru_after;
4838        let sub = |a: libc::timeval, b: libc::timeval| -> libc::timeval {
4839            let mut sec = a.tv_sec - b.tv_sec;
4840            let mut usec = a.tv_usec as i64 - b.tv_usec as i64;
4841            if usec < 0 {
4842                sec -= 1;
4843                usec += 1_000_000;
4844            }
4845            libc::timeval {
4846                tv_sec: sec,
4847                tv_usec: usec as libc::suseconds_t,
4848            }
4849        };
4850        delta.ru_utime = sub(ru_after.ru_utime, ru_before.ru_utime);
4851        delta.ru_stime = sub(ru_after.ru_stime, ru_before.ru_stime);
4852        let ti = crate::ported::zsh_h::timeinfo::from_rusage(&delta);
4853        // c:Src/jobs.c:808-809 — `s = getsparam("TIMEFMT"); s ||
4854        // DEFAULT_TIMEFMT`. Honor user-set TIMEFMT, fall back to the
4855        // canonical default.
4856        let fmt = crate::ported::params::getsparam("TIMEFMT")
4857            .unwrap_or_else(|| crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string());
4858        // c:Src/jobs.c:768 `desc` arg — for the `time { sublist }` /
4859        // `time simple-cmd` keyword path, zsh passes the sublist's
4860        // source text (used by %J via printtime). The compiler now
4861        // threads the rendered source text through as the desc operand
4862        // (compile_zsh.rs Time arm, argc==2 form). Bug #66.
4863        // c:Src/jobs.c — no job, no report (see forks_before above).
4864        let forked = crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed)
4865            != forks_before;
4866        if forked {
4867            let line = crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
4868            eprintln!("{}", line);
4869        }
4870        Value::Status(status)
4871    });
4872
4873    // `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocator.
4874    // Stack: [path, varid, op_byte]. Opens path with the appropriate mode
4875    // and stores the resulting fd number in $varid as a string. We use
4876    // a high starting fd (10+) by allocating then dup'ing — matches zsh's
4877    // "fresh fd >= 10" promise so subsequent commands don't collide on
4878    // stdin/out/err.
4879    vm.register_builtin(BUILTIN_OPEN_NAMED_FD, |vm, _argc| {
4880        use std::sync::atomic::Ordering;
4881        let op_byte = vm.pop().to_int() as u8;
4882        let varid = vm.pop().to_str();
4883        let path = vm.pop().to_str();
4884        // Param introspection used by both the open and close forms.
4885        let param_flags = crate::ported::params::paramtab()
4886            .read()
4887            .ok()
4888            .and_then(|t| t.get(&varid).map(|p| p.node.flags));
4889        let param_readonly = param_flags
4890            .map(|f| (f & crate::ported::zsh_h::PM_READONLY as i32) != 0)
4891            .unwrap_or(false);
4892        // `{varid}>&-` / `{varid}<&-` — REDIR_CLOSE with varid.
4893        // Direct port of Src/exec.c:3805-3850.
4894        if matches!(
4895            op_byte,
4896            b if b == fusevm::op::redirect_op::DUP_WRITE
4897                || b == fusevm::op::redirect_op::DUP_READ
4898        ) {
4899            let n = path.trim_start_matches('&');
4900            if n == "-" {
4901                let val = with_executor(|exec| exec.scalar(&varid)).unwrap_or_default();
4902                let fd1 = val.parse::<i32>();
4903                // c:3811-3816 — bad=1: parameter doesn't contain an fd.
4904                let Ok(fd1) = fd1 else {
4905                    crate::ported::utils::zwarn(&format!(
4906                        "parameter {} does not contain a file descriptor",
4907                        varid
4908                    ));
4909                    with_executor(|exec| exec.redirect_failed = true);
4910                    return Value::Status(1);
4911                };
4912                // c:3813-3814 — bad=2: readonly parameter.
4913                if param_readonly {
4914                    crate::ported::utils::zwarn(&format!(
4915                        "can't close file descriptor from readonly parameter {}",
4916                        varid
4917                    ));
4918                    with_executor(|exec| exec.redirect_failed = true);
4919                    return Value::Status(1);
4920                }
4921                // c:3830-3835 — bad=3: fd >= 10 marked FDT_INTERNAL.
4922                if fd1 >= 10
4923                    && fd1 <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
4924                    && crate::ported::utils::fdtable_get(fd1)
4925                        == crate::ported::zsh_h::FDT_INTERNAL
4926                {
4927                    crate::ported::utils::zwarn(&format!(
4928                        "file descriptor {} used by shell, not closed",
4929                        fd1
4930                    ));
4931                    with_executor(|exec| exec.redirect_failed = true);
4932                    return Value::Status(1);
4933                }
4934                // c:3870-3873 — close; report failure (varid form
4935                // always reports, unlike bare `N>&-`).
4936                if crate::ported::utils::zclose(fd1) < 0 {
4937                    crate::ported::utils::zwarn(&format!(
4938                        "failed to close file descriptor {}: {}",
4939                        fd1,
4940                        std::io::Error::last_os_error()
4941                    ));
4942                    return Value::Status(1);
4943                }
4944                return Value::Status(0);
4945            }
4946            // `{varid}>&N` — dup N to a fresh fd >= 10, store in varid.
4947            if let Ok(src) = n.parse::<i32>() {
4948                if param_readonly {
4949                    crate::ported::utils::zwarn(&format!(
4950                        "can't allocate file descriptor to readonly parameter {}",
4951                        varid
4952                    ));
4953                    with_executor(|exec| exec.redirect_failed = true);
4954                    return Value::Status(1);
4955                }
4956                let dup = unsafe { libc::dup(src) };
4957                if dup < 0 {
4958                    crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src));
4959                    with_executor(|exec| exec.redirect_failed = true);
4960                    return Value::Status(1);
4961                }
4962                // c:2404-2412 addfd varid arm — movefd + FDT_EXTERNAL.
4963                let final_fd = crate::ported::utils::movefd(dup);
4964                crate::ported::utils::fdtable_set(
4965                    final_fd,
4966                    crate::ported::zsh_h::FDT_EXTERNAL,
4967                );
4968                with_executor(|exec| {
4969                    exec.set_scalar(varid, final_fd.to_string());
4970                });
4971                return Value::Status(0);
4972            }
4973            return Value::Status(1);
4974        }
4975        // `{varid}<<HERE` / `{varid}<<<str` — op byte 255 (zshrs-side
4976        // contract with compile_redir; fusevm's redirect_op stops at
4977        // 8). C path: gethere/getherestr write the body to a temp
4978        // file (Src/exec.c:4660-4682), then addfd's varid arm moves
4979        // the read fd >= 10, marks FDT_EXTERNAL and sets the param
4980        // (c:2402-2412). `path` carries the BODY text here.
4981        if op_byte == 255 {
4982            if param_readonly {
4983                crate::ported::utils::zwarn(&format!(
4984                    "can't allocate file descriptor to readonly parameter {}",
4985                    varid
4986                ));
4987                with_executor(|exec| exec.redirect_failed = true);
4988                return Value::Status(1);
4989            }
4990            let body = format!("{}\n", path.trim_end_matches('\n'));
4991            let mut tmpl: Vec<u8> = b"/tmp/zshrs_hd_XXXXXX\0".to_vec();
4992            let write_fd =
4993                unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
4994            if write_fd < 0 {
4995                crate::ported::utils::zwarn(&format!(
4996                    "can't create temp file for here document: {}",
4997                    std::io::Error::last_os_error()
4998                ));
4999                return Value::Status(1);
5000            }
5001            let bytes = body.as_bytes();
5002            let mut off = 0;
5003            while off < bytes.len() {
5004                let n = unsafe {
5005                    libc::write(
5006                        write_fd,
5007                        bytes[off..].as_ptr() as *const libc::c_void,
5008                        bytes.len() - off,
5009                    )
5010                };
5011                if n <= 0 {
5012                    unsafe { libc::close(write_fd) };
5013                    return Value::Status(1);
5014                }
5015                off += n as usize;
5016            }
5017            unsafe { libc::close(write_fd) };
5018            let read_fd = unsafe {
5019                libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY)
5020            };
5021            unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
5022            if read_fd < 0 {
5023                return Value::Status(1);
5024            }
5025            let final_fd = crate::ported::utils::movefd(read_fd);
5026            if final_fd < 0 {
5027                return Value::Status(1);
5028            }
5029            crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
5030            with_executor(|exec| {
5031                exec.set_scalar(varid, final_fd.to_string());
5032            });
5033            return Value::Status(0);
5034        }
5035        // Open form: `{varid}>file` etc.
5036        // c:Src/exec.c:2177-2215 checkclobberparam — gate BEFORE open.
5037        if param_readonly {
5038            // c:2191-2197
5039            crate::ported::utils::zwarn(&format!(
5040                "can't allocate file descriptor to readonly parameter {}",
5041                varid
5042            ));
5043            with_executor(|exec| exec.redirect_failed = true);
5044            return Value::Status(1);
5045        }
5046        // c:2199-2213 — NO_CLOBBER refuses to overwrite a parameter
5047        // already holding an OPEN fd (decimal value, fdtable says
5048        // FDT_EXTERNAL).
5049        if !isset(crate::ported::zsh_h::CLOBBER)
5050            && op_byte != fusevm::op::redirect_op::CLOBBER
5051        {
5052            if let Some(val) = with_executor(|exec| exec.scalar(&varid)) {
5053                if let Ok(fd) = val.parse::<i32>() {
5054                    if fd >= 0
5055                        && fd <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
5056                        && crate::ported::utils::fdtable_get(fd)
5057                            == crate::ported::zsh_h::FDT_EXTERNAL
5058                    {
5059                        crate::ported::utils::zwarn(&format!(
5060                            "can't clobber parameter {} containing file descriptor {}",
5061                            varid, fd
5062                        ));
5063                        with_executor(|exec| exec.redirect_failed = true);
5064                        return Value::Status(1);
5065                    }
5066                }
5067            }
5068        }
5069        let path_c = match CString::new(path.clone()) {
5070            Ok(c) => c,
5071            Err(_) => return Value::Status(1),
5072        };
5073        let flags = match op_byte {
5074            b if b == fusevm::op::redirect_op::READ => libc::O_RDONLY,
5075            b if b == fusevm::op::redirect_op::WRITE || b == fusevm::op::redirect_op::CLOBBER => {
5076                libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC
5077            }
5078            b if b == fusevm::op::redirect_op::APPEND => {
5079                libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND
5080            }
5081            b if b == fusevm::op::redirect_op::READ_WRITE => libc::O_RDWR | libc::O_CREAT,
5082            _ => return Value::Status(1),
5083        };
5084        let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o666) };
5085        if fd < 0 {
5086            return Value::Status(1);
5087        }
5088        // c:2404-2412 addfd varid arm — `fd1 = movefd(fd2);
5089        // fdtable[fd1] = FDT_EXTERNAL; setiparam(varid, fd1);`.
5090        // FDT_EXTERNAL (not INTERNAL): the user owns this fd — the
5091        // NO_CLOBBER gate above and `{fd}>&-` close both key off it.
5092        let final_fd = crate::ported::utils::movefd(fd);
5093        if final_fd < 0 {
5094            crate::ported::utils::zerr(&format!(
5095                "cannot move fd {}: {}",
5096                fd,
5097                std::io::Error::last_os_error()
5098            ));
5099            return Value::Status(1);
5100        }
5101        crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
5102        let _ = Ordering::Relaxed;
5103        with_executor(|exec| {
5104            exec.set_scalar(varid, final_fd.to_string());
5105        });
5106        Value::Status(0)
5107    });
5108
5109    // BUILTIN_SET_TRY_BLOCK_ERROR — capture the try-block's exit
5110    // status into `__zshrs_try_block_saved_status` (a scratch
5111    // scalar) so the always-arm can later restore it. Also set
5112    // `TRY_BLOCK_ERROR` per zsh semantics: it stays at -1 unless
5113    // the try-block fired an explicit error (errflag), per
5114    // c:Src/exec.c execlist's WC_TRYBLOCK arm.
5115    vm.register_builtin(BUILTIN_SET_TRY_BLOCK_ERROR, |vm, _argc| {
5116        use std::sync::atomic::Ordering;
5117        let vm_status = vm.last_status;
5118        let errored = (crate::ported::utils::errflag.load(Ordering::Relaxed)
5119            & crate::ported::zsh_h::ERRFLAG_ERROR)
5120            != 0;
5121        // c:Src/exec.c WC_TRYBLOCK — the always-arm runs with a
5122        // clean escape state. Snapshot RETFLAG / BREAKS / CONTFLAG /
5123        // EXIT_PENDING here and clear them; RESTORE_TRY_BLOCK_STATUS
5124        // re-applies them at always-arm exit so the propagation jump
5125        // emitted by compile_zsh fires correctly.
5126        let ret_save = crate::ported::builtin::RETFLAG.swap(0, Ordering::Relaxed);
5127        let brk_save = crate::ported::builtin::BREAKS.swap(0, Ordering::Relaxed);
5128        let cont_save = crate::ported::builtin::CONTFLAG.swap(0, Ordering::Relaxed);
5129        let exit_save = crate::ported::builtin::EXIT_PENDING.swap(0, Ordering::Relaxed);
5130        TRY_ESCAPE_SAVE.with(|s| {
5131            s.borrow_mut()
5132                .push((ret_save, brk_save, cont_save, exit_save));
5133        });
5134        with_executor(|exec| {
5135            exec.set_scalar(
5136                "__zshrs_try_block_saved_status".to_string(),
5137                vm_status.to_string(),
5138            );
5139            // c:Src/loop.c:765-766 — `try_errflag = errflag &
5140            // ERRFLAG_ERROR; try_interrupt = (errflag & ERRFLAG_INT) ?
5141            // 1 : 0`. Updates the canonical loop.c globals that
5142            // `$TRY_BLOCK_ERROR` / `$TRY_BLOCK_INTERRUPT` read from
5143            // (port at src/ported/loop.rs::try_errflag etc.).
5144            // Mirror into paramtab too via set_scalar so
5145            // `${parameters[TRY_BLOCK_ERROR]}` and direct assignment
5146            // shapes stay in sync.
5147            let try_err = if errored { vm_status as i64 } else { 0 };
5148            crate::ported::r#loop::try_errflag.store(try_err, Ordering::Relaxed);
5149            crate::ported::r#loop::try_interrupt.store(0, Ordering::Relaxed);
5150            if errored {
5151                exec.set_scalar(
5152                    "TRY_BLOCK_ERROR".to_string(),
5153                    vm_status.to_string(),
5154                );
5155                // Clear errflag so always-arm runs cleanly. c:768.
5156                crate::ported::utils::errflag.fetch_and(
5157                    !crate::ported::zsh_h::ERRFLAG_ERROR,
5158                    Ordering::Relaxed,
5159                );
5160            } else {
5161                exec.set_scalar("TRY_BLOCK_ERROR".to_string(), "0".to_string());
5162            }
5163        });
5164        Value::Status(0)
5165    });
5166
5167    // BUILTIN_BEGIN_INLINE_ENV / END_INLINE_ENV — wrap an
5168    // inline-assignment-prefixed command (`X=foo Y=bar cmd`):
5169    // BEGIN pushes a save frame; SET_VAR fires for each assign and
5170    // ALSO env::set_var's the value (visible to cmd's child); the
5171    // command runs; END pops the frame and restores both shell-var
5172    // and process-env state. Direct port of zsh's addvars() →
5173    // execute_simple → restore-after-exec contract.
5174    vm.register_builtin(BUILTIN_BEGIN_INLINE_ENV, |_vm, _argc| {
5175        with_executor(|exec| {
5176            exec.inline_env_stack.push(Vec::new());
5177        });
5178        Value::Status(0)
5179    });
5180    vm.register_builtin(BUILTIN_END_INLINE_ENV, |_vm, _argc| {
5181        with_executor(|exec| {
5182            if let Some(frame) = exec.inline_env_stack.pop() {
5183                for (name, prev_var, prev_env) in frame.into_iter().rev() {
5184                    match prev_var {
5185                        Some(v) => {
5186                            exec.set_scalar(name.clone(), v);
5187                        }
5188                        None => {
5189                            exec.unset_scalar(&name);
5190                        }
5191                    }
5192                    match prev_env {
5193                        Some(v) => env::set_var(&name, &v),
5194                        None => env::remove_var(&name),
5195                    }
5196                }
5197            }
5198        });
5199        Value::Status(0)
5200    });
5201    // c:Src/exec.c:3969-3976 — bare-exec assignment epilogue: see the
5202    // const's doc block. POSIX_BUILTINS → assignments persist (pop the
5203    // frame, discard the saved state); otherwise → restore_params
5204    // (same walk as END_INLINE_ENV).
5205    vm.register_builtin(BUILTIN_EXEC_INLINE_ENV_DONE, |_vm, _argc| {
5206        let persist = isset(crate::ported::zsh_h::POSIXBUILTINS);
5207        with_executor(|exec| {
5208            if let Some(frame) = exec.inline_env_stack.pop() {
5209                if persist {
5210                    return; // c:3971 — no save/restore under POSIX_BUILTINS
5211                }
5212                for (name, prev_var, prev_env) in frame.into_iter().rev() {
5213                    match prev_var {
5214                        Some(v) => {
5215                            exec.set_scalar(name.clone(), v);
5216                        }
5217                        None => {
5218                            exec.unset_scalar(&name);
5219                        }
5220                    }
5221                    match prev_env {
5222                        Some(v) => env::set_var(&name, &v),
5223                        None => env::remove_var(&name),
5224                    }
5225                }
5226            }
5227        });
5228        Value::Status(0)
5229    });
5230
5231    // BUILTIN_RESTORE_TRY_BLOCK_STATUS — emitted at the end of an
5232    // `always` arm. Per zshmisc, the exit status of the entire
5233    // `{ try } always { finally }` construct is the try-list's
5234    // status, regardless of what happens in the always-list (the
5235    // exception is `return`/`exit` inside always, which short-
5236    // circuits and the cleanup is the only thing that runs). So
5237    // restore TRY_BLOCK_ERROR unconditionally — the always-list's
5238    // exit status is discarded for the construct.
5239    vm.register_builtin(BUILTIN_RESTORE_TRY_BLOCK_STATUS, |_vm, _argc| {
5240        use std::sync::atomic::Ordering;
5241        // c:Src/exec.c — the entire `{try} always {…}` construct's
5242        // exit status is the try-block's last status. Per zsh
5243        // semantics this carries through regardless of what the
5244        // always-arm did (including reads/writes of TRY_BLOCK_ERROR
5245        // — those affect later commands' visible value but don't
5246        // override the construct's exit). The "swallow" idiom in
5247        // C is gated on errflag state at always-arm exit, not on
5248        // TBE's literal value; full fidelity needs more state and
5249        // is deferred.
5250        let saved = with_executor(|exec| {
5251            exec.scalar("__zshrs_try_block_saved_status")
5252                .and_then(|s| s.parse::<i32>().ok())
5253                .unwrap_or(0)
5254        });
5255        // Re-apply the escape flags captured by SET_TRY_BLOCK_ERROR.
5256        // If the always-arm itself fired return/break/continue/exit,
5257        // its handler already overwrote the canonical atomics; let
5258        // those win — the always-arm's own escape always takes
5259        // priority over the try-block's deferred one.
5260        if let Some((ret, brk, cont, exit_p)) = TRY_ESCAPE_SAVE.with(|s| s.borrow_mut().pop()) {
5261            if crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) == 0 {
5262                crate::ported::builtin::RETFLAG.store(ret, Ordering::Relaxed);
5263            }
5264            if crate::ported::builtin::BREAKS.load(Ordering::Relaxed) == 0 {
5265                crate::ported::builtin::BREAKS.store(brk, Ordering::Relaxed);
5266            }
5267            if crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed) == 0 {
5268                crate::ported::builtin::CONTFLAG.store(cont, Ordering::Relaxed);
5269            }
5270            if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) == 0 {
5271                crate::ported::builtin::EXIT_PENDING.store(exit_p, Ordering::Relaxed);
5272            }
5273        }
5274        Value::Status(saved)
5275    });
5276
5277    vm.register_builtin(BUILTIN_IS_TTY, |vm, _argc| {
5278        let fd_str = vm.pop().to_str();
5279        let fd: i32 = fd_str.trim().parse().unwrap_or(-1);
5280        let is_tty = if fd < 0 {
5281            false
5282        } else {
5283            unsafe { libc::isatty(fd) != 0 }
5284        };
5285        Value::Bool(is_tty)
5286    });
5287
5288    // Set $LINENO before executing the next statement. Direct
5289    // port of zsh's `lineno` global tracking from Src/input.c
5290    // (`if ((inbufflags & INP_LINENO) || !strin) && c == '\n')
5291    // lineno++;`). The compiler emits one of these before each
5292    // top-level pipe in `compile_sublist`, carrying the line
5293    // number captured by the parser at `ZshPipe.lineno`. Pops
5294    // [n], updates `$LINENO` in the variable table.
5295    vm.register_builtin(BUILTIN_SET_LINENO, |vm, _argc| {
5296        let n = vm.pop().to_int();
5297        // c:Src/exec.c:lineno = N — direct write to the param's
5298        // u_val. Cannot go through setsparam because LINENO carries
5299        // PM_READONLY (so `(t)LINENO` reads `integer-readonly-special`
5300        // per zsh); setsparam → assignstrvalue's PM_READONLY guard
5301        // would reject the internal write. C zsh handles this via the
5302        // PM_SPECIAL GSU vtable's setfn callback which bypasses the
5303        // generic readonly check; the Rust port writes the canonical
5304        // field directly instead.
5305        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
5306            if let Some(pm) = tab.get_mut("LINENO") {
5307                pm.u_val = n;
5308                pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
5309            }
5310        }
5311        // Mirror to the file-static `lineno` (utils.c:121) that
5312        // zerrmsg reads at utils.c:301 for the `:N: msg` prefix.
5313        crate::ported::utils::set_lineno(n as i32);
5314        // Also drive lex::LEX_LINENO — zerrmsg (utils.rs:376) reads
5315        // THAT counter for the `name:N:` prefix. C zsh interleaves
5316        // parse and execute per top-level list, so its single
5317        // `lineno` global serves both; zshrs compiles the whole
5318        // script before running, leaving LEX_LINENO parked at EOF.
5319        // Without this write, every runtime zwarn/zerr reported the
5320        // script's LAST line instead of the failing statement's.
5321        crate::ported::lex::set_lineno(n as u64);
5322        // DAP hook — checks breakpoints / step mode / pause-request
5323        // for the line we just landed on. O(1) no-op when DAP is off
5324        // (single atomic load on a OnceLock). Inside `--dap` mode
5325        // this is the call that blocks the executor on a Condvar
5326        // until the IDE sends `continue`. Mirrors strykelang's
5327        // `debugger.should_stop(line) → debugger.prompt(...)` flow.
5328        crate::extensions::dap::check_line(n as u32);
5329        Value::Status(0)
5330    });
5331
5332    // Direct port of Src/prompt.c:1623 cmdpush. Token is a `CS_*`
5333    // value (zsh.h:2775-2806) emitted by compile_zsh around each
5334    // compound command (if/while/[[…]]/((…))/$(…)) and consumed by
5335    // `%_` in PS4 / prompt expansion.
5336    vm.register_builtin(BUILTIN_CMD_PUSH, |vm, _argc| {
5337        let token = vm.pop().to_int() as u8;
5338        // Route through canonical cmdpush (Src/prompt.c:1623). The
5339        // prompt expander reads from the file-static `CMDSTACK` at
5340        // `prompt.rs:2006`, not `exec.cmd_stack` — without this,
5341        // `%_` in PS4 saw an empty stack during xtrace.
5342        if (token as i32) < crate::ported::zsh_h::CS_COUNT {
5343            crate::ported::prompt::cmdpush(token);
5344        }
5345        Value::Status(0)
5346    });
5347
5348    // Direct port of Src/prompt.c:1631 cmdpop.
5349    vm.register_builtin(BUILTIN_CMD_POP, |_vm, _argc| {
5350        crate::ported::prompt::cmdpop();
5351        Value::Status(0)
5352    });
5353
5354    vm.register_builtin(BUILTIN_OPTION_SET, |vm, _argc| {
5355        let name = vm.pop().to_str();
5356        // Direct port of `optison(char *name, char *s)` at Src/cond.c:502 — `[[ -o NAME ]]`
5357        // reads through the same `opts[]` array that `setopt NAME`
5358        // writes via `dosetopt`. Earlier code read a duplicate Executor
5359        // HashMap which never saw `bin_setopt`'s writes (those land in
5360        // `OPTS_LIVE` via `opt_state_set`). Routing through the canonical
5361        // C port restores the single-store invariant: one `opts[]`,
5362        // shared between setopt/unsetopt and `[[ -o ]]`.
5363        let r = crate::ported::cond::optison("test", &name); // c:cond.c:502
5364        match r {
5365            0 => Value::Bool(true),  // c:cond.c:520 set
5366            1 => Value::Bool(false), // c:cond.c:518/520 unset
5367            _ => {
5368                // c:cond.c:514 — unknown option: zwarnnam emitted by
5369                // optison itself when POSIXBUILTINS is unset; mirror to
5370                // stderr here for parity with the earlier diagnostic.
5371                eprintln!("{}:1: no such option: {}", shname(), name);
5372                Value::Bool(false)
5373            }
5374        }
5375    });
5376    // Tri-state `-o` for compile_cond's direct status path. Returns
5377    // 0 / 1 / 3 as a Value::Int that compile_cond consumes via
5378    // Op::SetStatus. Mirrors zsh's `[[ -o invalid ]]` returning $?=3.
5379    vm.register_builtin(BUILTIN_OPTION_CHECK_TRISTATE, |vm, _argc| {
5380        let name = vm.pop().to_str();
5381        let r = crate::ported::cond::optison("test", &name); // c:cond.c:502
5382                                                             // optison itself prints the diagnostic via zwarnnam when r=3
5383                                                             // and POSIXBUILTINS is unset (the canonical path). Don't
5384                                                             // double-emit here. r is already 0/1/3.
5385        Value::Int(r as i64)
5386    });
5387
5388    // BUILTIN_PARAM_FILTER — `${var:#pat}` / `${var:|name}` etc.
5389    // PURE PASSTHRU: rebuild `${name:#pat}` and route to paramsubst.
5390    vm.register_builtin(BUILTIN_PARAM_FILTER, |vm, _argc| {
5391        let pattern = vm.pop().to_str();
5392        let name = vm.pop().to_str();
5393        let body = format!("${{{}:#{}}}", name, pattern);
5394        paramsubst_to_value(&body)
5395    });
5396
5397    // `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()`
5398    // — subscripted-array assign with array RHS. Stack pushed by
5399    // compile_assign as: [elem0, elem1, …, elemN-1, name, key].
5400    vm.register_builtin(BUILTIN_SET_SUBSCRIPT_RANGE, |vm, argc| {
5401        let n = argc as usize;
5402        let mut popped: Vec<Value> = Vec::with_capacity(n);
5403        for _ in 0..n {
5404            popped.push(vm.pop());
5405        }
5406        popped.reverse();
5407        if popped.len() < 3 {
5408            return Value::Status(1);
5409        }
5410        // c:Src/params.c:3511-3526 — trailing append flag. The ARRAY
5411        // subscript path (`a[N]+=(v)` / `a[lo,hi]+=(v)`) sets it so the
5412        // AUGMENT transform below collapses the range to an empty range
5413        // after the slice end and inserts ONLY the new value. The scalar
5414        // path pre-concats the old slice (ARRAY_INDEX+Concat) and passes
5415        // 0, so it keeps plain-replace semantics.
5416        let append = popped.pop().map_or(false, |v| v.to_str() == "1");
5417        let key = popped.pop().unwrap().to_str();
5418        let name = popped.pop().unwrap().to_str();
5419        let mut values: Vec<String> = Vec::new();
5420        for v in popped {
5421            match v {
5422                Value::Array(items) => {
5423                    for it in items {
5424                        values.push(it.to_str());
5425                    }
5426                }
5427                other => values.push(other.to_str()),
5428            }
5429        }
5430        with_executor(|exec| {
5431            // Parse subscript: slice `lo,hi` or single index `i`.
5432            // setarrvalue (Src/params.c:2895) expects 1-based start/
5433            // end inclusive where start==end means replace one
5434            // element. Negative bounds translate to len+n+1 (1-based).
5435            //
5436            // c:Src/params.c — the END side accepts 0 as a valid value
5437            // that signals "insert BEFORE start position" (the canonical
5438            // `a[N,N-1]=val` prepend / mid-insert idiom). Bug #275 in
5439            // docs/BUGS.md: the previous Rust port clamped end up to 1,
5440            // collapsing `a[1,0]=(X Y)` into `a[1,1]=(X Y)` which
5441            // OVERWRITES position 1 instead of prepending. Provide two
5442            // translators — start_translate clamps to 1 (1-based);
5443            // end_translate keeps 0 intact so the splice in
5444            // setarrvalue (start_idx=0..end_idx=0) inserts at the front.
5445            // Bug #589: for scalars (no array), use the scalar's char
5446            // count as `len` so negative-index translation (`a[2,-1]`)
5447            // computes against the actual string length, not 0.
5448            let len = exec
5449                .array(&name)
5450                .map(|a| a.len() as i64)
5451                .or_else(|| {
5452                    crate::ported::params::paramtab()
5453                        .read()
5454                        .ok()
5455                        .and_then(|t| {
5456                            t.get(&name).and_then(|pm| {
5457                                if crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
5458                                    == crate::ported::zsh_h::PM_SCALAR
5459                                {
5460                                    pm.u_str
5461                                        .as_ref()
5462                                        .map(|s| s.chars().count() as i64)
5463                                } else {
5464                                    None
5465                                }
5466                            })
5467                        })
5468                })
5469                .unwrap_or(0);
5470            let start_translate = |raw: i64| -> i32 {
5471                if raw < 0 {
5472                    (len + raw + 1).max(1) as i32
5473                } else {
5474                    raw.max(1) as i32
5475                }
5476            };
5477            let end_translate = |raw: i64| -> i32 {
5478                if raw < 0 {
5479                    (len + raw + 1).max(0) as i32
5480                } else {
5481                    raw.max(0) as i32
5482                }
5483            };
5484            // c:Src/params.c — KSH_ARRAYS option flips array subscripts
5485            // from 1-based to 0-based. setarrvalue expects 1-based
5486            // inclusive bounds, so under KSH_ARRAYS we shift positive
5487            // inputs by +1 before translation. Negative bounds left
5488            // alone (count from end). Sibling of #610/#611/#612.
5489            // Bug #613.
5490            let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
5491            let ksh_shift = |raw: i64| -> i64 {
5492                if ksh_arrays && raw >= 0 { raw + 1 } else { raw }
5493            };
5494            // c:Src/params.c getindex — subscript bounds are MATH
5495            // expressions, not bare integers: `a[(( ${#a}+1 ))]=(x)`,
5496            // `a[n+1]=(x)`. Plain `parse::<i64>()` returned 0 on any
5497            // arithmetic subscript, which the `i == 0` guard turned into
5498            // a silent no-op (computed-index append never landed). Parse
5499            // the literal fast-path first, then fall back to mathevali
5500            // (which handles `(( ))` grouping, var refs, and operators).
5501            let eval_bound = |s: &str| -> i64 {
5502                let t = s.trim();
5503                t.parse::<i64>()
5504                    .ok()
5505                    .or_else(|| crate::ported::math::mathevali(t).ok())
5506                    .unwrap_or(0)
5507            };
5508            let (start, end) = if let Some((s_str, e_str)) = key.split_once(',') {
5509                let s = ksh_shift(eval_bound(s_str));
5510                let e = ksh_shift(eval_bound(e_str));
5511                (start_translate(s), end_translate(e))
5512            } else {
5513                let i = ksh_shift(eval_bound(&key));
5514                if i == 0 {
5515                    return;
5516                }
5517                let n = start_translate(i);
5518                (n, n)
5519            };
5520            // c:Src/params.c:3518-3520 (assignaparam ASSPM_AUGMENT) — a
5521            // subscripted `+=` to an array does NOT prepend the old slice;
5522            // it collapses the range to an EMPTY range positioned right
5523            // AFTER the slice end (`v->start = v->end--`) and splices in
5524            // ONLY the new value: `a[2]+=(d)` on (a b c) → (a b d c);
5525            // `a[2,3]+=(x)` on (1 2 3 4) → (1 2 3 x 4). In setarrvalue's
5526            // 1-based convention here that means start = end+1 (so
5527            // start_idx == end_idx == end → splice arr[end..end]).
5528            let (start, end) = if append && end > 0 {
5529                (end + 1, end)
5530            } else {
5531                (start, end)
5532            };
5533            // Route through canonical setarrvalue (Src/params.c:2895).
5534            // It handles PM_READONLY rejection, PM_HASHED slice-error,
5535            // PM_ARRAY splice + bounds clamp + padding (c:2980+).
5536            let taken = match crate::ported::params::paramtab().write() {
5537                Ok(mut tab) => tab.remove(&name),
5538                Err(_) => None,
5539            };
5540            // c:Src/exec.c:2640 / getvalue(…, 1) — a subscript assignment to
5541            // a NONEXISTENT parameter auto-creates it. getindex/fetchvalue
5542            // with the create flag calls createparam(name, PM_ARRAY) so the
5543            // splice has an array to write into; `unset u; u[1,2]=(a z)`
5544            // then yields the array (a z). Without this, setarrvalue saw
5545            // v.pm == None and silently stored nothing. The single-index
5546            // scalar-value path (SET_ASSOC/SET_ARRAY_AT) already vivifies;
5547            // this brings the range/array-value path to parity.
5548            let taken = taken.or_else(|| {
5549                crate::ported::params::createparam(
5550                    &name,
5551                    crate::ported::zsh_h::PM_ARRAY as i32,
5552                );
5553                crate::ported::params::paramtab()
5554                    .write()
5555                    .ok()
5556                    .and_then(|mut t| t.remove(&name))
5557            });
5558            // c:Src/params.c:2748+ — PM_SCALAR with subscript range
5559            // SPLICES the value into the scalar's char string. Bug
5560            // #589: zshrs's slice handler always called setarrvalue,
5561            // erroring "attempt to assign array value to non-array"
5562            // for `a=hello; a[2,3]=XYZ`. Detect PM_SCALAR and route
5563            // through assignstrvalue (which does scalar splice via
5564            // the PM_SCALAR arm at params.rs:3709-3789).
5565            let is_scalar = taken.as_ref().map_or(false, |pm| {
5566                crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
5567                    == crate::ported::zsh_h::PM_SCALAR
5568            });
5569            let mut v = crate::ported::zsh_h::value {
5570                pm: taken,
5571                arr: Vec::new(),
5572                scanflags: 0,
5573                valflags: 0,
5574                start,
5575                end,
5576            };
5577            if is_scalar {
5578                // Scalar splice — concat values, route through
5579                // assignstrvalue which dispatches by PM_TYPE.
5580                // start_translate returns 1-based positions; assignstrvalue's
5581                // PM_SCALAR arm at params.rs:3735+ expects 0-based start
5582                // (chars before start are kept) and 0-based end-exclusive
5583                // (chars from end are kept). Convert: start-=1.
5584                if v.start > 0 {
5585                    v.start -= 1;
5586                }
5587                let val: String = values.join("");
5588                crate::ported::params::assignstrvalue(Some(&mut v), Some(val), 0);
5589            } else {
5590                crate::ported::params::setarrvalue(&mut v, values);
5591            }
5592            // Write the mutated Param back to paramtab — setarrvalue
5593            // mutated v.pm in-place; the prior `tab.remove(&name)` at
5594            // the top of this handler took ownership, so we re-insert
5595            // here. setarrvalue + this re-insert IS the canonical
5596            // store (Src/params.c:2895). No further mirror needed.
5597            if let Some(pm) = v.pm {
5598                if let Ok(mut tab) = crate::ported::params::paramtab().write() {
5599                    tab.insert(name, pm);
5600                }
5601            }
5602        });
5603        Value::Status(0)
5604    });
5605
5606    // BUILTIN_CONCAT_SPLICE — word-segment concat with first/last
5607    // sticking (default zsh splice semantics for `${arr[@]}`, `$@`).
5608    vm.register_builtin(BUILTIN_CONCAT_SPLICE, |vm, _argc| {
5609        let rhs = vm.pop();
5610        let lhs = vm.pop();
5611        match (lhs, rhs) {
5612            (Value::Array(mut la), Value::Array(ra)) => {
5613                if la.is_empty() {
5614                    return Value::Array(ra);
5615                }
5616                if ra.is_empty() {
5617                    return Value::Array(la);
5618                }
5619                // Last of la merges with first of ra; rest unchanged.
5620                let last_l = la.pop().unwrap();
5621                let mut ra_iter = ra.into_iter();
5622                let first_r = ra_iter.next().unwrap();
5623                let l_s = last_l.as_str_cow();
5624                let r_s = first_r.as_str_cow();
5625                let mut merged = String::with_capacity(l_s.len() + r_s.len());
5626                merged.push_str(&l_s);
5627                merged.push_str(&r_s);
5628                la.push(Value::str(merged));
5629                la.extend(ra_iter);
5630                Value::Array(la)
5631            }
5632            (Value::Array(mut la), rhs_scalar) => {
5633                // c:Src/subst.c paramsubst splice — empty array on
5634                // either side preserves the empty (zero words),
5635                // doesn't collapse into a single-empty-string scalar.
5636                // Bug #120 in docs/BUGS.md: empty array slice
5637                // concatenated with empty literal returned
5638                // Value::str("") which surfaced as one empty arg
5639                // instead of zero args.
5640                let rhs_s = rhs_scalar.as_str_cow();
5641                if la.is_empty() {
5642                    if rhs_s.is_empty() {
5643                        return Value::Array(Vec::new());
5644                    }
5645                    return Value::str(rhs_s.to_string());
5646                }
5647                let last = la.pop().unwrap();
5648                let l_s = last.as_str_cow();
5649                let mut s = String::with_capacity(l_s.len() + rhs_s.len());
5650                s.push_str(&l_s);
5651                s.push_str(&rhs_s);
5652                la.push(Value::str(s));
5653                Value::Array(la)
5654            }
5655            (lhs_scalar, Value::Array(mut ra)) => {
5656                let lhs_s = lhs_scalar.as_str_cow();
5657                if ra.is_empty() {
5658                    // Empty-array RHS — preserve emptiness when the
5659                    // LHS is also empty (no prefix to attach). Bug
5660                    // #120 in docs/BUGS.md.
5661                    if lhs_s.is_empty() {
5662                        return Value::Array(Vec::new());
5663                    }
5664                    return Value::str(lhs_s.to_string());
5665                }
5666                let first = ra.remove(0);
5667                let r_s = first.as_str_cow();
5668                let mut s = String::with_capacity(lhs_s.len() + r_s.len());
5669                s.push_str(&lhs_s);
5670                s.push_str(&r_s);
5671                let mut out = Vec::with_capacity(ra.len() + 1);
5672                out.push(Value::str(s));
5673                out.extend(ra);
5674                Value::Array(out)
5675            }
5676            (lhs_s, rhs_s) => {
5677                let l = lhs_s.as_str_cow();
5678                let r = rhs_s.as_str_cow();
5679                let mut s = String::with_capacity(l.len() + r.len());
5680                s.push_str(&l);
5681                s.push_str(&r);
5682                Value::str(s)
5683            }
5684        }
5685    });
5686
5687    // BUILTIN_CONCAT_DISTRIBUTE — word-segment concat. With
5688    // rcexpandparam (zsh option), distributes element-wise (cartesian
5689    // product). Default mode: joins arrays with IFS first char to a
5690    // single scalar before concat, matching zsh's default unquoted
5691    // and DQ semantics. Direct port of Src/subst.c sepjoin path
5692    // (line ~1813) which gates element-vs-join on the rc_expand_param
5693    // option, defaulting to join.
5694    // BUILTIN_CONCAT_DISTRIBUTE_FORCED — same shape as
5695    // CONCAT_DISTRIBUTE, but always cartesian-distributes when one
5696    // side is Array. Used for compile-time-detected explicit
5697    // distribution forms (`${^arr}` etc.) where the source flag
5698    // overrides the rcexpandparam option default.
5699    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE_FORCED, |vm, _argc| {
5700        let rhs = vm.pop();
5701        let lhs = vm.pop();
5702        match (lhs, rhs) {
5703            (Value::Array(la), Value::Array(ra)) => {
5704                if ra.is_empty() {
5705                    return Value::Array(la);
5706                }
5707                if la.is_empty() {
5708                    return Value::Array(ra);
5709                }
5710                let mut out = Vec::with_capacity(la.len() * ra.len());
5711                for a in &la {
5712                    let a_s = a.as_str_cow();
5713                    for b in &ra {
5714                        let b_s = b.as_str_cow();
5715                        let mut s = String::with_capacity(a_s.len() + b_s.len());
5716                        s.push_str(&a_s);
5717                        s.push_str(&b_s);
5718                        out.push(Value::str(s));
5719                    }
5720                }
5721                Value::Array(out)
5722            }
5723            (Value::Array(la), rhs_scalar) => {
5724                let r = rhs_scalar.as_str_cow();
5725                let out: Vec<Value> = la
5726                    .into_iter()
5727                    .map(|a| {
5728                        let a_s = a.as_str_cow();
5729                        let mut s = String::with_capacity(a_s.len() + r.len());
5730                        s.push_str(&a_s);
5731                        s.push_str(&r);
5732                        Value::str(s)
5733                    })
5734                    .collect();
5735                Value::Array(out)
5736            }
5737            (lhs_scalar, Value::Array(ra)) => {
5738                let l = lhs_scalar.as_str_cow();
5739                let out: Vec<Value> = ra
5740                    .into_iter()
5741                    .map(|b| {
5742                        let b_s = b.as_str_cow();
5743                        let mut s = String::with_capacity(l.len() + b_s.len());
5744                        s.push_str(&l);
5745                        s.push_str(&b_s);
5746                        Value::str(s)
5747                    })
5748                    .collect();
5749                Value::Array(out)
5750            }
5751            (lhs_s, rhs_s) => {
5752                let l = lhs_s.as_str_cow();
5753                let r = rhs_s.as_str_cow();
5754                let mut s = String::with_capacity(l.len() + r.len());
5755                s.push_str(&l);
5756                s.push_str(&r);
5757                Value::str(s)
5758            }
5759        }
5760    });
5761
5762    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE, |vm, argc| {
5763        let rhs = vm.pop();
5764        let lhs = vm.pop();
5765        // c:Src/options.c — RC_EXPAND_PARAM applies to UNQUOTED
5766        // expansions only; inside DQ `"$foo${arr}bar"` joins via
5767        // $IFS[0] regardless of the option. The compiler emits
5768        // CallBuiltin(BUILTIN_CONCAT_DISTRIBUTE, 1) when the parent
5769        // word is DQ-wrapped (compile_zsh.rs parent_is_dq); the
5770        // default UNQUOTED path emits argc=2 (lhs + rhs). Treat
5771        // argc==1 as "force rc_expand off." Bug #246 in docs/BUGS.md.
5772        let dq_suppress = argc == 1;
5773        let rc_expand = !dq_suppress
5774            && with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
5775        // Helper: join an Array to scalar via sepjoin's IFS default.
5776        // c:Src/utils.c:3936-3945 — set-but-empty IFS joins with ""
5777        // (`IFS=""; echo "x$*y"` → `xabcy`); only unset /
5778        // space-leading IFS yields " ".
5779        let join_arr = |arr: Vec<Value>| -> String {
5780            let strs: Vec<String> = arr
5781                .iter()
5782                .map(|v| v.as_str_cow().into_owned())
5783                .collect();
5784            crate::ported::utils::sepjoin(&strs, None)
5785        };
5786        if !rc_expand {
5787            // Default: join any Array side to scalar, then concat.
5788            let l = match lhs {
5789                Value::Array(a) => join_arr(a),
5790                other => other.as_str_cow().into_owned(),
5791            };
5792            let r = match rhs {
5793                Value::Array(a) => join_arr(a),
5794                other => other.as_str_cow().into_owned(),
5795            };
5796            let mut s = String::with_capacity(l.len() + r.len());
5797            s.push_str(&l);
5798            s.push_str(&r);
5799            return Value::str(s);
5800        }
5801        match (lhs, rhs) {
5802            (Value::Array(la), Value::Array(ra)) => {
5803                // Cartesian product: [a + b for a in la for b in ra].
5804                let mut out = Vec::with_capacity(la.len() * ra.len().max(1));
5805                if ra.is_empty() {
5806                    return Value::Array(la);
5807                }
5808                if la.is_empty() {
5809                    return Value::Array(ra);
5810                }
5811                for a in &la {
5812                    let a_s = a.as_str_cow();
5813                    for b in &ra {
5814                        let b_s = b.as_str_cow();
5815                        let mut s = String::with_capacity(a_s.len() + b_s.len());
5816                        s.push_str(&a_s);
5817                        s.push_str(&b_s);
5818                        out.push(Value::str(s));
5819                    }
5820                }
5821                Value::Array(out)
5822            }
5823            (Value::Array(la), rhs_scalar) => {
5824                let r = rhs_scalar.as_str_cow();
5825                let out: Vec<Value> = la
5826                    .into_iter()
5827                    .map(|a| {
5828                        let a_s = a.as_str_cow();
5829                        let mut s = String::with_capacity(a_s.len() + r.len());
5830                        s.push_str(&a_s);
5831                        s.push_str(&r);
5832                        Value::str(s)
5833                    })
5834                    .collect();
5835                Value::Array(out)
5836            }
5837            (lhs_scalar, Value::Array(ra)) => {
5838                let l = lhs_scalar.as_str_cow();
5839                let out: Vec<Value> = ra
5840                    .into_iter()
5841                    .map(|b| {
5842                        let b_s = b.as_str_cow();
5843                        let mut s = String::with_capacity(l.len() + b_s.len());
5844                        s.push_str(&l);
5845                        s.push_str(&b_s);
5846                        Value::str(s)
5847                    })
5848                    .collect();
5849                Value::Array(out)
5850            }
5851            (lhs_s, rhs_s) => {
5852                // Fast path: both scalar → identical to Op::Concat.
5853                let l = lhs_s.as_str_cow();
5854                let r = rhs_s.as_str_cow();
5855                let mut s = String::with_capacity(l.len() + r.len());
5856                s.push_str(&l);
5857                s.push_str(&r);
5858                Value::str(s)
5859            }
5860        }
5861    });
5862
5863    // `[[ a -ef b ]]` — same-inode test. Resolves both paths via fs::metadata
5864    // (follows symlinks the way zsh's -ef does) and compares (dev, inode).
5865    // Returns false on any I/O error (path missing, permission denied, etc.).
5866    vm.register_builtin(BUILTIN_SAME_FILE, |vm, _argc| {
5867        let b = vm.pop().to_str();
5868        let a = vm.pop().to_str();
5869        let same = match (fs::metadata(&a), fs::metadata(&b)) {
5870            (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
5871            _ => false,
5872        };
5873        Value::Bool(same)
5874    });
5875
5876    // `[[ -c path ]]` — character device.
5877    vm.register_builtin(BUILTIN_IS_CHARDEV, |vm, _argc| {
5878        let path = vm.pop().to_str();
5879        let result = fs::metadata(&path)
5880            .map(|m| m.file_type().is_char_device())
5881            .unwrap_or(false);
5882        Value::Bool(result)
5883    });
5884    // `[[ -b path ]]` — block device.
5885    vm.register_builtin(BUILTIN_IS_BLOCKDEV, |vm, _argc| {
5886        let path = vm.pop().to_str();
5887        let result = fs::metadata(&path)
5888            .map(|m| m.file_type().is_block_device())
5889            .unwrap_or(false);
5890        Value::Bool(result)
5891    });
5892    // `[[ -p path ]]` — FIFO (named pipe).
5893    vm.register_builtin(BUILTIN_IS_FIFO, |vm, _argc| {
5894        let path = vm.pop().to_str();
5895        let result = fs::metadata(&path)
5896            .map(|m| m.file_type().is_fifo())
5897            .unwrap_or(false);
5898        Value::Bool(result)
5899    });
5900    // `[[ -S path ]]` — socket.
5901    vm.register_builtin(BUILTIN_IS_SOCKET, |vm, _argc| {
5902        let path = vm.pop().to_str();
5903        let result = fs::symlink_metadata(&path)
5904            .map(|m| m.file_type().is_socket())
5905            .unwrap_or(false);
5906        Value::Bool(result)
5907    });
5908
5909    // `[[ -k path ]]` / `-u` / `-g` — sticky / setuid / setgid bit.
5910    vm.register_builtin(BUILTIN_HAS_STICKY, |vm, _argc| {
5911        let path = vm.pop().to_str();
5912        let result = fs::metadata(&path)
5913            .map(|m| m.permissions().mode() & libc::S_ISVTX as u32 != 0)
5914            .unwrap_or(false);
5915        Value::Bool(result)
5916    });
5917    vm.register_builtin(BUILTIN_HAS_SETUID, |vm, _argc| {
5918        let path = vm.pop().to_str();
5919        let result = fs::metadata(&path)
5920            .map(|m| m.permissions().mode() & libc::S_ISUID as u32 != 0)
5921            .unwrap_or(false);
5922        Value::Bool(result)
5923    });
5924    vm.register_builtin(BUILTIN_HAS_SETGID, |vm, _argc| {
5925        let path = vm.pop().to_str();
5926        let result = fs::metadata(&path)
5927            .map(|m| m.permissions().mode() & libc::S_ISGID as u32 != 0)
5928            .unwrap_or(false);
5929        Value::Bool(result)
5930    });
5931    vm.register_builtin(BUILTIN_OWNED_BY_USER, |vm, _argc| {
5932        let path = vm.pop().to_str();
5933        let euid = unsafe { libc::geteuid() };
5934        let result = fs::metadata(&path)
5935            .map(|m| m.uid() == euid)
5936            .unwrap_or(false);
5937        Value::Bool(result)
5938    });
5939    vm.register_builtin(BUILTIN_OWNED_BY_GROUP, |vm, _argc| {
5940        let path = vm.pop().to_str();
5941        let egid = unsafe { libc::getegid() };
5942        let result = fs::metadata(&path)
5943            .map(|m| m.gid() == egid)
5944            .unwrap_or(false);
5945        Value::Bool(result)
5946    });
5947
5948    // `[[ -N path ]]` — file's access time is NOT newer than its
5949    // modification time (zsh man: "true if file exists and its
5950    // access time is not newer than its modification time"). Used
5951    // by zsh's mailbox-watching code. The semantic is `atime <=
5952    // mtime` (equivalent to `mtime >= atime`) — equal counts as
5953    // true, which a strict `mtime > atime` check missed for newly
5954    // created files where both stamps are identical.
5955    vm.register_builtin(BUILTIN_FILE_MODIFIED_SINCE_ACCESS, |vm, _argc| {
5956        let path = vm.pop().to_str();
5957        let result = fs::metadata(&path)
5958            .map(|m| m.atime() <= m.mtime())
5959            .unwrap_or(false);
5960        Value::Bool(result)
5961    });
5962
5963    // `[[ a -nt b ]]` — true if `a`'s mtime is strictly later than `b`'s.
5964    // BOTH files must exist; if either is missing the result is false.
5965    // (Earlier behavior was bash's "missing == infinitely-old"; zsh
5966    // strictly requires both files to exist.)
5967    vm.register_builtin(BUILTIN_FILE_NEWER, |vm, _argc| {
5968        let b = vm.pop().to_str();
5969        let a = vm.pop().to_str();
5970        // Use SystemTime modified() for nanosecond precision —
5971        // MetadataExt::mtime() returns seconds only, so two files
5972        // touched within the same second compared equal even when
5973        // 500ms apart. zsh tracks ns and uses `>=` for ties (touching
5974        // a then b in quick succession should still report b newer).
5975        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
5976        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
5977        let result = match (ta, tb) {
5978            (Some(ta), Some(tb)) => ta > tb,
5979            _ => false,
5980        };
5981        Value::Bool(result)
5982    });
5983
5984    // `[[ a -ot b ]]` — mirror of -nt. Same both-must-exist contract.
5985    vm.register_builtin(BUILTIN_FILE_OLDER, |vm, _argc| {
5986        let b = vm.pop().to_str();
5987        let a = vm.pop().to_str();
5988        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
5989        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
5990        let result = match (ta, tb) {
5991            (Some(ta), Some(tb)) => ta < tb,
5992            _ => false,
5993        };
5994        Value::Bool(result)
5995    });
5996
5997    // `set -e` / `setopt errexit` post-command check. Compiler emits
5998    // this after each top-level command's SetStatus (skipped inside
5999    // conditionals/pipelines/&&||/`!`). If errexit is on AND the last
6000    // command exited non-zero AND it's not a `return` from a function,
6001    // exit the shell with that status.
6002    // `set -x` / `setopt xtrace` — print each command before it runs.
6003    // The compiler emits this BEFORE the actual builtin/external call
6004    // with the command's literal text as a single string arg. We
6005    // print to stderr if xtrace is on. Honors `$PS4` (default `+ `).
6006    //
6007    // ── XTRACE flow control ────────────────────────────────────────
6008    // Mirror of C zsh's `doneps4` flag in execcmd_exec (Src/exec.c).
6009    // When an assignment trace fires (XTRACE_ASSIGN), it emits PS4
6010    // and sets this flag so the subsequent XTRACE_ARGS skips its own
6011    // PS4 emission — the assignment + command end up on the SAME
6012    // line: `<PS4>a=1 echo hello\n`. XTRACE_ARGS / XTRACE_NEWLINE
6013    // reset the flag after emitting the trailing `\n`.
6014    vm.register_builtin(BUILTIN_XTRACE_IS_ON, |_vm, _argc| {
6015        // Push live xtrace state. Caller pairs this with JumpIfFalse
6016        // to skip the trace-string-building block when xtrace is off,
6017        // avoiding side-effectful operand re-evaluation. Bug #159 in
6018        // docs/BUGS.md.
6019        let on = with_executor(|_| opt_state_get("xtrace").unwrap_or(false));
6020        Value::Int(if on { 1 } else { 0 })
6021    });
6022
6023    vm.register_builtin(BUILTIN_XTRACE_LINE, |vm, _argc| {
6024        let cmd_text = vm.pop().to_str();
6025        // Sync exec.last_status with the live vm.last_status BEFORE
6026        // the next command runs. Direct port of the zsh exec.c
6027        // contract — `$?` reads the exit status of the *most recent*
6028        // command. XTRACE_LINE is emitted by the compiler BEFORE
6029        // every simple command, so it's the natural sync point.
6030        let live = vm.last_status;
6031        with_executor(|exec| {
6032            exec.set_last_status(live);
6033        });
6034        // C zsh emits xtrace for `(( … ))` / `[[ … ]]` / `case` /
6035        // `if/while/until/for/repeat` head expressions via
6036        // `printprompt4(); fprintf(xtrerr, "%s\n", expr)` at
6037        // Src/exec.c:5240 (math), c:5286 (cond), c:4117 (for), etc.
6038        // The compiler emits BUILTIN_XTRACE_LINE only at those
6039        // construct boundaries (compile_arith / compile_cond /
6040        // compile_if / compile_while / compile_for / compile_case);
6041        // simple commands route to BUILTIN_XTRACE_ARGS instead. So
6042        // this handler always emits when xtrace is on — no prefix-
6043        // string heuristic.
6044        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
6045        if on {
6046            let already = XTRACE_DONE_PS4.with(|f| f.get());
6047            if !already {
6048                printprompt4();
6049            }
6050            eprintln!("{}", cmd_text);
6051            XTRACE_DONE_PS4.with(|f| f.set(false));
6052        }
6053        Value::Status(0)
6054    });
6055
6056    // Like XTRACE_LINE but reads the top `argc - 1` values from the
6057    // VM stack WITHOUT consuming them (peek), then pops a prefix
6058    // string at the top. Joins prefix + peeked args with spaces using
6059    // zsh's quotedzputs-equivalent quoting. Direct port of
6060    // Src/exec.c:2055-2066 — emit AFTER expansion, with each arg
6061    // shell-quoted, so `for i in a b; echo for $i` traces as
6062    // `echo for a` / `echo for b`, not `echo for $i`.
6063    //
6064    // Stack contract on entry: [arg1, arg2, ..., argN, prefix].
6065    // Pops prefix; peeks argN..arg1 below. argc = N + 1.
6066    vm.register_builtin(BUILTIN_XTRACE_ARGS, |vm, argc| {
6067        let prefix = vm.pop().to_str();
6068        let live = vm.last_status;
6069        with_executor(|exec| {
6070            exec.set_last_status(live);
6071        });
6072        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
6073        if on {
6074            let n_args = argc.saturating_sub(1) as usize;
6075            let len = vm.stack.len();
6076            // c:Src/exec.c:2055 — argv is the POST-expansion word
6077            // list, so an arg that expanded to multiple words splats
6078            // into multiple trace tokens AND an arg that expanded to
6079            // zero words (empty unquoted `${UNSET}`) emits nothing.
6080            // pop_args (line 6243) already does this splat for the
6081            // real handler; mirror the same Array → splat / empty →
6082            // drop logic here so xtrace renders `echo ${UNSET}` as
6083            // `echo` (zsh) instead of `echo ''` (the previous
6084            // single-arg stringify path returned "" and then
6085            // quotedzputs wrapped it in `''`).
6086            let arg_strs: Vec<String> = if n_args > 0 && len >= n_args {
6087                let mut out = Vec::new();
6088                for v in &vm.stack[len - n_args..] {
6089                    match v {
6090                        Value::Array(items) => {
6091                            for item in items {
6092                                out.push(quotedzputs(&item.to_str()));
6093                            }
6094                        }
6095                        other => out.push(quotedzputs(&other.to_str())),
6096                    }
6097                }
6098                out
6099            } else {
6100                Vec::new()
6101            };
6102            // Builtins dispatch through `execbuiltin` (Src/builtin.c:442)
6103            // which emits its own PS4 + name + args xtrace. To avoid
6104            // double-emission, skip our emission here when the first
6105            // arg is a known builtin with a registered HandlerFunc —
6106            // those go through execbuiltin and will trace themselves.
6107            // Externals + builtins-not-yet-routed-through-execbuiltin
6108            // keep our emission as a stand-in.
6109            let goes_through_execbuiltin = crate::ported::builtin::BUILTINS
6110                .iter()
6111                .any(|b| b.node.nam == prefix && b.handlerfunc.is_some());
6112            if !goes_through_execbuiltin {
6113                let line = if arg_strs.is_empty() {
6114                    prefix
6115                } else {
6116                    format!("{} {}", prefix, arg_strs.join(" "))
6117                };
6118                // Mirrors Src/exec.c:2055 xtrace emission. C does:
6119                //   if (!doneps4) printprompt4();
6120                //   ... emit args + spaces ...
6121                //   fputc('\n', xtrerr); fflush(xtrerr);
6122                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
6123                if !already_ps4 {
6124                    printprompt4();
6125                }
6126                eprintln!("{}", line);
6127            }
6128            XTRACE_DONE_PS4.with(|f| f.set(false));
6129        }
6130        Value::Status(0)
6131    });
6132
6133    // BUILTIN_XTRACE_ASSIGN — direct port of the per-assignment
6134    // trace block at Src/exec.c:2517-2582. C body excerpt:
6135    //   xtr = isset(XTRACE);
6136    //   if (xtr) { printprompt4(); doneps4 = 1; }
6137    //   while (assign) {
6138    //       if (xtr) fprintf(xtrerr, "%s+=" or "%s=", name);
6139    //       ... eval value into `val` ...
6140    //       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
6141    //       ...
6142    //   }
6143    //
6144    // Stack on entry: [..., name, value]. PEEKS both (they're left
6145    // on stack for SET_VAR to pop). Emits `name=<quoted-val> ` with
6146    // no newline; trailing `\n` comes from XTRACE_ARGS (cmd path)
6147    // or XTRACE_NEWLINE (assignment-only path).
6148    vm.register_builtin(BUILTIN_XTRACE_ASSIGN, |vm, _argc| {
6149        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
6150        if on {
6151            // PEEK [..., name, value] — argc==2 by contract.
6152            let len = vm.stack.len();
6153            if len >= 2 {
6154                let name = vm.stack[len - 2].to_str();
6155                let value = vm.stack[len - 1].to_str();
6156                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
6157                if !already_ps4 {
6158                    printprompt4();
6159                    XTRACE_DONE_PS4.with(|f| f.set(true));
6160                }
6161                // C: `fprintf(xtrerr, "%s=", name)` then `quotedzputs
6162                // (val); fputc(' ', xtrerr);`. Emit no newline.
6163                eprint!("{}={} ", name, quotedzputs(&value));
6164            }
6165        }
6166        Value::Status(0)
6167    });
6168
6169    // BUILTIN_XTRACE_NEWLINE — emit trailing `\n` + flush iff a
6170    // prior XTRACE_ASSIGN this line already emitted PS4. Mirrors
6171    // C's `fputc('\n', xtrerr); fflush(xtrerr);` at exec.c:3398
6172    // (the assignment-only path through execcmd_exec).
6173    vm.register_builtin(BUILTIN_XTRACE_NEWLINE, |_vm, _argc| {
6174        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
6175        if on {
6176            let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
6177            if already_ps4 {
6178                eprintln!();
6179                XTRACE_DONE_PS4.with(|f| f.set(false));
6180            }
6181        }
6182        Value::Status(0)
6183    });
6184
6185    // c:Src/exec.c WC_TRYBLOCK — post-always re-jump probes. Each
6186    // returns 1 + consumes the atomic when the corresponding
6187    // escape flag is set; the try-block compile pairs each with
6188    // a JumpIfFalse + Jump → outer scope's return / break /
6189    // continue patches.
6190    vm.register_builtin(BUILTIN_RETFLAG_CHECK, |_vm, _argc| {
6191        use std::sync::atomic::Ordering;
6192        let r = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
6193        if r != 0 {
6194            // Don't clear here — doshfunc owns the clear at c:6047
6195            // when the function unwinds. Leaving it set propagates
6196            // through nested `eval`/`source` callers correctly.
6197            Value::Int(1)
6198        } else {
6199            Value::Int(0)
6200        }
6201    });
6202    vm.register_builtin(BUILTIN_BREAKS_CHECK, |_vm, _argc| {
6203        use std::sync::atomic::Ordering;
6204        let b = crate::ported::builtin::BREAKS.load(Ordering::Relaxed);
6205        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
6206        // `break` sets BREAKS but NOT CONTFLAG; `continue` sets both.
6207        // Filter out the continue path here so the two checks are
6208        // mutually exclusive.
6209        if b != 0 && c == 0 {
6210            // Consume BREAKS so the outer loop's break_patches
6211            // landing doesn't double-decrement.
6212            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
6213            Value::Int(1)
6214        } else {
6215            Value::Int(0)
6216        }
6217    });
6218    vm.register_builtin(BUILTIN_CONTFLAG_CHECK, |_vm, _argc| {
6219        use std::sync::atomic::Ordering;
6220        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
6221        if c != 0 {
6222            crate::ported::builtin::CONTFLAG.store(0, Ordering::Relaxed);
6223            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
6224            Value::Int(1)
6225        } else {
6226            Value::Int(0)
6227        }
6228    });
6229    vm.register_builtin(BUILTIN_NOEXEC_CHECK, |_vm, _argc| {
6230        // c:Src/exec.c:1390 — `set -n` / `noexec` option: parse but
6231        // don't execute. Returns Int(1) when noexec is set so the
6232        // emit-side JumpIfTrue skips the statement body.
6233        if opt_state_get("noexec").unwrap_or(false) {
6234            return Value::Int(1);
6235        }
6236        // c:Src/exec.c:1390 — execlist's list-loop gate:
6237        //   `while (wc_code(code) == WC_LIST && !breaks && !retflag
6238        //          && !errflag)`
6239        // — once errflag is set, the NEXT sublist never starts, so
6240        // lastval survives untouched to the shell exit. Without this
6241        // prologue gate the follow-up statement RAN, its dispatch
6242        // saw errflag, returned 1, and SetStatus clobbered lastval —
6243        // `[[ x == [a- ]]; print rc=$?` exited 1 instead of zsh's 2
6244        // (the cond syntax error set lastval=2 per exec.c:5216-5221).
6245        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
6246            & crate::ported::zsh_h::ERRFLAG_ERROR)
6247            != 0
6248        {
6249            return Value::Int(1);
6250        }
6251        Value::Int(0)
6252    });
6253    vm.register_builtin(BUILTIN_DONETRAP_RESET, |_vm, _argc| {
6254        // c:Src/exec.c:1455 — `donetrap = 0;` at sublist start.
6255        // Reset before each top-level statement so the next
6256        // sublist's ERREXIT_CHECK fires the ZERR trap on its FIRST
6257        // non-zero command. Carries the "already fired" state
6258        // across function-call returns within the SAME outer
6259        // sublist (per C semantics — donetrap is process-global).
6260        // Bug #303 in docs/BUGS.md.
6261        crate::ported::exec::DONETRAP.store(0, std::sync::atomic::Ordering::Relaxed);
6262        Value::Status(0)
6263    });
6264
6265    // `[[ -z X ]]` / `[[ -n X ]]` — pop one Value, route through
6266    // canonical `src/ported/cond.rs::evalcond` so the actual
6267    // empty/non-empty test reuses the C-port at `cond.rs:270-271`
6268    // (`'n' => !arg.is_empty()`, `'z' => arg.is_empty()`).
6269    //
6270    // The Array→args conversion lives at the bridge because cond.rs
6271    // expects `&[&str]` (C `cond_str` signature equivalent). For
6272    // `"${arr[@]}"` in DQ context the splice yields `Value::Array`
6273    // — an empty array still expands to one implicit empty word
6274    // (per zsh's "${arr[@]}" splat preserving at least one slot
6275    // in cond context), so:
6276    //   - Array(0)   → ["-z", ""]            → evalcond → 0 (true)
6277    //   - Array(1)   → ["-z", word]          → evalcond → 0/1
6278    //   - Array(2+)  → ["-z", w1, w2, ...]   → evalcond → 2 (parse
6279    //                                          error: too many ops)
6280    //                                          → coerced to false
6281    //   - Str(s)     → ["-z", s]             → evalcond → 0/1
6282    //
6283    // Bug #185 in docs/BUGS.md.
6284    fn run_cond_str_empty(v: Value, op: &str) -> Value {
6285        let words: Vec<String> = match v {
6286            Value::Array(arr) => arr.into_iter().map(|x| x.to_str()).collect(),
6287            Value::Str(s) => vec![s.to_string()],
6288            other => vec![other.to_str()],
6289        };
6290        let mut args: Vec<&str> = vec![op];
6291        if words.is_empty() {
6292            args.push("");
6293        } else {
6294            args.extend(words.iter().map(|s| s.as_str()));
6295        }
6296        let opts: std::collections::HashMap<String, bool> =
6297            std::collections::HashMap::new();
6298        let vars: std::collections::HashMap<String, String> =
6299            std::collections::HashMap::new();
6300        // c:Src/cond.c:62-66 — `evalcond` returns 0=true, 1=false,
6301        // 2=syntax-error. Coerce error to false (observable behavior
6302        // in zsh: `[[ -z a b ]]` errors and the test as a whole
6303        // returns non-zero).
6304        // `[[ ]]` dispatch — C's `evalcond(state, NULL)` calling convention.
6305        // `None` for from_test → mathevali integer-compare coercion path.
6306        let ret = crate::ported::cond::evalcond(&args, &opts, &vars, false, None);
6307        Value::Int(if ret == 0 { 1 } else { 0 })
6308    }
6309    vm.register_builtin(BUILTIN_COND_STR_EMPTY, |vm, _argc| {
6310        let v = vm.pop();
6311        run_cond_str_empty(v, "-z")
6312    });
6313    vm.register_builtin(BUILTIN_COND_STR_NONEMPTY, |vm, _argc| {
6314        let v = vm.pop();
6315        run_cond_str_empty(v, "-n")
6316    });
6317
6318    // `exec N<<<"str"` — herestring redirect to explicit fd, applied
6319    // permanently. Direct port of `Src/exec.c:4655 getherestr` +
6320    // `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)` at c:3766-
6321    // 3780 for the nullexec=1 bare-exec-redir path. Bug #205 in
6322    // docs/BUGS.md.
6323    vm.register_builtin(BUILTIN_EXEC_HERESTR_FD, |vm, _argc| {
6324        let fd = vm.pop().to_int() as i32;
6325        let content = vm.pop().to_str();
6326        // c:4671-4672 — append `\n` for "real" herestrings (not
6327        // heredoc-derived). zshrs's bare-exec path only fires for
6328        // the `<<<` syntax (REDIR_HERESTR), so always append.
6329        let body = format!("{}\n", content);
6330        // c:4673-4679 — gettempfile → write_loop → close → reopen
6331        // read-only → unlink. Rust equivalent via tempfile crate or
6332        // explicit O_TMPFILE; use mkstemp + unlink-immediately to
6333        // mirror C exactly.
6334        use std::ffi::CString;
6335        let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
6336        let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
6337        if write_fd < 0 {
6338            crate::ported::utils::zwarn(&format!(
6339                "can't create temp file for here document: {}",
6340                std::io::Error::last_os_error()
6341            ));
6342            return Value::Status(1);
6343        }
6344        // c:4675 — write_loop(fd, t, len)
6345        let bytes = body.as_bytes();
6346        let mut off = 0;
6347        while off < bytes.len() {
6348            let n = unsafe {
6349                libc::write(
6350                    write_fd,
6351                    bytes[off..].as_ptr() as *const libc::c_void,
6352                    bytes.len() - off,
6353                )
6354            };
6355            if n <= 0 {
6356                unsafe { libc::close(write_fd) };
6357                return Value::Status(1);
6358            }
6359            off += n as usize;
6360        }
6361        unsafe { libc::close(write_fd) }; // c:4676
6362        // Path null-terminated by mkstemp; reopen for reading.
6363        let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
6364        // c:4678 — unlink immediately so the file disappears on
6365        // close, leaving only the fd reference.
6366        unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
6367        if read_fd < 0 {
6368            return Value::Status(1);
6369        }
6370        // c:3779 addfd → dup2 to target fd, close intermediate.
6371        let r = unsafe { libc::dup2(read_fd, fd) };
6372        unsafe { libc::close(read_fd) };
6373        if r < 0 {
6374            return Value::Status(1);
6375        }
6376        Value::Status(0)
6377    });
6378    // c:Src/exec.c:2418 + addfd splice — MULTIOS fan-out. Stack
6379    // layout pushed by compile_zsh's coalescing pass:
6380    //   [target_1, op_byte_1, target_2, op_byte_2, …, target_N,
6381    //    op_byte_N, fd]
6382    // argc = 2N + 1. Pops, opens every target, sets up a pipe +
6383    // splitter thread that reads pipe → writes every chunk to
6384    // every opened target, dup2's pipe-write-end onto fd. The
6385    // splitter is closed + joined by host_redirect_scope_end.
6386    // Bug #36 in docs/BUGS.md.
6387    vm.register_builtin(BUILTIN_MULTIOS_REDIRECT, |vm, argc| {
6388        if argc < 3 || argc % 2 == 0 {
6389            // Bad shape — bail.
6390            return Value::Status(1);
6391        }
6392        // Pop fd first (top of stack).
6393        let fd = vm.pop().to_int() as i32;
6394        // Then pop (op, target) pairs in reverse compile order. Keep
6395        // targets as Values — a glob-bearing target arrives as a
6396        // Value::Array of matches.
6397        let n_targets = ((argc - 1) / 2) as usize;
6398        let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_targets);
6399        for _ in 0..n_targets {
6400            let op_byte = vm.pop().to_int() as u8;
6401            let target = vm.pop();
6402            pairs.push((op_byte, target));
6403        }
6404        // Restore compile order (target_1 first).
6405        pairs.reverse();
6406
6407        // c:Src/glob.c:2195-2203 xpandredir — "Loop over matches,
6408        // duplicating the redirection for each file found": a glob
6409        // target with N matches becomes N members of the same multio
6410        // (`echo hi > *.txt` with two matches writes both files).
6411        let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
6412        for (op_byte, target) in pairs {
6413            match target {
6414                Value::Array(items) => {
6415                    for item in items {
6416                        entries.push((op_byte, item.to_str()));
6417                    }
6418                }
6419                other => entries.push((op_byte, other.to_str())),
6420            }
6421        }
6422        if entries.is_empty() {
6423            return Value::Status(1);
6424        }
6425
6426        // c:Src/exec.c:2418 — `else if (!mfds[fd1] || unset(MULTIOS))`:
6427        // with MULTIOS unset every redirect takes the REPLACE path in
6428        // script order — each target is still opened (created /
6429        // truncated) and dup2'd over the fd, so the LAST one wins and
6430        // earlier files end up empty (`unsetopt multios; print x > a
6431        // > b` leaves `a` empty, `x` in `b`). host_apply_redirect is
6432        // exactly one replace step, noclobber gate included.
6433        let multios_on = opt_state_get("multios").unwrap_or(true);
6434        if !multios_on {
6435            with_executor(|exec| {
6436                for (op_byte, target) in &entries {
6437                    exec.host_apply_redirect(fd as u8, *op_byte, target);
6438                    if exec.redirect_failed {
6439                        // c:Src/exec.c execerr — abort the remaining
6440                        // redirect list on failure.
6441                        break;
6442                    }
6443                }
6444            });
6445            return Value::Status(0);
6446        }
6447
6448        if entries.len() == 1 {
6449            // Single member after splicing — a plain replace
6450            // (c:2418 new-multio arm). Route through
6451            // host_apply_redirect so the noclobber gate, the
6452            // pipeline-output split partial, and error handling all
6453            // apply exactly as for an un-bagged redirect.
6454            let (op_byte, target) = &entries[0];
6455            with_executor(|exec| {
6456                exec.host_apply_redirect(fd as u8, *op_byte, target);
6457            });
6458            return Value::Status(0);
6459        }
6460
6461        // c:Src/exec.c:3722-3724 — when this command's stdout IS the
6462        // pipeline output, C seeds mfds[1] with the pipe BEFORE
6463        // walking the redirect list, so the pipe is the multio's
6464        // first member (`print x >&1 > f | cat` sends `x` down the
6465        // pipe TWICE: once for the seed, once for the `>&1` dup).
6466        let pipe_seed = fd == 1
6467            && with_executor(|exec| {
6468                exec.pipe_output_scope
6469                    .is_some_and(|d| d + 1 == exec.redirect_scope_stack.len())
6470            });
6471
6472        // Save current fd state for scope-end restoration — BEFORE
6473        // the first member's replace dup2 below.
6474        let saved = unsafe { libc::dup(fd) };
6475        if saved >= 0 {
6476            with_executor(|exec| {
6477                if let Some(top) = exec.redirect_scope_stack.last_mut() {
6478                    top.push((fd, saved));
6479                } else {
6480                    unsafe { libc::close(saved) };
6481                }
6482            });
6483        }
6484
6485        // Accumulate member fds in redirect order. c:Src/exec.c:
6486        // 2447-2480 addfd — the FIRST member REPLACES the fd
6487        // (c:2448-2450 `mfds[fd1]->ct=1; mfds[fd1]->fds[0]=fd1;`), so
6488        // a later numeric `>&N` self-dup resolves against the fd's
6489        // value at that point in the sequence: `print x > f >&1`
6490        // writes f TWICE; `print x >&1 > f` writes the ORIGINAL
6491        // stdout + f.
6492        let mut target_fds: Vec<i32> = Vec::with_capacity(entries.len() + 1);
6493        if pipe_seed {
6494            let p = unsafe { libc::dup(fd) };
6495            if p >= 0 {
6496                target_fds.push(p);
6497            }
6498        }
6499        let noclobber = opt_state_get("noclobber").unwrap_or(false)
6500            || !opt_state_get("clobber").unwrap_or(true);
6501        for (i, (op_byte, target)) in entries.iter().enumerate() {
6502            let open_result: std::io::Result<i32> = match *op_byte {
6503                r::DUP_WRITE | r::DUP_READ => {
6504                    // Numeric `>&N` — dup the LIVE fd N (after any
6505                    // earlier member's replace).
6506                    match target.trim_start_matches('&').parse::<i32>() {
6507                        Ok(src) => {
6508                            let d = unsafe { libc::dup(src) };
6509                            if d >= 0 {
6510                                Ok(d)
6511                            } else {
6512                                Err(std::io::Error::last_os_error())
6513                            }
6514                        }
6515                        Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
6516                    }
6517                }
6518                r::WRITE => {
6519                    // c:Src/exec.c clobber_open — noclobber applies
6520                    // to multio file targets too; failure aborts the
6521                    // remaining redirect list (execerr), so `setopt
6522                    // noclobber; touch a; print x > a > b` errors on
6523                    // `a` and never creates `b`.
6524                    let target_meta = std::fs::metadata(target).ok();
6525                    let target_is_regular_file = target_meta
6526                        .as_ref()
6527                        .map(|m| m.file_type().is_file())
6528                        .unwrap_or(false);
6529                    // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY re-uses
6530                    // an empty regular file under noclobber (same allowance
6531                    // as the single-redirect path).
6532                    let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
6533                        && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
6534                    if noclobber && target_is_regular_file && !clobber_empty_ok {
6535                        eprintln!("{}:1: file exists: {}", shname(), target);
6536                        for prev in &target_fds {
6537                            unsafe {
6538                                libc::close(*prev);
6539                            }
6540                        }
6541                        with_executor(|exec| {
6542                            exec.redirect_failed = true;
6543                        });
6544                        // Sink the upcoming command's output (mirrors
6545                        // the single-redirect noclobber arm in
6546                        // host_apply_redirect).
6547                        if let Ok(file) =
6548                            fs::OpenOptions::new().write(true).open("/dev/null")
6549                        {
6550                            let new_fd = file.into_raw_fd();
6551                            unsafe {
6552                                libc::dup2(new_fd, fd);
6553                                libc::close(new_fd);
6554                            }
6555                        }
6556                        return Value::Status(1);
6557                    }
6558                    fs::OpenOptions::new()
6559                        .write(true)
6560                        .create(true)
6561                        .truncate(true)
6562                        .open(target)
6563                        .map(|f| f.into_raw_fd())
6564                }
6565                r::APPEND => fs::OpenOptions::new()
6566                    .write(true)
6567                    .create(true)
6568                    .append(true)
6569                    .open(target)
6570                    .map(|f| f.into_raw_fd()),
6571                _ => fs::OpenOptions::new()
6572                    .write(true)
6573                    .create(true)
6574                    .truncate(true)
6575                    .open(target)
6576                    .map(|f| f.into_raw_fd()),
6577            };
6578            match open_result {
6579                Ok(tfd) => {
6580                    if i == 0 && !pipe_seed {
6581                        // c:2448-2450 — first member replaces the fd.
6582                        unsafe {
6583                            libc::dup2(tfd, fd);
6584                        }
6585                    }
6586                    target_fds.push(tfd);
6587                }
6588                Err(e) => {
6589                    let msg = match e.kind() {
6590                        std::io::ErrorKind::PermissionDenied => "permission denied",
6591                        std::io::ErrorKind::NotFound => "no such file or directory",
6592                        std::io::ErrorKind::IsADirectory => "is a directory",
6593                        _ => "redirect failed",
6594                    };
6595                    // c:Src/exec.c:3741 — `zwarn("%e: %s", errno, fname)`:
6596                    // zwarning supplies the `name:LINE:` prefix with the
6597                    // REAL current lineno (the old eprintln hardcoded `:1:`).
6598                    crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
6599                    // Close already-opened fds to avoid leaks.
6600                    for prev in &target_fds {
6601                        unsafe {
6602                            libc::close(*prev);
6603                        }
6604                    }
6605                    with_executor(|exec| {
6606                        exec.redirect_failed = true;
6607                    });
6608                    return Value::Status(1);
6609                }
6610            }
6611        }
6612
6613        // Create the splitter pipe.
6614        let (read_end, write_end) = match os_pipe::pipe() {
6615            Ok(p) => p,
6616            Err(_) => {
6617                for f in &target_fds {
6618                    unsafe {
6619                        libc::close(*f);
6620                    }
6621                }
6622                return Value::Status(1);
6623            }
6624        };
6625        let pipe_write_raw = AsRawFd::as_raw_fd(&write_end);
6626        // Spawn the splitter thread: read pipe → write every chunk
6627        // to every target fd. Each write inside the thread uses
6628        // libc::write directly on the raw fd (no Rust File ownership
6629        // so the splitter can close after EOF without racing main).
6630        let target_fds_for_thread = target_fds.clone();
6631        let handle = std::thread::spawn(move || {
6632            let mut r = read_end;
6633            let mut buf = [0u8; 8192];
6634            loop {
6635                match std::io::Read::read(&mut r, &mut buf) {
6636                    Ok(0) => break,
6637                    Ok(n) => {
6638                        for &tfd in &target_fds_for_thread {
6639                            let mut off = 0;
6640                            while off < n {
6641                                let w = unsafe {
6642                                    libc::write(
6643                                        tfd,
6644                                        buf[off..n].as_ptr() as *const libc::c_void,
6645                                        n - off,
6646                                    )
6647                                };
6648                                if w <= 0 {
6649                                    break;
6650                                }
6651                                off += w as usize;
6652                            }
6653                        }
6654                    }
6655                    Err(_) => break,
6656                }
6657            }
6658            // Close every target so file contents flush.
6659            for tfd in target_fds_for_thread {
6660                unsafe {
6661                    libc::close(tfd);
6662                }
6663            }
6664        });
6665
6666        // Dup the pipe write-end onto the target fd; close the
6667        // original write_end so EOF arrives when host_redirect_scope_end
6668        // closes our tracked pipe_write_fd.
6669        let write_dup = unsafe { libc::dup(pipe_write_raw) };
6670        drop(write_end);
6671        if write_dup < 0 {
6672            return Value::Status(1);
6673        }
6674        unsafe {
6675            libc::dup2(write_dup, fd);
6676            libc::close(write_dup);
6677        }
6678        // Track the running splitter so scope-end can drain + join.
6679        // The "write_fd" we store is the user-visible fd (e.g. 1).
6680        // Closing that fd at scope-end isn't quite right; we need a
6681        // way to send EOF. Solution: track the write_dup we just
6682        // closed; instead keep a second dup for the close-on-end.
6683        let close_on_end = unsafe { libc::dup(fd) };
6684        with_executor(|exec| {
6685            if let Some(top) = exec.multios_scope_stack.last_mut() {
6686                top.push((close_on_end, handle));
6687            } else {
6688                // No scope — leak the dup; thread will keep running
6689                // until process exit. Should not happen because
6690                // host_redirect_scope_begin pushed a frame.
6691                unsafe { libc::close(close_on_end) };
6692            }
6693        });
6694        Value::Status(0)
6695    });
6696    // c:Src/exec.c:2418 input-arm — MULTIOS read fan-in. Stack
6697    // layout pushed by compile_zsh (mirrors the write side):
6698    //   [source_1, op_1, source_2, op_2, …, source_N, op_N, fd]
6699    // argc = 2N + 1; op distinguishes file opens (READ) from numeric
6700    // `<&N` dups (DUP_READ); a glob source arrives as Value::Array
6701    // and splices into one member per match (c:Src/glob.c:2195-2203).
6702    // Opens every source, sets up a pipe + producer thread that
6703    // reads each source in order and writes to the pipe write-end,
6704    // then closes its write-end so the consumer gets EOF. dup2 the
6705    // pipe read-end onto fd. Bug #36 input side in docs/BUGS.md.
6706    vm.register_builtin(BUILTIN_MULTIOS_READ, |vm, argc| {
6707        if argc < 3 || argc % 2 == 0 {
6708            return Value::Status(1);
6709        }
6710        let fd = vm.pop().to_int() as i32;
6711        let n_sources = ((argc - 1) / 2) as usize;
6712        let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_sources);
6713        for _ in 0..n_sources {
6714            let op_byte = vm.pop().to_int() as u8;
6715            let source = vm.pop();
6716            pairs.push((op_byte, source));
6717        }
6718        pairs.reverse();
6719
6720        // Splice glob match arrays (c:Src/glob.c:2195-2203).
6721        let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
6722        for (op_byte, source) in pairs {
6723            match source {
6724                Value::Array(items) => {
6725                    for item in items {
6726                        entries.push((op_byte, item.to_str()));
6727                    }
6728                }
6729                other => entries.push((op_byte, other.to_str())),
6730            }
6731        }
6732        if entries.is_empty() {
6733            return Value::Status(1);
6734        }
6735
6736        // c:Src/exec.c:2418 — `unset(MULTIOS)`: sequential replace,
6737        // last source wins (`unsetopt multios; cat < a < b` reads
6738        // only b; a is still opened — and errors still surface).
6739        let multios_on = opt_state_get("multios").unwrap_or(true);
6740        if !multios_on {
6741            with_executor(|exec| {
6742                for (op_byte, source) in &entries {
6743                    exec.host_apply_redirect(fd as u8, *op_byte, source);
6744                    if exec.redirect_failed {
6745                        break;
6746                    }
6747                }
6748            });
6749            return Value::Status(0);
6750        }
6751
6752        if entries.len() == 1 {
6753            // Single member after splicing — plain replace.
6754            let (op_byte, source) = &entries[0];
6755            with_executor(|exec| {
6756                exec.host_apply_redirect(fd as u8, *op_byte, source);
6757            });
6758            return Value::Status(0);
6759        }
6760
6761        // Save current fd state for scope-end restoration — BEFORE
6762        // the first member's replace dup2 below.
6763        let saved = unsafe { libc::dup(fd) };
6764        if saved >= 0 {
6765            with_executor(|exec| {
6766                if let Some(top) = exec.redirect_scope_stack.last_mut() {
6767                    top.push((fd, saved));
6768                } else {
6769                    unsafe { libc::close(saved) };
6770                }
6771            });
6772        }
6773
6774        // Open every source in redirect order; numeric `<&N` dups
6775        // resolve against the LIVE fd table. First member replaces
6776        // the fd (c:2448-2450) so later self-dups see it.
6777        let mut source_fds: Vec<i32> = Vec::with_capacity(entries.len());
6778        for (i, (op_byte, source)) in entries.iter().enumerate() {
6779            let open_result: std::io::Result<i32> = match *op_byte {
6780                r::DUP_READ | r::DUP_WRITE => {
6781                    match source.trim_start_matches('&').parse::<i32>() {
6782                        Ok(src) => {
6783                            let d = unsafe { libc::dup(src) };
6784                            if d >= 0 {
6785                                Ok(d)
6786                            } else {
6787                                Err(std::io::Error::last_os_error())
6788                            }
6789                        }
6790                        Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
6791                    }
6792                }
6793                _ => fs::File::open(source).map(|f| f.into_raw_fd()),
6794            };
6795            match open_result {
6796                Ok(tfd) => {
6797                    if i == 0 {
6798                        unsafe {
6799                            libc::dup2(tfd, fd);
6800                        }
6801                    }
6802                    source_fds.push(tfd);
6803                }
6804                Err(e) => {
6805                    let msg = match e.kind() {
6806                        std::io::ErrorKind::PermissionDenied => "permission denied",
6807                        std::io::ErrorKind::NotFound => "no such file or directory",
6808                        _ => "open failed",
6809                    };
6810                    // c:Src/exec.c:3741 — zwarn with real lineno prefix.
6811                    crate::ported::utils::zwarn(&format!("{}: {}", msg, source));
6812                    for prev in &source_fds {
6813                        unsafe {
6814                            libc::close(*prev);
6815                        }
6816                    }
6817                    with_executor(|exec| {
6818                        exec.redirect_failed = true;
6819                    });
6820                    return Value::Status(1);
6821                }
6822            }
6823        }
6824
6825        // Create the concatenator pipe.
6826        let (read_end, write_end) = match os_pipe::pipe() {
6827            Ok(p) => p,
6828            Err(_) => {
6829                for f in &source_fds {
6830                    unsafe {
6831                        libc::close(*f);
6832                    }
6833                }
6834                return Value::Status(1);
6835            }
6836        };
6837        // dup the pipe read-end onto fd before spawning the
6838        // producer; close the original read_end so the consumer
6839        // (reading via fd) is the sole reference until scope-end.
6840        let read_dup = unsafe { libc::dup(AsRawFd::as_raw_fd(&read_end)) };
6841        drop(read_end);
6842        if read_dup < 0 {
6843            for f in &source_fds {
6844                unsafe {
6845                    libc::close(*f);
6846                }
6847            }
6848            return Value::Status(1);
6849        }
6850        unsafe {
6851            libc::dup2(read_dup, fd);
6852            libc::close(read_dup);
6853        }
6854        // Spawn the producer.
6855        let source_fds_for_thread = source_fds.clone();
6856        let handle = std::thread::spawn(move || {
6857            let mut w = write_end;
6858            let mut buf = [0u8; 8192];
6859            for sfd in source_fds_for_thread {
6860                loop {
6861                    let n = unsafe {
6862                        libc::read(
6863                            sfd,
6864                            buf.as_mut_ptr() as *mut libc::c_void,
6865                            buf.len(),
6866                        )
6867                    };
6868                    if n <= 0 {
6869                        break;
6870                    }
6871                    let n = n as usize;
6872                    if std::io::Write::write_all(&mut w, &buf[..n]).is_err() {
6873                        break;
6874                    }
6875                }
6876                unsafe {
6877                    libc::close(sfd);
6878                }
6879            }
6880            // Closing w (the write_end) at scope drop signals EOF
6881            // to the consumer.
6882        });
6883        with_executor(|exec| {
6884            // Track using a closed-write sentinel — the producer
6885            // owns write_end so we just need to join. Use -1 fd
6886            // marker meaning "no fd to close".
6887            if let Some(top) = exec.multios_scope_stack.last_mut() {
6888                top.push((-1, handle));
6889            } else {
6890                let _ = handle.join();
6891            }
6892        });
6893        Value::Status(0)
6894    });
6895    // c:Src/exec.c:3978-3986 — nullexec==1 marker. See the const's
6896    // doc block. Arg: 1 = entering a bare-exec redirect, 0 = leaving.
6897    vm.register_builtin(BUILTIN_EXEC_PERM_REDIRS, |vm, _argc| {
6898        let on = vm.pop().to_int() != 0;
6899        with_executor(|exec| exec.exec_redirs_permanent = on);
6900        Value::Status(0)
6901    });
6902    // Bare-exec redirect epilogue — see the const's doc block.
6903    // c:Src/exec.c:252-259 (execerr) + c:4367-4386 (done: POSIX gate).
6904    vm.register_builtin(BUILTIN_EXEC_REDIR_DONE, |vm, _argc| {
6905        use std::sync::atomic::Ordering;
6906        let failed = with_executor(|exec| {
6907            let f = exec.redirect_failed;
6908            exec.redirect_failed = false;
6909            f
6910        });
6911        if !failed {
6912            return Value::Status(0);
6913        }
6914        // c:255 — `redir_err = lastval = 1`.
6915        vm.last_status = 1;
6916        if isset(crate::ported::zsh_h::POSIXBUILTINS)
6917            && !isset(crate::ported::zsh_h::INTERACTIVE)
6918        {
6919            // c:4379-4383 — non-interactive POSIX fatal: exit(1).
6920            // In-process equivalent: arm EXIT_PENDING/EXIT_VAL so the
6921            // next BUILTIN_ERREXIT_CHECK (trigger 2) unwinds the
6922            // script with status 1 — same deferred-exit shape the
6923            // `exit` builtin uses inside subshell contexts.
6924            crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
6925            crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
6926        }
6927        Value::Status(1)
6928    });
6929    // c:Src/exec.c:3722-3724 — see the const's doc block. No args.
6930    vm.register_builtin(BUILTIN_PIPE_OUTPUT_MARK, |_vm, _argc| {
6931        with_executor(|exec| exec.pipe_output_pending = true);
6932        Value::Status(0)
6933    });
6934    // c:Src/exec.c — block-level redirect-failure gate. When a
6935    // compound command (`{ … } < file`, `( … ) > file`, etc.) has a
6936    // failing redirect (e.g. `< /nonexistent`), zsh skips the entire
6937    // body AND sets lastval to 1. The simple-command path's
6938    // redirect_failed check (line 215-221 above) only catches the
6939    // failure when a builtin dispatches and is consumed by that
6940    // single builtin call — so a multi-statement block kept running
6941    // its remaining statements after the redir error. Emit-side at
6942    // compile_zsh.rs::compile_command's Redirected arm pairs this
6943    // with a JumpIfTrue → WithRedirectsEnd to abandon the body.
6944    vm.register_builtin(BUILTIN_REDIRECT_FAILED_CHECK, |vm, _argc| {
6945        let failed = with_executor(|exec| {
6946            let f = exec.redirect_failed;
6947            exec.redirect_failed = false;
6948            f
6949        });
6950        if failed {
6951            vm.last_status = 1;
6952            Value::Int(1)
6953        } else {
6954            Value::Int(0)
6955        }
6956    });
6957    // c:Src/exec.c — drop-in replacement for fusevm's Op::Exec used by
6958    // the dynamic-first-word path (`$cmd`, `$(cmd)`, glob-named cmds).
6959    // fusevm's Op::Exec returns Value::Status(0) when post-expansion
6960    // argv is empty (vm.rs:1722) — that clobbers \$? for the
6961    // `\$(exit 1); echo \$?` case where the cmd-subst left
6962    // last_status = 1 but the empty expansion gets exec'd to 0.
6963    // Mirror C zsh: when the word list is empty after expansion,
6964    // \$? becomes whatever the inner cmd-subst's last_status is
6965    // (preserved here by returning Value::Status(last_status)).
6966    // c:Src/cond.c:308-316 — `if (!(pprog = patcompile(right, ...)))
6967    //   { zwarnnam(fromtest, "bad pattern: %s", right); return 2; }`.
6968    // The cond path must NOT use str_match/glob_match_static: the
6969    // case-statement consumer of those follows Src/loop.c:667 zerr
6970    // semantics (errflag abort), while cond is a zwarn + status-2
6971    // soft failure. COND_BAD_PATTERN carries the 2 across the
6972    // Bool-shaped stack contract (so `!=`'s LogNot can't lose it).
6973    thread_local! {
6974        static COND_BAD_PATTERN: std::cell::Cell<bool> =
6975            const { std::cell::Cell::new(false) };
6976    }
6977    vm.register_builtin(BUILTIN_COND_STRMATCH, |vm, _argc| {
6978        let pat = vm.pop().to_str();
6979        let s = vm.pop().to_str();
6980        let mut pat_tok = pat.clone();
6981        crate::ported::glob::tokenize(&mut pat_tok);
6982        if crate::ported::pattern::patcompile(
6983            &pat_tok,
6984            crate::ported::zsh_h::PAT_STATIC as i32,
6985            None,
6986        )
6987        .is_none()
6988        {
6989            // c:314 — zwarnnam(fromtest, "bad pattern: %s", right).
6990            crate::ported::utils::zwarn(&format!("bad pattern: {}", pat));
6991            COND_BAD_PATTERN.with(|c| c.set(true));
6992            return Value::Bool(false);
6993        }
6994        // Match via the shared engine so `(#b)`/`(#m)` backref and
6995        // MATCH-variable population stays in one place.
6996        Value::Bool(crate::vm_helper::glob_match_static(&s, &pat))
6997    });
6998    vm.register_builtin(BUILTIN_COND_UNKNOWN, |vm, _argc| {
6999        // c:Src/cond.c:150-188 — `zwarnnam(fromtest, "unknown condition: %s",
7000        // name)` for a `-X` op with no matching cond module. Like a cond
7001        // syntax error it yields status 2 and aborts: arm COND_BAD_PATTERN so
7002        // the downstream BUILTIN_COND_STATUS_FROM_BOOL carries the 2 across the
7003        // Bool-shaped stack and runs the shared errflag+set_last_status(2)+abort
7004        // path (c:Src/exec.c:5216-5221). Returns Bool(false) as the operand.
7005        let op = vm.pop().to_str();
7006        crate::ported::utils::zerr(&format!("unknown condition: {}", op));
7007        COND_BAD_PATTERN.with(|c| c.set(true));
7008        Value::Bool(false)
7009    });
7010    vm.register_builtin(BUILTIN_COND_STATUS_FROM_BOOL, |vm, _argc| {
7011        let ok = vm.pop().to_int() != 0;
7012        let bad = COND_BAD_PATTERN.with(|c| {
7013            let b = c.get();
7014            c.set(false);
7015            b
7016        });
7017        if bad {
7018            // c:Src/exec.c:5216-5221 — `stat = evalcond(...);
7019            //   /* 2 indicates a syntax error. For compatibility,
7020            //      turn this into a shell error. */
7021            //   if (stat == 2) errflag |= ERRFLAG_ERROR;`
7022            // The errflag abort exits the script with lastval (2),
7023            // matching `zsh -fc '[[ x == [a- ]]; print rc=$?'`
7024            // printing nothing after the diagnostic and exiting 2.
7025            crate::ported::utils::errflag.fetch_or(
7026                crate::ported::zsh_h::ERRFLAG_ERROR,
7027                std::sync::atomic::Ordering::Relaxed,
7028            );
7029            with_executor(|exec| exec.set_last_status(2));
7030            return Value::Int(2); // c:Src/cond.c:316 `return 2;`
7031        }
7032        Value::Int(if ok { 0 } else { 1 })
7033    });
7034    vm.register_builtin(BUILTIN_EXEC_DYNAMIC, |vm, argc| {
7035        let raw = pop_args(vm, argc);
7036        // Flatten Array entries into argv slots (matches fusevm
7037        // Op::Exec's flatten at vm.rs:1660-1665) so `${arr[@]}` /
7038        // splice expansions produce one argv slot per element.
7039        let args: Vec<String> = raw.into_iter().collect();
7040        // c:Src/subst.c paramsubst — when `${var:?msg}` or
7041        // `${var?msg}` set errflag, the expansion may produce empty
7042        // argv[0] which would fall into the EACCES/permission-denied
7043        // path below, masking the real paramsubst diagnostic with a
7044        // spurious "permission denied:" line and rc=126. Honour
7045        // errflag so the simple command ends with the paramsubst
7046        // error as the sole diagnostic, rc=1. Bug #86.
7047        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
7048            & crate::ported::zsh_h::ERRFLAG_ERROR)
7049            != 0
7050        {
7051            return Value::Status(1);
7052        }
7053        if args.is_empty() {
7054            // c:Src/exec.c — empty argv preserves prior \$?. The
7055            // cmd-subst inside the word already set last_status; just
7056            // round-trip it back through SetStatus.
7057            return Value::Status(vm.last_status);
7058        }
7059        if args[0].is_empty() {
7060            // Explicit empty command word — exec returns EACCES.
7061            let script_name =
7062                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
7063            let lineno: u64 = with_executor(|exec| {
7064                exec.scalar("LINENO")
7065                    .and_then(|s| s.parse::<u64>().ok())
7066                    .unwrap_or(1)
7067            });
7068            eprintln!("{}:{}: permission denied: ", script_name, lineno);
7069            return Value::Status(126);
7070        }
7071        // AOP intercepts (zshrs extension, no C counterpart) — same
7072        // gate as host_exec_external (the static-head path): dynamic
7073        // command names (`cmd=/bin/echo; $cmd payload`) must consult
7074        // registered intercepts before dispatch, else `intercept
7075        // before /bin/echo ...` fires for the literal spelling but
7076        // not the variable one. run_intercepts runs before-advice
7077        // in-place and returns None to continue; Some(status) means
7078        // an around/after advice fully handled the command.
7079        let intercepted = with_executor(|exec| {
7080            if exec.intercepts.is_empty() {
7081                return None;
7082            }
7083            let full_cmd = if args.len() == 1 {
7084                args[0].clone()
7085            } else {
7086                args.join(" ")
7087            };
7088            let rest: Vec<String> = args[1..].to_vec();
7089            exec.run_intercepts(&args[0], &full_cmd, &rest)
7090        });
7091        if let Some(result) = intercepted {
7092            return Value::Status(result.unwrap_or(127));
7093        }
7094        // c:Src/exec.c:2900 execcmd_exec — canonical simple-command
7095        // dispatcher. Runs precmd-modifier walk (c:3013-3091), then
7096        // dispatches to execbuiltin (c:4233) / runshfunc (c:3431+) /
7097        // execute (c:4314) per the resolved head. zshrs's bytecode VM
7098        // expanded the args before reaching here; we feed them in via
7099        // eparams.args and let execcmd_exec do the rest exactly as C
7100        // does for static heads. Without this, `c=builtin; $c source X`
7101        // skipped the precmd walk and emitted "command not found:
7102        // builtin".
7103        let mut state = crate::ported::zsh_h::estate {
7104            prog: Box::<crate::ported::zsh_h::eprog>::default(),
7105            pc: 0,
7106            strs: None,
7107            strs_offset: 0,
7108        };
7109        let mut eparams = crate::ported::zsh_h::execcmd_params {
7110            args: Some(args),
7111            redir: None,
7112            beg: 0,
7113            varspc: None,
7114            assignspc: None,
7115            typ: crate::ported::zsh_h::WC_SIMPLE as i32,
7116            postassigns: 0,
7117            htok: 0,
7118        };
7119        // input/output=0 → no pipe redirection (use shell stdio
7120        // directly); `output != 0` at c:2988 forks immediately. last1=2
7121        // (c:Src/exec.c:2014 `last1 ? 1 : 2`): terminal pipe stage but
7122        // the shell IS needed afterward — the VM keeps executing
7123        // bytecode after this op. last1=1 would arm the fake-exec
7124        // optimization (c:3646-3651, gate at c:3662 `last1 != 1`),
7125        // making `execute()` execve THIS process for external heads:
7126        // `p=/bin/echo; $p hi; echo after` replaced the shell and
7127        // `after` never ran (D04parameter chunk 11 shell-killer).
7128        // c:Src/exec.c:1690-1700 — execpline's job frame: save thisjob
7129        // (`pj = thisjob`) and allocate the jobtab slot that
7130        // execcmd_fork's addproc (c:2853) hangs the child pid off.
7131        // Without a live thisjob, the fork at c:3662 (last1 != 1 →
7132        // external must fork) registers no proc, nothing waits, and
7133        // the child races the rest of the script.
7134        let pj = {
7135            use crate::ported::jobs;
7136            *jobs::THISJOB
7137                .get_or_init(|| std::sync::Mutex::new(-1))
7138                .lock()
7139                .unwrap_or_else(|e| e.into_inner())
7140        };
7141        let newjob = {
7142            use crate::ported::jobs;
7143            let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
7144            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
7145            jobs::initjob(&mut tab) // c:1700 `thisjob = newjob = initjob()`
7146        };
7147        {
7148            use crate::ported::jobs;
7149            *jobs::THISJOB
7150                .get_or_init(|| std::sync::Mutex::new(-1))
7151                .lock()
7152                .unwrap_or_else(|e| e.into_inner()) = newjob as i32;
7153        }
7154        crate::ported::exec::execcmd_exec(
7155            &mut state,
7156            &mut eparams,
7157            0,                                    // input  (c:2989)
7158            0,                                    // output (c:2988)
7159            crate::ported::zsh_h::Z_SYNC as i32,  // how
7160            2,                                    // last1=2 — shell continues (c:2014)
7161            -1,                                   // close_if_forked
7162        );
7163        // c:Src/exec.c:1828-1835 — execpline's Z_SYNC tail: waitjobs()
7164        // reaps the forked external. c:Src/jobs.c:487-495 + 551-552 —
7165        // the job's LAST proc sets lastval (0200|sig when signalled,
7166        // else WEXITSTATUS). Builtin/shfunc heads never forked (job
7167        // has no procs) — LASTVAL was already set by execbuiltin /
7168        // doshfunc inside execcmd_exec; skip the wait.
7169        {
7170            use crate::ported::jobs;
7171            let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
7172            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
7173            if jobs::hasprocs(&tab, newjob) {
7174                jobs::waitjobs(&mut tab, newjob); // c:1835
7175                if let Some(p) = tab[newjob].procs.last() {
7176                    let val = if p.is_signaled() {
7177                        0o200 | p.term_sig() // c:Src/jobs.c:489-490
7178                    } else {
7179                        p.exit_status() // c:Src/jobs.c:494
7180                    };
7181                    crate::ported::builtin::LASTVAL
7182                        .store(val, std::sync::atomic::Ordering::Relaxed);
7183                }
7184            }
7185            // c:1977-1979 — `deletejob(jn, 0)` once done; c:1981
7186            // `thisjob = pj` restores the caller's job.
7187            if newjob < tab.len() {
7188                jobs::deletejob(&mut tab[newjob], false);
7189            }
7190            *jobs::THISJOB
7191                .get_or_init(|| std::sync::Mutex::new(-1))
7192                .lock()
7193                .unwrap_or_else(|e| e.into_inner()) = pj;
7194        }
7195        let status = crate::ported::builtin::LASTVAL
7196            .load(std::sync::atomic::Ordering::Relaxed);
7197        let mut synth = crate::ported::zsh_h::job::default();
7198        crate::ported::jobs::waitonejob(&mut synth);
7199        Value::Status(status)
7200    });
7201    // c:Src/exec.c:3340-3364 — `< file` / `> file` with no command
7202    // word. Resolves NULLCMD/READNULLCMD at runtime then routes
7203    // through host_exec_external. Redirects are already applied by
7204    // the surrounding WithRedirectsBegin scope.
7205    vm.register_builtin(BUILTIN_NULLCMD_EXEC, |vm, argc| {
7206        let args = pop_args(vm, argc);
7207        let is_single_read = args
7208            .first()
7209            .map(|s| s != "0" && !s.is_empty())
7210            .unwrap_or(false);
7211        // c:Src/exec.c — when the surrounding redir-open failed
7212        // (e.g. `< /nonexistent`), zerr already printed the diag
7213        // and set redirect_failed. Don't invoke NULLCMD — return
7214        // status 1 like the wordcode path does.
7215        let redir_failed = with_executor(|exec| {
7216            let f = exec.redirect_failed;
7217            exec.redirect_failed = false;
7218            f
7219        });
7220        if redir_failed {
7221            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
7222            return Value::Status(1);
7223        }
7224        let nullcmd = crate::ported::params::getsparam("NULLCMD");
7225        let nc_str = nullcmd.as_deref().unwrap_or("");
7226        let nc_empty = nc_str.is_empty();
7227        // c:3340-3344 — CSHNULLCMD or no NULLCMD set → diagnostic.
7228        if nc_empty || crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLCMD) {
7229            let script_name =
7230                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
7231            let lineno: u64 = with_executor(|exec| {
7232                exec.scalar("LINENO")
7233                    .and_then(|s| s.parse::<u64>().ok())
7234                    .unwrap_or(1)
7235            });
7236            eprintln!("{}:{}: redirection with no command", script_name, lineno);
7237            return Value::Status(1);
7238        }
7239        // c:3350 — SHNULLCMD → run `:`.
7240        let cmd: String = if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHNULLCMD) {
7241            ":".to_string()
7242        } else if is_single_read {
7243            // c:3354-3359 — single REDIR_READ + READNULLCMD set → readnullcmd.
7244            let rnc = crate::ported::params::getsparam("READNULLCMD");
7245            let rnc_str = rnc.as_deref().unwrap_or("");
7246            if !rnc_str.is_empty() {
7247                rnc_str.to_string()
7248            } else {
7249                nc_str.to_string() // c:3360-3363 fallback
7250            }
7251        } else {
7252            nc_str.to_string() // c:3360-3363
7253        };
7254        let status = with_executor(|exec| exec.host_exec_external(&[cmd]));
7255        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
7256        Value::Status(status)
7257    });
7258    // c:Src/exec.c:3342 — `zerr("redirection with no command")`.
7259    // Bare prefix-keyword (`builtin`, `command`, `exec`, `noglob`,
7260    // `nocorrect`) with a redirect but no command word. Emits the
7261    // canonical diagnostic via zerr (which sets errflag) and
7262    // returns Status(1). Bug #534.
7263    vm.register_builtin(BUILTIN_REDIR_NO_CMD, |_vm, _argc| {
7264        crate::ported::utils::zerr("redirection with no command");
7265        Value::Status(1)
7266    });
7267    vm.register_builtin(BUILTIN_DEBUG_TRAP, |vm, _argc| {
7268        // c:Src/signals.c:1245 dotrap(SIGDEBUG) — fires the DEBUG
7269        // trap body once per statement. The body sees the parent
7270        // shell's $? (LASTVAL). Guard against re-entry: commands
7271        // inside the DEBUG trap body would otherwise trigger
7272        // DEBUG_TRAP recursively → stack overflow. zsh guards via
7273        // its in_trap counter; we mirror with a thread-local Cell.
7274        //
7275        // c:Src/exec.c::trapcmd — before dotrap, the C source sets
7276        // `ZSH_DEBUG_CMD` to the about-to-run command text via
7277        // `dupstring(text)`. The trap body reads the parameter;
7278        // C unsets it after the trap returns. compile_list emits
7279        // the rendered statement text as the single arg here so the
7280        // shell-visible parameter reflects the command. Bug #263 in
7281        // docs/BUGS.md.
7282        let cmd_text = vm.pop().to_str();
7283        DEBUG_TRAP_REENTRY.with(|c| {
7284            if c.get() {
7285                return Value::Status(0);
7286            }
7287            // c:Src/exec.c:1423 — `if (sigtrapped[SIGDEBUG] &&
7288            // isset(DEBUGBEFORECMD) && !intrap)`. Bug #573: without
7289            // this gate, every sublist boundary called
7290            // setsparam("ZSH_DEBUG_CMD", ...) even when no DEBUG trap
7291            // was set, polluting the param table and (under
7292            // WARN_CREATE_GLOBAL) emitting a spurious
7293            // `scalar parameter ZSH_DEBUG_CMD created globally`
7294            // warning at every function call.
7295            //
7296            // Two trap registries exist (per signals.rs:1481-1511 dotrap):
7297            //   - settrap path → sigtrapped[SIGDEBUG] bits set
7298            //   - bin_trap path → traps_table["DEBUG"] populated, sigtrapped untouched
7299            // Mirror the dotrap dispatch decision: skip only when BOTH
7300            // are absent.
7301            let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
7302            let debug_trapped = crate::ported::signals::sigtrapped
7303                .lock()
7304                .map(|v| v.get(sig_debug).copied().unwrap_or(0))
7305                .unwrap_or(0);
7306            let debug_in_table = crate::ported::builtin::traps_table()
7307                .lock()
7308                .map(|t| t.contains_key("DEBUG"))
7309                .unwrap_or(false);
7310            if debug_trapped == 0 && !debug_in_table {
7311                return Value::Status(0);
7312            }
7313            c.set(true);
7314            // c:Src/exec.c — set ZSH_DEBUG_CMD scalar (PM_READONLY
7315            // is NOT set on ZSH_DEBUG_CMD, so the canonical
7316            // setsparam path is fine here — no direct paramtab
7317            // mutation needed).
7318            crate::ported::params::setsparam("ZSH_DEBUG_CMD", &cmd_text);
7319            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGDEBUG);
7320            // c:Src/exec.c::trapcmd — `unsetparam("ZSH_DEBUG_CMD")`
7321            // after the trap returns. Mirror that.
7322            crate::ported::params::unsetparam("ZSH_DEBUG_CMD");
7323            c.set(false);
7324            Value::Status(0)
7325        })
7326    });
7327
7328    vm.register_builtin(BUILTIN_ERREXIT_CHECK, |vm, _argc| {
7329        // Returns Value::Int(1) when the caller should jump to the
7330        // current scope's return-patch landing (subshell-end / func-
7331        // end / chunk-end). Returns Value::Int(0) otherwise. Emit
7332        // side at `emit_errexit_check` pairs this with a JumpIfTrue
7333        // → return_patches pattern so the caller can short-circuit.
7334        //
7335        // Four triggers:
7336        //   1. RETFLAG set by a nested `return` / `exit` (eval,
7337        //      sourced file, called function). Unwind THIS scope so
7338        //      the flag propagates outward until something clears it.
7339        //   2. EXIT_PENDING set (mostly subshell-context exits). Same
7340        //      propagation logic.
7341        //   3. `set -e` + nonzero status — the classic errexit path.
7342        //   4. errflag set in non-interactive mode — readonly
7343        //      reassign, bad redirect, parse error mid-expansion etc.
7344        //      Aborts the script (c:Src/init.c loop()).
7345        use std::sync::atomic::Ordering;
7346        let retflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
7347        let exit_pending = crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed);
7348        if retflag != 0 || exit_pending != 0 {
7349            if exit_pending != 0 {
7350                // c:Src/builtin.c zexit — the deferred exit carries its
7351                // status in EXIT_VAL; sync it into the VM counter so
7352                // the top-level unwind reports it as the script's exit
7353                // (run_chunk returns vm.last_status). Without this, a
7354                // POSIX-fatal `.` failure exited 127 (bin_dot's return)
7355                // instead of C's exit(1) at Src/exec.c:4383.
7356                vm.last_status =
7357                    crate::ported::builtin::EXIT_VAL.load(Ordering::Relaxed) & 0xFF;
7358            }
7359            return Value::Int(1);
7360        }
7361        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
7362            & crate::ported::zsh_h::ERRFLAG_ERROR)
7363            != 0;
7364        // c:Src/init.c:1931 — `if (errflag && !interact &&
7365        // !isset(CONTINUEONERROR)) { errexit = 1; break; }` — with
7366        // CONTINUE_ON_ERROR set, the top-level do-while re-enters
7367        // loop() and the NEXT list runs instead of the shell exiting.
7368        // Clear the flag so the next statement starts clean (the
7369        // failed statement's lastval is already in place).
7370        if errflag_set
7371            && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
7372            && crate::ported::zsh_h::isset(crate::ported::zsh_h::CONTINUEONERROR)
7373        {
7374            crate::ported::utils::errflag
7375                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
7376            return Value::Int(0);
7377        }
7378        if errflag_set && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE) {
7379            // c:Src/exec.c execlist — every enclosing list loop runs
7380            // `while (... && !errflag)`, so a set errflag breaks the
7381            // CURRENT scope and the check in the enclosing scope
7382            // breaks THAT one, all the way out. Leave errflag SET —
7383            // do NOT convert it to EXIT_PENDING: a process-exit
7384            // signal tunnels through the containment boundaries C
7385            // has, namely eval (Src/builtin.c:6221 `errflag &=
7386            // ~ERRFLAG_ERROR`), source (Src/init.c:1663 same), fork
7387            // boundaries (subshell/cmdsubst — child's errflag dies
7388            // with the child), and the interactive toplevel
7389            // (Src/init.c:139). Those boundaries clear errflag
7390            // themselves and execution continues past them; with
7391            // EXIT_PENDING armed here, `eval 'assoc=(odd)'; echo
7392            // after` aborted the whole script where zsh 5.9 prints
7393            // `after` (eval status 1). Bug #74's function case
7394            // (`f() { local -r x=5; x=10; }; f; echo after`) still
7395            // aborts: the function scope unwinds on THIS check, and
7396            // the caller's next ERREXIT_CHECK sees the still-set
7397            // errflag and unwinds too — exactly C's propagation.
7398            //
7399            // c:Src/init.c:234 — loop() BREAKS on errflag and
7400            // zsh_main exits with the UNTOUCHED lastval, NOT a
7401            // forced 1: `typeset -i x=3#8` (math error during the
7402            // assignment, before typeset sets a status) exits 0 in
7403            // zsh; a cond syntax error set lastval=2 (exec.c:5216-
7404            // 5221) and zsh exits 2; the readonly-reassign case
7405            // exits 1 because ITS lastval is 1. Sync the VM counter
7406            // from the executor's live lastval instead of
7407            // overwriting.
7408            vm.last_status = with_executor(|exec| exec.last_status());
7409            return Value::Int(1);
7410        }
7411        let last = vm.last_status;
7412        if last == 0 {
7413            return Value::Int(0);
7414        }
7415        // c:Src/exec.c:1598 `if (!this_noerrexit && !donetrap &&
7416        // !this_donetrap)` — gate the ZERR trap fire on DONETRAP so
7417        // an inner sublist (e.g. `false` inside a function) that
7418        // already fired ZERR doesn't fire it AGAIN at the outer
7419        // sublist's post-command check (after the function
7420        // returned non-zero). Bug #303 in docs/BUGS.md. DONETRAP
7421        // is reset at top-level statement boundaries via
7422        // BUILTIN_DONETRAP_RESET (compile_list emit at
7423        // compile_zsh.rs).
7424        let already_done =
7425            crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
7426        if !already_done {
7427            // c:Src/signals.c:1245 dotrap(SIGZERR) — canonical ZERR
7428            // trap dispatch. Fires whenever a command exits
7429            // non-zero.
7430            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR);
7431            // c:1602 — `donetrap = 1;` after firing.
7432            crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed);
7433        }
7434        // c:Src/exec.c:1605-1610 — compute errreturn / errexit.
7435        //   errreturn = ERRRETURN && (INTERACTIVE || locallevel || sourcelevel)
7436        //               && !(noerrexit & NOERREXIT_RETURN)
7437        //   errexit   = (ERREXIT || (ERRRETURN && !errreturn))
7438        //               && !(noerrexit & NOERREXIT_EXIT)
7439        let no_err = crate::ported::exec::noerrexit.load(Ordering::Relaxed);
7440        let locallvl = crate::ported::params::locallevel.load(Ordering::Relaxed);
7441        let sourcelvl = crate::ported::init::sourcelevel.load(Ordering::Relaxed);
7442        let errreturn_opt = isset(crate::ported::zsh_h::ERRRETURN);
7443        let in_unwindable_scope = isset(crate::ported::zsh_h::INTERACTIVE)
7444            || locallvl != 0
7445            || sourcelvl != 0;
7446        let errreturn = errreturn_opt
7447            && in_unwindable_scope
7448            && (no_err & crate::ported::zsh_h::NOERREXIT_RETURN) == 0;
7449        if errreturn {
7450            // c:1620-1623 — `retflag = 1; breaks = loops;` — unwind to
7451            // function boundary without exiting the shell.
7452            crate::ported::builtin::RETFLAG.store(1, Ordering::Relaxed);
7453            let loops = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
7454            crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed);
7455            return Value::Int(1);
7456        }
7457        let (errexit_on, in_subshell) = with_executor(|exec| {
7458            let on_canonical = isset(ERREXIT)
7459                || (errreturn_opt && !errreturn); // c:1608-1609
7460            let on_legacy = opt_state_get("errexit").unwrap_or(false);
7461            (
7462                (on_canonical || on_legacy)
7463                    && (no_err & crate::ported::zsh_h::NOERREXIT_EXIT) == 0,
7464                !exec.subshell_snapshots.is_empty(),
7465            )
7466        });
7467        if !errexit_on {
7468            return Value::Int(0);
7469        }
7470        // c:Src/exec.c — `set -e` fires shell exit, NOT a function-only
7471        // unwind. When LOCAL_OPTIONS restores the option mid-fn, the
7472        // restoration would otherwise mask the trigger and let the
7473        // outer scope continue. Setting EXIT_PENDING + EXIT_VAL here
7474        // (for ALL scope kinds, not just subshells) makes the fn-exit
7475        // path propagate to the shell-exit boundary at c:6135-6155.
7476        crate::ported::builtin::EXIT_VAL.store(last, Ordering::Relaxed);
7477        crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
7478        let _ = in_subshell;
7479        // Function scope and top-level scope both branch to their
7480        // respective return_patches; top-level lands at chunk-end,
7481        // so execute_script returns `last` as the script's exit
7482        // status (same observable behavior as a process::exit).
7483        Value::Int(1)
7484    });
7485
7486    // BUILTIN_ASSIGN_ONLY_STATUS — status of an assignment-only
7487    // simple command. c:Src/exec.c:3393-3396 (execcmd_exec, no
7488    // command word + varspc): `if (errflag) lastval = 1; else
7489    // lastval = cmdoutval;`; same shape at c:1322 (execsimple
7490    // WC_ASSIGN: `lv = (errflag ? errflag : cmdoutval)`) and
7491    // c:3977 (nullexec=2 redir variant). cmdoutval is the exit of
7492    // a `$()` that ran in an RHS (already in vm.last_status via
7493    // compile_assign's per-assign SetStatus), 0 otherwise. The
7494    // store goes to the canonical LASTVAL too — that IS C's single
7495    // `lastval` global; without it the errflag-abort path
7496    // (BUILTIN_ERREXIT_CHECK trigger 4) syncs vm.last_status from
7497    // a stale LASTVAL and `readonly r=1; r=2` exited 0, not 1.
7498    vm.register_builtin(BUILTIN_ASSIGN_ONLY_STATUS, |vm, _argc| {
7499        use std::sync::atomic::Ordering;
7500        let had_cmd_subst = vm.pop().to_int() != 0;
7501        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
7502            & crate::ported::zsh_h::ERRFLAG_ERROR)
7503            != 0;
7504        // c:Src/exec.c addvars — `if (!pm) { lastval = 1; if
7505        // (!cmdoutval) cmdoutval = 1; }` (assignment-failed cheat).
7506        let assign_failed = ASSIGN_FAILED_FLAG.swap(false, std::sync::atomic::Ordering::Relaxed);
7507        let status = if errflag_set || assign_failed {
7508            1 // c:Src/exec.c:3394 `lastval = 1` / addvars cmdoutval=1
7509        } else if had_cmd_subst {
7510            vm.last_status // c:3396 `lastval = cmdoutval` (subst exit)
7511        } else {
7512            0 // c:3396 `lastval = cmdoutval` (cmdoutval = 0)
7513        };
7514        with_executor(|exec| exec.set_last_status(status));
7515        Value::Status(status)
7516    });
7517
7518    // `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
7519    // Pops [name, op_byte, rhs] (rhs popped first). Returns the modified
7520    // value as Value::Str. Handles unset/empty distinction (`:-` etc.
7521    // treat empty same as unset, matching POSIX).
7522    // BUILTIN_PARAM_DEFAULT_FAMILY — `${var-x}` / `${var:-x}` / `${var=x}` /
7523    // `${var:=x}` / `${var?x}` / `${var:?x}` / `${var+x}` / `${var:+x}`.
7524    // PURE PASSTHRU: pop name + op + rhs, reconstruct the canonical
7525    // brace expression, hand to `subst::paramsubst` (C port of
7526    // `Src/subst.c::paramsubst`). All "missing vs empty" gating,
7527    // nounset suppression, default-evaluation, and elide-empty-words
7528    // semantics live inside paramsubst.
7529    vm.register_builtin(BUILTIN_PARAM_DEFAULT_FAMILY, |vm, _argc| {
7530        let rhs = vm.pop().to_str();
7531        let op = vm.pop().to_int() as u8;
7532        let name = vm.pop().to_str();
7533        // op=8 is the `${+name}` set-test prefix form (distinct from the
7534        // `${name+rhs}` substitute-if-set suffix form which is op=7).
7535        // Per compile_zsh.rs::parse_param_modifier: the `+` is emitted as
7536        // a leading sigil and `rhs` is empty.
7537        let body = if op == 8 {
7538            format!("${{+{}}}", name)
7539        } else {
7540            let op_str = match op {
7541                0 => ":-",
7542                1 => ":=",
7543                2 => ":?",
7544                3 => ":+",
7545                4 => "-",
7546                5 => "=",
7547                6 => "?",
7548                7 => "+",
7549                _ => "-",
7550            };
7551            format!("${{{}{}{}}}", name, op_str, rhs)
7552        };
7553        paramsubst_to_value(&body)
7554    });
7555
7556    // `${var:offset[:length]}` — substring. Pops [name, offset, length].
7557    // length == -1 means "rest of string". Negative offset counts from end.
7558    // BUILTIN_PARAM_SUBSTRING — `${var:offset:length}` literal-int form.
7559    // PURE PASSTHRU: reconstruct `${name:offset:length}` and route
7560    // through `subst::paramsubst`. Length sentinel `i64::MIN` =
7561    // "no length given" (omit the `:length` portion).
7562    //
7563    // c:Src/subst.c:1571,3781 — `${name:-N}` is the colon-default
7564    // operator, NOT a substring with negative offset. zsh's lexical
7565    // rule disambiguates via a literal space: `${name: -N}` (space
7566    // before `-`) is the substring form. The reconstructed body MUST
7567    // preserve that space when offset < 0; otherwise paramsubst's
7568    // `:-` dispatch fires on the synthesized `${name:-N}` body and
7569    // returns N as the unset-default instead of slicing the last N
7570    // chars. Length-form `${name:-N:M}` has the same trap.
7571    vm.register_builtin(BUILTIN_PARAM_SUBSTRING, |vm, _argc| {
7572        let length = vm.pop().to_int();
7573        let offset = vm.pop().to_int();
7574        let name = vm.pop().to_str();
7575        let off_sep = if offset < 0 { " " } else { "" };
7576        let body = if length == i64::MIN {
7577            format!("${{{}:{}{}}}", name, off_sep, offset)
7578        } else {
7579            format!("${{{}:{}{}:{}}}", name, off_sep, offset, length)
7580        };
7581        paramsubst_to_value(&body)
7582    });
7583
7584    // BUILTIN_PARAM_SUBSTRING_EXPR — `${var:offset_expr[:length_expr]}` form.
7585    // PURE PASSTHRU: rebuild `${name:offset:length}` using the
7586    // expression text verbatim (paramsubst's offset/length
7587    // parser evaluates arith / param refs itself).
7588    //
7589    // c:Src/subst.c:1571,3781 — same `:-` disambiguation trap as
7590    // BUILTIN_PARAM_SUBSTRING. The expression text may itself start
7591    // with `-` (e.g. `${VAR:$((-1))}` arith resolves at the body-
7592    // assembly layer in some upstream paths, leaving `-1` in
7593    // off_expr). Insert a leading space when off_expr starts with
7594    // `-` so paramsubst's check_colon_subscript (subst.c:1571)
7595    // accepts the operand as a math expression instead of the
7596    // `:-` operator catching it.
7597    vm.register_builtin(BUILTIN_PARAM_SUBSTRING_EXPR, |vm, _argc| {
7598        let has_len = vm.pop().to_int() != 0;
7599        let len_expr = vm.pop().to_str();
7600        let off_expr = vm.pop().to_str();
7601        let name = vm.pop().to_str();
7602        let off_sep = if off_expr.starts_with('-') { " " } else { "" };
7603        let body = if has_len {
7604            format!("${{{}:{}{}:{}}}", name, off_sep, off_expr, len_expr)
7605        } else {
7606            format!("${{{}:{}{}}}", name, off_sep, off_expr)
7607        };
7608        paramsubst_to_value(&body)
7609    });
7610
7611    // `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}`
7612    // Pops [name, pattern, op_byte]. op: 0=`#` short-prefix, 1=`##` long,
7613    // 2=`%` short-suffix, 3=`%%` long. Glob-pattern matching via the
7614    // existing glob_match_static helper.
7615    // BUILTIN_PARAM_STRIP — `${var#pat}` / `${var##pat}` / `${var%pat}` /
7616    // `${var%%pat}`. PURE PASSTHRU: reconstruct the brace expression
7617    // and route through `subst::paramsubst`. (M)/(S) flags arrive
7618    // through SUB_FLAGS (already inside paramsubst's scope), so we
7619    // just clear the bridge-side cached read.
7620    vm.register_builtin(BUILTIN_PARAM_STRIP, |vm, _argc| {
7621        let _dq_flag = vm.pop().to_int() != 0;
7622        let op = vm.pop().to_int() as u8;
7623        let pattern = vm.pop().to_str();
7624        let name = vm.pop().to_str();
7625        let op_str = match op {
7626            0 => "#",
7627            1 => "##",
7628            2 => "%",
7629            3 => "%%",
7630            _ => "#",
7631        };
7632        let body = format!("${{{}{}{}}}", name, op_str, pattern);
7633        paramsubst_to_value(&body)
7634    });
7635
7636    // `$((expr))` — pops [expr_string], evaluates via MathEval which
7637    // honors integer-vs-float distinction (zsh-compatible). Returns
7638    // the result as Value::Str so it can be Concat'd into surrounding
7639    // word context.
7640    vm.register_builtin(BUILTIN_ARITH_EVAL, |vm, _argc| {
7641        // Pure path: evaluate expr, return string. errflag may be
7642        // set by arithsubst on math error; the caller decides
7643        // whether to clear it. For `(( ... ))` (math command) the
7644        // compile_arith path clears via BUILTIN_ARITH_CMD_FINISH;
7645        // for `$((... ))` (substitution inside another command)
7646        // errflag stays set so the surrounding command aborts —
7647        // matches c:Src/math.c "math errors propagate as errflag
7648        // through the containing word expansion".
7649        let expr = vm.pop().to_str();
7650        let result = crate::ported::subst::arithsubst(&expr, "", "");
7651        let _ = vm; // silence unused warning when no math error path mutates
7652        Value::str(result)
7653    });
7654
7655    // After-call hook used by compile_arith's `(( ... ))` path: when
7656    // arithsubst set errflag (math error), clear it and signal
7657    // status=2 in vm.last_status — matches zsh's c:exec.c arith-
7658    // failure: the math command exits 2 and the script continues.
7659    vm.register_builtin(BUILTIN_ARITH_CMD_FINISH, |vm, _argc| {
7660        use std::sync::atomic::Ordering;
7661        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
7662        let err = live & crate::ported::zsh_h::ERRFLAG_ERROR;
7663        let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
7664        if err != 0 {
7665            // c:Src/subst.c:3344 — when `${var:?msg}` fires, errflag
7666            // is OR'd with ERRFLAG_HARD to signal a script-abort
7667            // error (vs a recoverable math error like `$((1/0))`).
7668            // Clear only the ERRFLAG_ERROR bit; preserve
7669            // ERRFLAG_HARD so the next ERREXIT_CHECK aborts the
7670            // script. Bug #193 in docs/BUGS.md.
7671            if hard != 0 {
7672                // Keep ERRFLAG_HARD AND ERRFLAG_ERROR set so the
7673                // script-abort gate downstream still fires.
7674                vm.last_status = 2;
7675                Value::Status(2)
7676            } else {
7677                crate::ported::utils::errflag
7678                    .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
7679                vm.last_status = 2;
7680                Value::Status(2)
7681            }
7682        } else {
7683            Value::Status(vm.last_status)
7684        }
7685    });
7686
7687    // `$(cmd)` — pops [cmd_string], routes through
7688    // run_command_substitution which performs an in-process pipe-capture.
7689    // Avoids the Op::CmdSubst sub-chunk word-emit bug
7690    // (`printf "a\nb"` produced "anb" via that path). Returns trimmed
7691    // output (trailing newlines stripped per POSIX cmd-sub semantics).
7692    vm.register_builtin(BUILTIN_CMD_SUBST_TEXT, |vm, _argc| {
7693        let cmd = vm.pop().to_str();
7694        // Inherit live $? into the inner shell so cmd-subst sees the
7695        // parent's most recent exit. Same rationale as the mode-3
7696        // backtick path above.
7697        let live_status = vm.last_status;
7698        let result = with_executor(|exec| {
7699            exec.set_last_status(live_status);
7700            exec.run_command_substitution(&cmd)
7701        });
7702        // Mirror run_command_substitution's exec.last_status side
7703        // effect into the VM's live counter so a containing
7704        // assignment's BUILTIN_SET_VAR — which reads vm.last_status
7705        // — sees the cmd-subst's exit. Without this, `a=$(false);
7706        // echo $?` reads stale 0 (vm.last_status was zeroed by
7707        // compile_assign's prelude SetStatus, and run_cmd_subst only
7708        // updated exec.last_status). Pull the value back through
7709        // exec since it owns the canonical post-subst record.
7710        let cs_status = with_executor(|exec| exec.last_status());
7711        vm.last_status = cs_status;
7712        Value::str(result)
7713    });
7714
7715    // Text-based word expansion. Pops [preserved_text, mode_byte].
7716    // mode_byte:
7717    //   0 = Default — expand_string + xpandbraces + expand_glob
7718    //   1 = DoubleQuoted — strip outer `"…"`, expand_string only
7719    //         (no brace, no glob — DQ semantics)
7720    //   2 = SingleQuoted — strip outer `'…'`, no expansion
7721    //         (kept for symmetry; Snull early-return covers most SQ)
7722    //   3 = AltBackquote — strip backticks, run as cmd-sub
7723    //   7 = RedirTarget — same as Default but glob gated on MULTIOS
7724    //         (c:Src/glob.c:2161-2167 xpandredir)
7725    // Single result → Value::str; multi → Value::Array.
7726    vm.register_builtin(BUILTIN_EXPAND_TEXT, |vm, _argc| {
7727        let mode = vm.pop().to_int() as u8;
7728        let text = vm.pop().to_str();
7729        // Sync vm.last_status → exec.last_status so cmd-subst (mode 3)
7730        // and any nested $? reads inside singsub see the live `$?`
7731        // from the most recent VM op. Without this, cmd-subst inside
7732        // arg-eval saw a stale exec.last_status that was zeroed at
7733        // the start of the current statement. Direct port of zsh's
7734        // pre-cmdsubst lastval propagation per Src/exec.c:4770.
7735        let live_status = vm.last_status;
7736        with_executor(|exec| exec.set_last_status(live_status));
7737        let result_value = with_executor(|exec| match mode {
7738            // Mode 1 = DoubleQuoted (argument context).
7739            // Mode 5 = DoubleQuoted in scalar-assignment context.
7740            // Both share the same DQ unescape pre-processing; mode 5
7741            // additionally bumps `in_scalar_assign` so subst_port's
7742            // paramsubst sees ssub=true and suppresses split flags
7743            // `(f)` / `(s:STR:)` / `(0)` per Src/subst.c:1759 +
7744            // Src/exec.c::addvars line 2546 (the PREFORK_SINGLE bit
7745            // C zsh sets when prefork-ing the assignment RHS).
7746            1 | 5 => {
7747                // DoubleQuoted: strip outer `"…"` if present. In DQ
7748                // context, `\` escapes the DQ-special chars `$`, `` ` ``,
7749                // `"`, `\`. zsh's expand_string expects the lexer's
7750                // `\0X` literal-marker for an already-escaped char, so
7751                // we pre-process: `\$` → `\0$`, `\\` → `\0\`, etc. Then
7752                // expand_string handles the rest.
7753                let inner = if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
7754                    &text[1..text.len() - 1]
7755                } else {
7756                    text.as_str()
7757                };
7758                // The lexer's dquote_parse (Src/lex.c) already tokenized
7759                // DQ contents: `$` → Qstring (\u{8c}), `\$`/`\\`/`\"`/
7760                // `` \` `` → Bnull (\u{9f}) + literal. Stringsubst /
7761                // multsub recognize these markers natively. We pass
7762                // `inner` through verbatim — no re-tokenization needed.
7763                let prepped: String = inner.to_string();
7764                // Tell parameter-flag application that we're inside
7765                // double quotes — array-only flags ((o), (O), (n),
7766                // (i), (M), (u)) must be no-ops here per zsh.
7767                exec.in_dq_context += 1;
7768                if mode == 5 {
7769                    exec.in_scalar_assign += 1;
7770                }
7771                // Mode 1 = argv DQ word; mode 5 = scalar-assign RHS.
7772                // In C zsh, the corresponding prefork-on-list paths
7773                // are: argv → `prefork(argv_list, 0)` returns multi-
7774                // word LinkList (Src/exec.c::execcmd), assignment →
7775                // `prefork(rhs_list, PREFORK_SINGLE|PREFORK_ASSIGN)`
7776                // returns single-word (Src/exec.c::addvars line
7777                // 2546). zshrs's `multsub` (Src/subst.c:544) is the
7778                // multi-result variant; `singsub` (Src/subst.c:514)
7779                // asserts ≤1 node. Mode 5 keeps singsub; mode 1
7780                // switches to multsub so `"${(@)arr}"`/`"$@"`/
7781                // `"${arr[@]}"` in argv context emit multiple words
7782                // as the C path would.
7783                // c:Src/lex.c untokenize — the final argv pass C runs
7784                // on every expanded word (glob.c:1862 / exec.c) drops
7785                // the Nularg empty-word sentinel remnulargs left in
7786                // place and folds any remaining token chars. Without
7787                // it, quoted splits with empty pieces
7788                // ("${(s:|:)x}" on "|a|b|") leak U+00A1 into argv.
7789                let result_value = if mode == 5 {
7790                    let out = crate::ported::subst::singsub(&prepped);
7791                    Value::str(crate::ported::lex::untokenize(&out))
7792                } else {
7793                    let (_first, nodes, _ms_ws, _ret) = crate::ported::subst::multsub(&prepped, 0);
7794                    // c:Src/subst.c:655 — multsub returns Vec::new()
7795                    // for zero-word results (quoted array splat that
7796                    // resolved to empty array). Surface as
7797                    // Value::Array(vec![]) so the downstream array
7798                    // assignment / argv flattening sees ZERO args.
7799                    // Previous Rust port returned Value::str("") which
7800                    // surfaced as ONE empty arg. Bug #120 in
7801                    // docs/BUGS.md.
7802                    if nodes.is_empty() {
7803                        Value::Array(Vec::new())
7804                    } else if nodes.len() == 1 {
7805                        Value::str(crate::ported::lex::untokenize(
7806                            &nodes.into_iter().next().unwrap(),
7807                        ))
7808                    } else {
7809                        Value::Array(
7810                            nodes
7811                                .into_iter()
7812                                .map(|n| Value::str(crate::ported::lex::untokenize(&n)))
7813                                .collect(),
7814                        )
7815                    }
7816                };
7817                if mode == 5 {
7818                    exec.in_scalar_assign -= 1;
7819                }
7820                exec.in_dq_context -= 1;
7821                result_value
7822            }
7823            2 => {
7824                // SingleQuoted: pure literal, strip outer `'…'`.
7825                let inner = if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
7826                    &text[1..text.len() - 1]
7827                } else {
7828                    text.as_str()
7829                };
7830                Value::str(inner.to_string())
7831            }
7832            3 => {
7833                // Backquote command sub: strip outer backticks.
7834                // Word-split the result on IFS when the surrounding
7835                // word is unquoted — zsh: `print -l \`echo a b c\``
7836                // emits one arg per word. The $(…) path applies the
7837                // same split via BUILTIN_WORD_SPLIT after capture; do
7838                // the equivalent here for the `…` form.
7839                let inner = if text.len() >= 2 && text.starts_with('`') && text.ends_with('`') {
7840                    &text[1..text.len() - 1]
7841                } else {
7842                    text.as_str()
7843                };
7844                // Apply the live VM status before running the inner
7845                // shell so the inherited $? matches zsh's lastval
7846                // propagation.
7847                exec.set_last_status(live_status);
7848                let captured = exec.run_command_substitution(inner);
7849                let trimmed = captured.trim_end_matches('\n');
7850                if exec.in_dq_context > 0 {
7851                    Value::str(trimmed.to_string())
7852                } else {
7853                    let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
7854                    let parts: Vec<Value> = trimmed
7855                        .split(|c: char| ifs.contains(c))
7856                        .filter(|s| !s.is_empty())
7857                        .map(|s| Value::str(s.to_string()))
7858                        .collect();
7859                    if parts.is_empty() {
7860                        Value::str(String::new())
7861                    } else if parts.len() == 1 {
7862                        parts.into_iter().next().unwrap()
7863                    } else {
7864                        Value::Array(parts)
7865                    }
7866                }
7867            }
7868            4 => {
7869                // HeredocBody: expand variables / command-subst / arith
7870                // but NOT glob or brace. Heredoc lines like `[42]` must
7871                // pass through verbatim — running them through the
7872                // default pipeline triggers NOMATCH on the literal.
7873                Value::str(crate::ported::subst::singsub(&text))
7874            }
7875            _ => {
7876                // Default (unquoted): the lexer's gettokstr already
7877                // tokenized backslash-escapes (`\$` → Bnull+$, etc).
7878                // Pass `text` through verbatim — multsub/stringsubst
7879                // recognize the markers natively. No bridge-side
7880                // re-tokenization needed.
7881                //
7882                // Mode 6 = unquoted RHS in scalar-assign context.
7883                // Pass PREFORK_ASSIGN so prefork's filesub colon-walk
7884                // fires per c:Src/exec.c:2546.
7885                let prepped: String = text.clone();
7886                if std::env::var("ZSHRS_TRACE_DEFP").is_ok() {
7887                    eprintln!(
7888                        "[TRACE_DEFP] text={:?} prepped={:?} mode={}",
7889                        text, prepped, mode
7890                    );
7891                }
7892                let pf_flags = if mode == 6 {
7893                    crate::ported::zsh_h::PREFORK_ASSIGN
7894                } else {
7895                    0
7896                };
7897                // c:Src/subst.c:544+ — `multsub(&prepped, 0)` is the
7898                // unquoted-argv equivalent of zsh's `prefork(list,
7899                // 0, NULL)` for a single-element list. Returns the
7900                // post-expansion node list (Vec<String>) so array-
7901                // shape results (e.g. `${a:e}`, `${a[@]}`,
7902                // `${(s::)str}`) splat into multiple argv words.
7903                // singsub() collapses to one string and discards the
7904                // splat — parity bug #28 (whole-array modifier).
7905                let (_first, nodes, _ms_ws, _ret) =
7906                    crate::ported::subst::multsub(&prepped, pf_flags);
7907                if std::env::var("ZSHRS_TRACE_MULTSUB").is_ok() {
7908                    eprintln!("[TRACE_MULTSUB] prepped={:?} nodes={:?}", prepped, nodes);
7909                }
7910                // c:Src/subst.c:166 — xpandbraces runs AFTER prefork's
7911                // substitution pass and BEFORE untokenize/glob. Per
7912                // word, scan for Inbrace TOKEN and expand. Words that
7913                // don't contain Inbrace TOKEN pass through unchanged.
7914                // Brace expansion is done here (inside the bridge
7915                // default arm) instead of via a post-EXPAND_TEXT
7916                // BRACE_EXPAND emit because untokenize (line below)
7917                // strips TOKEN bytes, after which the strict-TOKEN
7918                // xpandbraces gate would no longer match.
7919                let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
7920                // c:Src/options.c — `no_brace_expand` (negated
7921                // `braceexpand`) gates brace expansion entirely.
7922                // When off, `{a,b}` stays literal.
7923                let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
7924                let pre_brace: Vec<String> = if nodes.is_empty() {
7925                    vec![String::new()]
7926                } else {
7927                    nodes
7928                };
7929                let brace_expanded: Vec<String> = pre_brace
7930                    .into_iter()
7931                    .flat_map(|w| {
7932                        if brace_expand && w.contains('\u{8f}') {
7933                            crate::ported::glob::xpandbraces(&w, brace_ccl)
7934                        } else {
7935                            vec![w]
7936                        }
7937                    })
7938                    .collect();
7939                // zsh stores the option as `glob` (default ON);
7940                // `setopt noglob` writes `glob=false`. Honor either
7941                // form so the dispatcher behaves the same as zsh.
7942                // Mode 7 = redirect-target word: glob only under
7943                // MULTIOS (c:Src/glob.c:2161-2167 xpandredir,
7944                // "Globbing is only done for multios.").
7945                let noglob = opt_state_get("noglob").unwrap_or(false)
7946                    || opt_state_get("GLOB").map(|v| !v).unwrap_or(false)
7947                    || !opt_state_get("glob").unwrap_or(true)
7948                    || (mode == 7 && !opt_state_get("multios").unwrap_or(true));
7949                let parts: Vec<String> = brace_expanded
7950                    .into_iter()
7951                    .flat_map(|s| {
7952                        // The lexer leaves glob metacharacters in their
7953                        // META-encoded form: `*` → `\u{87}`, `?` →
7954                        // `\u{86}`, `[` → `\u{91}`, etc. expand_string
7955                        // doesn't untokenize them, so the literal-char
7956                        // checks below (`s.contains('*')`) would miss
7957                        // every real glob and skip expand_glob — that
7958                        // bug let `echo *.toml` print the literal
7959                        // `*.toml` because the META `\u{87}` never
7960                        // matched the literal `*`. Untokenize once so
7961                        // the metacharacter checks see the canonical
7962                        // form. zsh's pattern.c expects `*` etc. as
7963                        // bare chars at the glob layer.
7964                        // c:Src/pattern.c:4306 haswilds on the still-
7965                        // TOKENIZED word (pre-untokenize), matching C's
7966                        // zglob entry gate (Src/glob.c:1230) which runs
7967                        // on the lexer-tokenized string. haswilds
7968                        // matches ONLY token codes: source-level
7969                        // `*.toml` carries Star and fires; bare literal
7970                        // `[`/`*`/`?` from `$'...'` decode, `:-`
7971                        // default values, or nested-substitution
7972                        // results were never shtokenize'd (C
7973                        // subst.c:3231 sets globsubst=0 in the `:-`
7974                        // arm) and stay literal — bug #625. Plain
7975                        // multibyte text (`↔`) never matches a token
7976                        // codepoint — bug #627.
7977                        let is_glob_pre = !noglob && crate::ported::pattern::haswilds(&s);
7978                        let s = crate::lex::untokenize(&s);
7979                        // Skip glob expansion for assignment-shaped
7980                        // words (`NAME=value`). zsh doesn't expand the
7981                        // RHS of an assignment as a path glob unless
7982                        // `setopt globassign` is set, and feeding such
7983                        // words through expand_glob makes NOMATCH
7984                        // (default ON) fire spuriously on
7985                        // `integer i=2*3+1`, `path=*.rs`, etc.
7986                        let is_assignment_shape = {
7987                            let bytes = s.as_bytes();
7988                            let mut i = 0;
7989                            if !bytes.is_empty()
7990                                && (bytes[0] == b'_' || bytes[0].is_ascii_alphabetic())
7991                            {
7992                                i += 1;
7993                                while i < bytes.len()
7994                                    && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric())
7995                                {
7996                                    i += 1;
7997                                }
7998                                i < bytes.len() && bytes[i] == b'='
7999                            } else {
8000                                false
8001                            }
8002                        };
8003                        // Glob-trigger decision: pre-untokenize
8004                        // haswilds_tokens_only result (computed above
8005                        // before the untokenize that collapses META
8006                        // tokens to their ASCII forms). The TOKEN-only
8007                        // gate matches C `Src/pattern.c:4306-4376`
8008                        // exactly — only Inbrack/Star/Quest/Inpar/Bar/
8009                        // Inang/Pound/Hat token codes count as wild,
8010                        // not their literal ASCII counterparts. Source-
8011                        // level `*.toml` carries Star token so globs;
8012                        // `$'…'`-decoded `[abc]` carries bare `[` so
8013                        // stays literal. Bug #625.
8014                        if is_glob_pre && !is_assignment_shape {
8015                            exec.expand_glob(&s)
8016                        } else {
8017                            vec![s]
8018                        }
8019                    })
8020                    .collect();
8021                if parts.len() == 1 {
8022                    let only = parts.into_iter().next().unwrap_or_default();
8023                    // Empty unquoted expansion → drop the arg entirely
8024                    // (zsh "remove empty unquoted words" rule). Returning
8025                    // an empty Value::Array makes pop_args contribute zero
8026                    // items. Direct port of subst.c's empty-elide pass at
8027                    // the end of multsub which removes empty linknodes
8028                    // from unquoted contexts. Quoted DQ/SQ paths (modes
8029                    // 1/2/5) take separate arms above and always emit
8030                    // Value::Str so the empty arg survives.
8031                    if only.is_empty() {
8032                        Value::Array(Vec::new())
8033                    } else {
8034                        Value::str(only)
8035                    }
8036                } else {
8037                    Value::Array(parts.into_iter().map(Value::str).collect())
8038                }
8039            }
8040        });
8041        // Pull any inner cmd-subst (`` `cmd` `` via mode 3 or via
8042        // mode 0/6 multsub → getoutput, `$(cmd)` via the default
8043        // arm's multsub path, nested `$()`s reached through
8044        // stringsubst) back into vm.last_status so a containing
8045        // assignment's BUILTIN_SET_VAR — which reads vm.last_status —
8046        // sees the cmd-subst's exit. Without this, backtick
8047        // assignments (`a=\`false\`; echo $?`) reported 0 because the
8048        // ported LASTVAL update never reached the VM-side counter.
8049        let cs_status = with_executor(|exec| exec.last_status());
8050        vm.last_status = cs_status;
8051        result_value
8052    });
8053
8054    // `${#name}` — pops [name]. Returns the value's element count for
8055    // arrays (indexed and assoc) or character length for scalars.
8056    // BUILTIN_PARAM_LENGTH — `${#name}`. PURE PASSTHRU.
8057    vm.register_builtin(BUILTIN_PARAM_LENGTH, |vm, _argc| {
8058        let name = vm.pop().to_str();
8059        // PARAM_LENGTH's empty-result semantics differ from
8060        // paramsubst_to_value: 0 nodes → "0" (numeric length), not
8061        // empty array. paramsubst on `${#X}` always returns at least
8062        // one node in practice (the length string); the empty case
8063        // is defensive.
8064        let mut ret_flags: i32 = 0;
8065        let (_full, _pos, nodes) = crate::ported::subst::paramsubst(
8066            &format!("${{#{}}}", name),
8067            0,
8068            false,
8069            0i32,
8070            &mut ret_flags,
8071        );
8072        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
8073            with_executor(|exec| exec.set_last_status(1));
8074        }
8075        if nodes.is_empty() {
8076            Value::str("0")
8077        } else {
8078            nodes_to_value(nodes)
8079        }
8080    });
8081
8082    // `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
8083    // `${var/%pat/repl}` — Pops [name, pattern, replacement, op_byte].
8084    // op: 0=first, 1=all, 2=anchor-prefix (`/#`), 3=anchor-suffix (`/%`).
8085    // BUILTIN_PARAM_REPLACE — `${var/pat/repl}` / `${var//pat/repl}` /
8086    // `${var/#pat/repl}` / `${var/%pat/repl}`. PURE PASSTHRU.
8087    vm.register_builtin(BUILTIN_PARAM_REPLACE, |vm, _argc| {
8088        let dq_flag = vm.pop().to_int() != 0;
8089        let op = vm.pop().to_int() as u8;
8090        let repl = vm.pop().to_str();
8091        let pattern = vm.pop().to_str();
8092        let name = vm.pop().to_str();
8093        // DQ context: C's lexer marks every `$` inside double quotes
8094        // as the Qstring token (Src/lex.c dquote_parse) and keeps `'`
8095        // a plain char — so a DQ replacement's `$'…'` is LITERAL in
8096        // C (Src/subst.c:301 decodes only the tokenized Snull form;
8097        // `"${a/X/$'\0'}"` keeps the five chars `$'\0'`). The body
8098        // rebuilt below re-enters stringsubst as raw text, which
8099        // would mis-decode `$'…'` as ANSI-C; stamp the Qstring
8100        // marker on the repl's `$` so stringsubst sees the same DQ
8101        // signal C's tokens carry. The PATTERN side keeps decoding
8102        // (matches observed zsh: the pattern's `$'\0'` matches a
8103        // real NUL while the repl's stays literal).
8104        let repl = if dq_flag {
8105            repl.replace('$', "\u{8c}")
8106        } else {
8107            repl
8108        };
8109        // op encoding: 0 = first `/`, 1 = all `//`, 2 = anchor-prefix
8110        // `/#`, 3 = anchor-suffix `/%`. The brace form distinguishes
8111        // first-vs-all by single vs doubled slash, and anchored by
8112        // a `#` or `%` immediately after the slash(es).
8113        let body = match op {
8114            0 => format!("${{{}/{}/{}}}", name, pattern, repl),
8115            1 => format!("${{{}//{}/{}}}", name, pattern, repl),
8116            2 => format!("${{{}/#{}/{}}}", name, pattern, repl),
8117            3 => format!("${{{}/%{}/{}}}", name, pattern, repl),
8118            _ => format!("${{{}/{}/{}}}", name, pattern, repl),
8119        };
8120        // c:Src/subst.c:1625 — paramsubst's qt flag. The compiler
8121        // threads the word's DQ context onto the stack; dropping it
8122        // (the old `let _dq_flag`) ran the rebuilt body with qt=false
8123        // whenever the opcode fired outside an EXPAND_TEXT scope, so
8124        // DQ-only semantics inside the replacement (e.g. `$'` staying
8125        // literal per Src/subst.c:301 — `"${a/x/$'\t'q}"`) were lost.
8126        // Bump in_dq_context exactly like EXPAND_TEXT mode 1 so
8127        // paramsubst_to_value's qt probe sees the right context.
8128        if dq_flag {
8129            with_executor(|exec| exec.in_dq_context += 1);
8130        }
8131        let ret = paramsubst_to_value(&body);
8132        if dq_flag {
8133            with_executor(|exec| exec.in_dq_context -= 1);
8134        }
8135        ret
8136    });
8137
8138    vm.register_builtin(BUILTIN_REGISTER_COMPILED_FN, |vm, argc| {
8139        let args = pop_args(vm, argc);
8140        let mut iter = args.into_iter();
8141        let name = iter.next().unwrap_or_default();
8142        let body_b64 = iter.next().unwrap_or_default();
8143        let body_source = iter.next().unwrap_or_default();
8144        let line_base_str = iter.next().unwrap_or_default();
8145        let line_base: i64 = line_base_str.parse().unwrap_or(0);
8146        let bytes = base64_decode(&body_b64);
8147        let status = match bincode::deserialize::<fusevm::Chunk>(&bytes) {
8148            Ok(chunk) => with_executor(|exec| {
8149                // c:Src/exec.c:5383 — `shf->filename =
8150                // ztrdup(scriptfilename);` — the function's
8151                // definition-file is read from the canonical
8152                // file-scope `scriptfilename` global at compile
8153                // time, NOT from a per-executor struct field.
8154                // exec.scriptfilename is seeded once at
8155                // bins/zshrs.rs:1717 to the bin basename ("zsh")
8156                // and never updates on source/dot, so reading from
8157                // it left every user function's def_file as "zsh".
8158                // Route through scriptfilename_get() so source /
8159                // dot's set_scriptfilename calls propagate.
8160                let def_file = crate::ported::utils::scriptfilename_get()
8161                    .or_else(|| exec.scriptfilename.clone());
8162                if !body_source.is_empty() {
8163                    exec.function_source
8164                        .insert(name.clone(), body_source.clone());
8165                }
8166                exec.function_line_base.insert(name.clone(), line_base);
8167                exec.function_def_file.insert(name.clone(), def_file);
8168                // PFA-SMR aspect: every `name() {}` / `function name { }`
8169                // funnels through here at compile time. Emit one record
8170                // with the function name + raw body source.
8171                #[cfg(feature = "recorder")]
8172                if crate::recorder::is_enabled() {
8173                    let ctx = exec.recorder_ctx();
8174                    let body = if body_source.is_empty() {
8175                        None
8176                    } else {
8177                        Some(body_source.as_str())
8178                    };
8179                    crate::recorder::emit_function(&name, body, ctx);
8180                }
8181                // Mirror into canonical shfunctab so scanfunctions /
8182                // ${(k)functions} / functions builtin see user defs.
8183                // C: exec.c:funcdef → shfunctab->addnode(ztrdup(name),shf).
8184                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
8185                    let mut shf = crate::ported::hashtable::shfunc_with_body(&name, &body_source);
8186                    // c:Src/exec.c:5409 — `shf->lineno = lineno;`. Use
8187                    // the same max(1, line_base) clamp as the synth_shf
8188                    // in vm_helper::dispatch_function_call. Bug #396.
8189                    shf.lineno = std::cmp::max(1, line_base);
8190                    tab.add(shf);
8191                }
8192                // c:Src/exec.c:5460-5475 — `TRAP<SIG>() { ... }` is the
8193                // function-named trap install. zsh detects the `TRAP`
8194                // prefix at func-def time and calls
8195                // `settrap(signum, NULL, ZSIG_FUNC)` so the next
8196                // dispatch of that signal routes to the named shfunc.
8197                // Bug #157 in docs/BUGS.md — fusevm_bridge's funcdef
8198                // opcode skipped this dispatch entirely, so TRAPEXIT /
8199                // TRAPUSR1 / TRAPZERR / TRAPDEBUG never fired.
8200                if name.len() > 4 && name.starts_with("TRAP") {
8201                    if let Some(sn) = crate::ported::jobs::getsigidx(&name[4..]) {
8202                        let _ = crate::ported::signals::settrap(
8203                            sn,
8204                            None,
8205                            crate::ported::zsh_h::ZSIG_FUNC as i32,
8206                        );
8207                    }
8208                }
8209                exec.functions_compiled.insert(name, chunk);
8210                0
8211            }),
8212            Err(_) => 1,
8213        };
8214        Value::Status(status)
8215    });
8216
8217    // Wire the ShellHost so direct shell ops (Op::Glob, Op::TildeExpand,
8218    // Op::ExpandParam, Op::CmdSubst, Op::CallFunction, etc.) route through
8219    // ZshrsHost back into the executor.
8220    vm.set_shell_host(Box::new(ZshrsHost));
8221}
8222
8223impl ZshrsHost {
8224    /// True iff `c` can be a `(j:…:)` / `(s:…:)` delimiter — non-alphanumeric,
8225    /// non-underscore. Restricting to punctuation avoids `(jL)` consuming `L`
8226    /// as a delim instead of as the next flag.
8227    fn is_zsh_flag_delim(c: char) -> bool {
8228        !c.is_ascii_alphanumeric() && c != '_'
8229    }
8230}
8231
8232/// Shared `${name[idx]}` subscript dispatch for BUILTIN_ARRAY_INDEX
8233/// and the KSHARRAYS-unset arm of BUILTIN_ARRAY_INDEX_UNBRACED.
8234///
8235/// c:Src/subst.c subscript parsing — when paramsubst re-parses the
8236/// synthesized `${name[idx]}` body, characters like `'` `"` `\` `$`
8237/// etc. are LEXER-active inside the `[…]` and get reinterpreted
8238/// (quote-strip, paramsubst recursion, …). For PRE-EVALUATED key
8239/// strings (the dynamic-key fast path at compile_zsh.rs:3234 already
8240/// expanded `$k` via EXPAND_TEXT), the idx is a literal string that
8241/// must match the stored key byte-for-byte — no further
8242/// reinterpretation. Direct assoc lookup bypasses the lexer for this
8243/// case, avoiding the quote-strip bug where `h[a'b]` failed to
8244/// resolve because paramsubst's subscript lexer treated the `'` as a
8245/// quote. Bug #338. Only fires for simple assoc-name + non-flag idx
8246/// (no outer-flag sentinels, no `(…)` flag prefix on idx, no splat
8247/// operator). Other paths (slice, splat, flag-based search,
8248/// magic-assoc) still flow through paramsubst.
8249fn array_index_lookup(name: &str, idx: &str) -> Value {
8250    let idx_is_simple = !idx.starts_with('(') && idx != "@" && idx != "*" && !idx.contains(',');
8251    if idx_is_simple {
8252        if let Some(v) =
8253            with_executor(|exec| exec.assoc(name).and_then(|m| m.get(idx).cloned()))
8254        {
8255            return Value::str(v);
8256        }
8257    }
8258    let body = format!("${{{}[{}]}}", name, idx);
8259    paramsubst_to_value(&body)
8260}
8261
8262/// KSHARRAYS bare-`$name` expansion words for the unbraced
8263/// no-subscript form (BUILTIN_ARRAY_INDEX_UNBRACED's KSHARRAYS arm).
8264///
8265/// - `@` / `*` stay the full positional list (the c:Src/params.c:
8266///   2293-2296 first-element collapse is gated on
8267///   `itype_end(t, IIDENT, 1) != t` — an identifier-shaped name —
8268///   which `@`/`*` are not). The literal `[idx]` then joins the LAST
8269///   word, matching zsh 5.9: `setopt ksharrays; set -- p q;
8270///   print -- $@[0]` → `zsh:1: no matches found: q[0]`.
8271/// - Identifier-named arrays collapse to the FIRST element
8272///   (c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`).
8273/// - Assocs collapse to the first value in scan order; the `options`
8274///   magic assoc's scan order is `OPTIONTAB` bucket order (first key
8275///   `posixargzero`), matching zsh 5.9: `emulate sh -L;
8276///   print $options[posixargzero]` → `off[posixargzero]`.
8277/// - Scalars / unset names expand to their value / empty (zsh 5.9:
8278///   `setopt ksharrays; print -- $unsetvar[0]` →
8279///   `zsh:1: no matches found: [0]`).
8280fn ksharrays_bare_words(name: &str) -> Vec<String> {
8281    if name == "@" || name == "*" {
8282        return with_executor(|exec| exec.pparams());
8283    }
8284    // Magic special-parameter lookups first — mirrors the
8285    // BUILTIN_GET_VAR precedence (partab before executor tables).
8286    if let Some(vals) = crate::vm_helper::partab_array_get(name) {
8287        return vec![vals.into_iter().next().unwrap_or_default()];
8288    }
8289    if let Some(keys) = crate::vm_helper::partab_scan_keys(name) {
8290        let v = keys
8291            .first()
8292            .and_then(|k| crate::vm_helper::partab_get(name, k))
8293            .unwrap_or_default();
8294        return vec![v];
8295    }
8296    let arr_or_assoc = with_executor(|exec| {
8297        if let Some(arr) = exec.array(name) {
8298            // c:Src/params.c:2293-2296 — first element only.
8299            return Some(arr.first().cloned().unwrap_or_default());
8300        }
8301        if let Some(map) = exec.assoc(name) {
8302            // Mirrors BUILTIN_GET_VAR's bare-assoc ordering
8303            // (sorted keys, first value).
8304            let mut keys: Vec<&String> = map.keys().collect();
8305            keys.sort();
8306            return Some(
8307                keys.first()
8308                    .and_then(|k| map.get(*k).cloned())
8309                    .unwrap_or_default(),
8310            );
8311        }
8312        None
8313    });
8314    if let Some(v) = arr_or_assoc {
8315        return vec![v];
8316    }
8317    vec![with_executor(|exec| exec.get_variable(name))]
8318}
8319
8320/// Run `body` through `crate::ported::subst::paramsubst` and convert
8321/// the resulting node list into a fusevm `Value`. Centralises the
8322/// pattern duplicated across ~10 BUILTIN_* handlers:
8323///   - build a `${...}` body string from opcode operands
8324///   - paramsubst the body
8325///   - propagate errflag to `exec.last_status`
8326///   - delegate the LinkList → Value conversion to `nodes_to_value`
8327///
8328/// **Extension** — Rust-only helper. No direct C analog because C
8329/// zsh uses LinkList everywhere; the conversion happens at the
8330/// boundary back into the VM's stack.
8331fn paramsubst_to_value(body: &str) -> Value {
8332    // c:Src/subst.c:1625 paramsubst's `qt` flag is the C signal that
8333    // the current expansion is inside `"…"`. The fast-path bridges
8334    // (BUILTIN_PARAM_*, BUILTIN_BRIDGE_BRACE_ARRAY) used to hardcode
8335    // qt=false, which silently broke DQ-only semantics inside
8336    // `${arr:^other}` / `${arr:^^other}` (Src/subst.c:3456-3520).
8337    // The executor's `in_dq_context` counter is bumped by EXPAND_TEXT
8338    // mode 1 / mode 5 before the bridge fires, so reading it here
8339    // propagates the DQ flag without changing every bridge call site.
8340    let qt = with_executor(|exec| exec.in_dq_context > 0);
8341    let mut ret_flags: i32 = 0;
8342    let (_full, _pos, nodes) = crate::ported::subst::paramsubst(body, 0, qt, 0i32, &mut ret_flags);
8343    if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
8344        with_executor(|exec| exec.set_last_status(1));
8345    }
8346    // c:Src/lex.c untokenize — the final argv pass C runs on every
8347    // expanded word (glob.c:1862 / exec.c) DROPS the Nularg
8348    // empty-word sentinel (c:2089 `if (c != Nularg)`) that
8349    // remnulargs faithfully leaves in place (glob.c:3673 re-adds it
8350    // for all-empty results). These fast-path bridges are terminal —
8351    // their output lands directly in argv slots — so apply it here.
8352    // Without it, quoted splits with empty pieces ("${(s:|:)x}" on
8353    // "|a|b|") leak U+00A1 into argv.
8354    let nodes: Vec<String> = nodes
8355        .into_iter()
8356        .map(|n| crate::ported::lex::untokenize(&n))
8357        .collect();
8358    nodes_to_value(nodes)
8359}
8360
8361/// Wrap a `Vec<String>` (e.g. paramsubst nodes, multsub parts,
8362/// xpandbraces output) into a fusevm `Value`: 0 → empty Array, 1 →
8363/// Str, >1 → Array. Same unwrap idiom every handler that calls a
8364/// canonical Vec-returning fn does.
8365fn nodes_to_value(nodes: Vec<String>) -> Value {
8366    // c:Src/glob.c:3649 remnulargs — strip the Nularg (`\u{a1}`)
8367    //   sentinel and other INULL bytes that paramsubst's splat block
8368    //   emits for empty array elements (so prefork's empty-node-delete
8369    //   pass doesn't drop them). Downstream consumers (cond `-z`/`-n`,
8370    //   command args, etc.) must see the post-remnulargs strings. Bug
8371    //   #185 in docs/BUGS.md: `[[ -z "${b[@]}" ]]` for b=("") returned
8372    //   false because the leftover `\u{a1}` had StringLen=1.
8373    let stripped: Vec<String> = nodes
8374        .into_iter()
8375        .map(|mut s| {
8376            crate::ported::glob::remnulargs(&mut s);
8377            s
8378        })
8379        .collect();
8380    if stripped.is_empty() {
8381        Value::Array(Vec::new())
8382    } else if stripped.len() == 1 {
8383        let only = stripped.into_iter().next().unwrap();
8384        // c:Src/subst.c:183-186 — `else if (!(flags & PREFORK_SINGLE)
8385        // && !(*ret_flags & PREFORK_KEY_VALUE) && !keep)
8386        //   uremnode(list, node);`
8387        // C zsh's prefork removes empty linknodes from the result
8388        // list when in non-SINGLE (argv-context) mode. The ported
8389        // prefork at subst.rs:388-396 honors the same delete-empty
8390        // pass, but some paramsubst paths land here with a single-
8391        // empty-string Vec instead of an empty Vec (paramsubst's
8392        // slice / substring / parameter-flag branches allocate a
8393        // result before checking emptiness). Mirror the prefork
8394        // drop at this layer: single-empty under !in_dq_context
8395        // collapses to Value::Array(empty), and pop_args (line 6243)
8396        // splats the empty Array → zero argv words. DQ context
8397        // (in_dq_context > 0) keeps the empty string so
8398        // `echo "${UNSET}"` still produces an empty arg per zsh's
8399        // quoting rules (c:Src/subst.c:1650-1656 isarr comment).
8400        if only.is_empty() {
8401            let in_dq = with_executor(|exec| exec.in_dq_context > 0);
8402            if !in_dq {
8403                return Value::Array(Vec::new());
8404            }
8405        }
8406        Value::str(only)
8407    } else {
8408        Value::Array(stripped.into_iter().map(Value::str).collect())
8409    }
8410}
8411
8412fn pop_args(vm: &mut fusevm::VM, argc: u8) -> Vec<String> {
8413    let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
8414    for _ in 0..argc {
8415        popped.push(vm.pop());
8416    }
8417    popped.reverse();
8418    let mut args: Vec<String> = Vec::with_capacity(popped.len());
8419    for v in popped {
8420        match v {
8421            Value::Array(items) => {
8422                for item in items {
8423                    args.push(item.to_str());
8424                }
8425            }
8426            other => args.push(other.to_str()),
8427        }
8428    }
8429    // `expand_glob` set the glob-failed cell when a no-match glob
8430    // triggered nomatch (c:Src/glob.c:1877). Signal the failure via
8431    // last_status + the per-command glob_failed cell; the dispatcher
8432    // (`host_exec_external`) consumes + clears it and returns status 1
8433    // without running the command body.
8434    if with_executor(|exec| exec.current_command_glob_failed.get()) {
8435        with_executor(|exec| exec.set_last_status(1));
8436    }
8437    // `$_` tracks the last argument of the PREVIOUSLY executed
8438    // command (zsh / bash convention). Promote the deferred value
8439    // into `$_` BEFORE this command runs (so `echo $_` reads the
8440    // prior command's last arg) then stash THIS command's last arg
8441    // for the next dispatch.
8442    let new_last = args.last().cloned();
8443    with_executor(|exec| {
8444        if let Some(prev) = exec.pending_underscore.take() {
8445            exec.set_scalar("_".to_string(), prev);
8446        }
8447        if let Some(last) = new_last {
8448            exec.pending_underscore = Some(last);
8449        }
8450    });
8451    args
8452}
8453
8454/// zsh dispatch order is alias → function → builtin → external. The
8455/// compiler emits direct CallBuiltin ops for known builtin names for
8456/// perf, which silently skips a user function that shadows the same
8457/// name (e.g. `echo() { ... }; echo hi` would run the C builtin
8458/// without this check). Returns Some(status) when the call is routed
8459/// to the user function; the builtin handler should fall through to
8460/// its native impl when None.
8461/// Fork+exec a system binary by name. Used by `reg_overridable!` as
8462/// the fall-through path when `[builtins].coreutils_shadows = off`
8463/// (the default) — runs the canonical `/bin/X` instead of zshrs's
8464/// in-process shadow so old scripts hit zero behavioral divergence.
8465///
8466/// Inherits stdin/stdout/stderr from the parent so pipelines work
8467/// transparently. Resolves the binary via PATH; mirrors what zsh's
8468/// own external-command dispatch would do. Returns the child's exit
8469/// status (or 127 if PATH lookup fails — the standard "command not
8470/// found" code).
8471fn exec_system_command(name: &str, args: &[String]) -> i32 {
8472    // c:Src/jobs.c — count the fork so `time` reports for an
8473    // overridable coreutils shadow run as an external (`time sleep 0`,
8474    // `time cat …`). This is a distinct spawn path from
8475    // execute_external_bg; without the bump BUILTIN_TIME_SUBLIST saw no
8476    // job and stayed silent. (Builtins that don't reach a spawn never
8477    // hit this fn.)
8478    crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
8479    let status = std::process::Command::new(name)
8480        .args(args)
8481        .stdin(std::process::Stdio::inherit())
8482        .stdout(std::process::Stdio::inherit())
8483        .stderr(std::process::Stdio::inherit())
8484        .status();
8485    match status {
8486        Ok(s) => s.code().unwrap_or(if s.success() { 0 } else { 1 }),
8487        Err(e) => {
8488            eprintln!("zshrs: {}: {}", name, e);
8489            127
8490        }
8491    }
8492}
8493
8494fn try_user_fn_override(name: &str, args: &[String]) -> Option<i32> {
8495    let has_fn = with_executor(|exec| {
8496        exec.functions_compiled.contains_key(name) || exec.function_exists(name)
8497    });
8498    if !has_fn {
8499        return None;
8500    }
8501    Some(with_executor(|exec| {
8502        exec.dispatch_function_call(name, args).unwrap_or(127)
8503    }))
8504}
8505
8506/// Builtin ID for `${name}` reads — routes through canonical
8507/// `getsparam` (Src/params.c:3076) via paramtab + env walk so nested
8508/// VMs (function calls) see the same storage.
8509pub const BUILTIN_GET_VAR: u16 = 283;
8510
8511/// Builtin ID for `name=value` assignments — pops [name, value] and
8512/// routes through canonical `setsparam` (Src/params.c:3350).
8513pub const BUILTIN_SET_VAR: u16 = 284;
8514
8515/// Builtin ID for pipeline execution. Pops N sub-chunk indices from the stack;
8516/// each index points into `vm.chunk.sub_chunks` (compiled stage bodies). Forks
8517/// N children, wires stdin/stdout between them via pipes, runs each stage's
8518/// bytecode on a fresh VM in its child, parent waits for all and pushes the
8519/// last stage's exit status. This is bytecode-native pipeline execution —
8520/// no tree-walker delegation.
8521pub const BUILTIN_RUN_PIPELINE: u16 = 285;
8522
8523/// Builtin ID for `Array → String` joining. Pops one value: if it's an Array,
8524/// joins its string-coerced elements with a single space; otherwise passes
8525/// through. Used after `Op::Glob` to convert the pattern's matched paths into
8526/// the single argv-token form the bytecode word model expects (no per-word
8527/// splitting yet — that's a future phase).
8528pub const BUILTIN_ARRAY_JOIN: u16 = 286;
8529
8530/// Builtin ID for `cmd &` background execution. IDs 287/288/289 are reserved
8531/// for the planned array work in Phase G1 (SET_ARRAY/SET_ASSOC/ARRAY_INDEX),
8532/// so this lands at 290. Pops the sub-chunk index then the job text; forks;
8533/// child detaches (`setsid`), runs the sub-chunk on a fresh VM, exits with
8534/// last_status; parent registers the job in the canonical JOBTAB
8535/// (initjob/addproc/spawnjob per c:Src/exec.c:1700-1758) so `jobs` / `wait
8536/// %N` / `kill %N` / `disown` and the zsh/parameter assocs all see it, then
8537/// returns Status(0) immediately.
8538pub const BUILTIN_RUN_BG: u16 = 290;
8539
8540/// Indexed-array assignment: `arr=(a b c)`. Compile_simple emits N element
8541/// pushes followed by name push, then `CallBuiltin(BUILTIN_SET_ARRAY, N+1)`.
8542/// The handler pops args (last popped = name in our pushing order) and stores
8543/// `Vec<String>` into `executor.arrays`. Tree-walker callers see the same
8544/// storage. Any prior scalar binding in `executor.variables` for `name` is
8545/// removed so `${name}` (scalar context) consistently reflects the array's
8546/// first element via `get_variable`.
8547pub const BUILTIN_SET_ARRAY: u16 = 287;
8548
8549/// Single-key set on an associative array: `foo[key]=val`. Stack (top-down):
8550/// [name, key, value]. Stores `value` into `executor.assoc_arrays[name][key]`,
8551/// creating the outer entry if missing. compile_simple detects `var[...]=...`
8552/// in assignments and emits this builtin.
8553pub const BUILTIN_SET_ASSOC: u16 = 288;
8554
8555/// `${arr[idx]}` — single-element array index. Pops two args:
8556///   stack: [name, idx_str]
8557/// Returns the indexed element as Value::str. Indexing semantics: zsh is
8558/// 1-based by default; bash is 0-based. We follow zsh.
8559/// Special idx values: `@` and `*` return the whole array as Value::Array
8560/// (which fuses correctly via the Op::Exec splice for argv splice).
8561pub const BUILTIN_ARRAY_INDEX: u16 = 289;
8562
8563/// `${#arr[@]}` and `${#arr}` (when arr is an array name) — array length.
8564/// Pops one arg: name. Returns Value::str of len.
8565
8566/// `${arr[@]}` — splice all elements as a Value::Array. Pops one arg: name.
8567/// The Array gets flattened by Op::Exec/ExecBg/CallFunction into argv.
8568pub const BUILTIN_ARRAY_ALL: u16 = 292;
8569
8570/// Flatten one level of Value::Array nesting. Pops N values; for each, if it's
8571/// a Value::Array, its elements are appended directly; otherwise the value is
8572/// appended as-is. Pushes a single Value::Array of the flattened result. Used
8573/// by the for-loop word-list compile path: when a word like `${arr[@]}`
8574/// produces a nested Array, this lets `for i in ${arr[@]}` iterate over the
8575/// inner elements rather than the outer single-element array.
8576pub const BUILTIN_ARRAY_FLATTEN: u16 = 293;
8577
8578/// `coproc [name] { body }` — bidirectional pipe to async child. Pops a name
8579/// (optional, "" for default) and a sub-chunk index. Creates two pipes, forks,
8580/// child redirects its fd 0/1 to the inner ends and runs the body, parent
8581/// stores [write_fd, read_fd] into the named array (default `COPROC`). Caller
8582/// closes the fds and `wait`s when done. Job-table integration deferred to
8583/// Phase G6 alongside the bg `&` work.
8584pub const BUILTIN_RUN_COPROC: u16 = 294;
8585
8586/// `arr+=(d e f)` — append N elements to an existing indexed array. Compile
8587/// emits N element pushes + name push, then `CallBuiltin(295, N+1)`. Handler
8588/// drains args (last popped = name), extends `executor.arrays[name]` (creates
8589/// the entry if missing). Mirrors zsh's `+=` semantics for indexed arrays.
8590pub const BUILTIN_APPEND_ARRAY: u16 = 295;
8591
8592/// `name[@]=(...)` / `name[*]=(...)` whole-array SET. Identical to
8593/// BUILTIN_SET_ARRAY for an indexed array / scalar (whole replace), but
8594/// rejects an associative target with "attempt to set slice of
8595/// associative array" (c:Src/params.c:3324-3327).
8596pub const BUILTIN_SET_ARRAY_AT: u16 = 633;
8597
8598/// `name[@]+=(...)` / `name[*]+=(...)` whole-array APPEND. Indexed
8599/// append (push), assoc target → same slice-of-assoc error as 633.
8600pub const BUILTIN_APPEND_ARRAY_AT: u16 = 634;
8601
8602/// `select var in words; do body; done` — interactive numbered-menu loop.
8603/// Compile emits N word pushes + var-name push + sub-chunk index push, then
8604/// `CallBuiltin(296, N+2)`. Handler prints `1) word1\n2) word2\n...` to
8605/// stderr, prints `$PROMPT3` (default `?# `) to stderr, reads a line from
8606/// stdin. On EOF returns 0. On a valid 1-based number, sets `var` to the
8607/// chosen word, runs the sub-chunk, then redisplays the menu and loops. On
8608/// invalid input redraws the menu without running the body. `break` from
8609/// inside the body exits the loop (handled by the body's own bytecode).
8610pub const BUILTIN_RUN_SELECT: u16 = 296;
8611
8612/// `m[k]+=value` — append onto an existing assoc-array value (string concat).
8613/// If the key doesn't exist, behaves like SET_ASSOC. Stack: [name, key, value].
8614
8615/// `break` from inside a body that runs on a sub-VM (select, future
8616/// loop-via-builtin constructs). Writes the canonical
8617/// `crate::ported::builtin::BREAKS` atomic (port of `Src/loop.c:46
8618/// breaks`). Outer-loop builtins drain BREAKS/CONTFLAG after each
8619/// body run, matching the loop.c:529-534 drain pattern.
8620pub const BUILTIN_SET_BREAK: u16 = 299;
8621
8622/// `continue` from inside a sub-VM body. Sets CONTFLAG=1 + bumps
8623/// BREAKS, matching `bin_break`'s WC_CONTINUE arm at Src/builtin.c
8624/// c:5836 `contflag = 1; FALLTHROUGH; breaks++;`.
8625pub const BUILTIN_SET_CONTINUE: u16 = 300;
8626
8627/// Brace expansion: `{a,b,c}` → 3 values, `{1..5}` → 5 values, `{01..05}` →
8628/// zero-padded numerics, `{a..e}` → letter range. Pops one string, returns
8629/// Value::Array of expansions (empty array → original string preserved).
8630pub const BUILTIN_BRACE_EXPAND: u16 = 301;
8631
8632/// Glob qualifier filter: `*(qualifier)` filters glob results by predicate.
8633/// Pops [pattern, qualifier_string]. Returns Value::Array of matching paths.
8634
8635/// Re-export the regex_match host method as a builtin so `[[ s =~ pat ]]`
8636/// works even when fusevm's Op::RegexMatch isn't routed (compat fallback).
8637
8638/// Word-split a string on IFS (default: whitespace). Pops one string,
8639/// returns Value::Array of fields. Used in array-literal context where
8640/// `arr=($(cmd))` should expand cmd's stdout into multiple elements.
8641pub const BUILTIN_WORD_SPLIT: u16 = 304;
8642
8643/// Register a pre-compiled fusevm chunk as a function. Stack: [name,
8644/// base64-bincode-of-Chunk]. Used by compile_zsh's compile_funcdef to
8645/// register functions parsed via parse_init+parse without going through the
8646/// ShellCommand JSON serialization path.
8647pub const BUILTIN_REGISTER_COMPILED_FN: u16 = 305;
8648/// `BUILTIN_VAR_EXISTS` constant.
8649pub const BUILTIN_VAR_EXISTS: u16 = 306;
8650/// Native param-modifier builtins. Each takes a fixed argv shape and
8651/// returns the modified value as Value::Str.
8652///
8653/// `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
8654/// — pop [name, op_byte, rhs]. op_byte: 0=`:-`, 1=`:=`, 2=`:?`, 3=`:+`.
8655pub const BUILTIN_PARAM_DEFAULT_FAMILY: u16 = 307;
8656/// `${var:offset[:length]}` — pop [name, offset, length] (length=-1 means
8657/// "rest of value"; negative offset counts from end).
8658pub const BUILTIN_PARAM_SUBSTRING: u16 = 308;
8659/// `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}` — pop
8660/// [name, pattern, op_byte]. op_byte: 0=`#`, 1=`##`, 2=`%`, 3=`%%`.
8661pub const BUILTIN_PARAM_STRIP: u16 = 309;
8662/// `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
8663/// `${var/%pat/repl}` — pop [name, pattern, replacement, op_byte].
8664/// op_byte: 0=first, 1=all, 2=anchor-prefix, 3=anchor-suffix.
8665pub const BUILTIN_PARAM_REPLACE: u16 = 310;
8666/// `${#name}` — character length of a scalar value, or element count
8667/// of an indexed/assoc array. Pops \[name\], returns count as Value::Str.
8668pub const BUILTIN_PARAM_LENGTH: u16 = 311;
8669/// `$((expr))` arithmetic substitution. Pops \[expr_string\], evaluates
8670/// via the executor's MathEval (integer-aware), returns result as
8671/// Value::Str. Bypasses ArithCompiler's float-only Op::Div path so
8672/// `$((10/3))` returns "3" not "3.333...".
8673pub const BUILTIN_ARITH_EVAL: u16 = 312;
8674/// `(( ... ))` math command post-eval status hook. Pops nothing,
8675/// pushes Value::Status. If errflag is set (math error in the
8676/// preceding BUILTIN_ARITH_EVAL call), clears it and emits status=2
8677/// matching c:Src/math.c arith-failure semantics. Otherwise emits
8678/// the current vm.last_status. Used by compile_arith's `(( ... ))`
8679/// path so the math command swallows errors without halting the
8680/// script — `$((... ))` substitutions skip this hook so their
8681/// errflag propagates up to the containing command.
8682pub const BUILTIN_ARITH_CMD_FINISH: u16 = 527;
8683/// `$(cmd)` command substitution. Pops \[cmd_string\], runs through
8684/// `run_command_substitution` which compiles via parse_init+parse + ZshCompiler
8685/// and captures stdout via an in-process pipe. Returns trimmed output
8686/// as Value::Str. Avoids the sub-chunk word-emit quoting bug in the
8687/// raw Op::CmdSubst path.
8688pub const BUILTIN_CMD_SUBST_TEXT: u16 = 313;
8689/// Text-based word expansion. Pops \[preserved_text\]: the word with
8690/// quotes preserved (Dnull→`"`, Snull→`'`, Bnull→`\`), runs
8691/// `expand_string` (variable + cmd-sub + arith) then `xpandbraces`
8692/// then `expand_glob`. Returns Value::str (single match) or
8693/// Value::Array (multi-match brace/glob).
8694pub const BUILTIN_EXPAND_TEXT: u16 = 314;
8695
8696/// `[[ a -ef b ]]` — same-inode test. Stack: [a, b]. Pushes Bool true iff
8697/// both paths resolve to the same `(dev, inode)` pair (zsh + bash semantics).
8698pub const BUILTIN_SAME_FILE: u16 = 315;
8699
8700/// `[[ a -nt b ]]` — file `a` newer than file `b` (mtime strict).
8701/// Stack: [path_a, path_b]. Pushes Bool. zsh-compatible "missing"
8702/// rules: if both exist, compare mtime; if only `a` exists → true;
8703/// otherwise false.
8704pub const BUILTIN_FILE_NEWER: u16 = 324;
8705
8706/// `[[ a -ot b ]]` — mirror of `-nt`. If both exist, compare mtime;
8707/// if only `b` exists → true; otherwise false.
8708pub const BUILTIN_FILE_OLDER: u16 = 325;
8709
8710/// `[[ -k path ]]` — sticky bit (S_ISVTX) set on path.
8711pub const BUILTIN_HAS_STICKY: u16 = 326;
8712/// `[[ -u path ]]` — setuid bit (S_ISUID).
8713pub const BUILTIN_HAS_SETUID: u16 = 327;
8714/// `[[ -g path ]]` — setgid bit (S_ISGID).
8715pub const BUILTIN_HAS_SETGID: u16 = 328;
8716/// `[[ -O path ]]` — owned by effective UID.
8717pub const BUILTIN_OWNED_BY_USER: u16 = 329;
8718/// `[[ -G path ]]` — owned by effective GID.
8719pub const BUILTIN_OWNED_BY_GROUP: u16 = 330;
8720/// `[[ -N path ]]` — file modified since last accessed (atime <= mtime).
8721pub const BUILTIN_FILE_MODIFIED_SINCE_ACCESS: u16 = 341;
8722
8723/// `name+=val` (no parens) — runtime-dispatched append.
8724/// If name is an indexed array → push val as element.
8725/// If name is an assoc array → error (zsh requires `(k v)` form).
8726/// Else → scalar concat (existing SET_VAR behavior).
8727pub const BUILTIN_APPEND_SCALAR_OR_PUSH: u16 = 331;
8728
8729/// `[[ -c path ]]` — character device.
8730pub const BUILTIN_IS_CHARDEV: u16 = 332;
8731/// `[[ -b path ]]` — block device.
8732pub const BUILTIN_IS_BLOCKDEV: u16 = 333;
8733/// `[[ -p path ]]` — FIFO / named pipe.
8734pub const BUILTIN_IS_FIFO: u16 = 334;
8735/// `[[ -S path ]]` — socket.
8736pub const BUILTIN_IS_SOCKET: u16 = 335;
8737/// `BUILTIN_ERREXIT_CHECK` constant.
8738pub const BUILTIN_ERREXIT_CHECK: u16 = 336;
8739/// Post-`always`-arm checks for the canonical RETFLAG / BREAKS /
8740/// CONTFLAG atomics that mark try-block escapes. Each returns
8741/// Value::Int(1) when the corresponding atomic is set (and consumes
8742/// it so the next escape doesn't re-fire) and Value::Int(0) otherwise.
8743/// Paired with JumpIfFalse + Jump to outer return_patches /
8744/// break_patches / continue_patches by compile_zsh's `Try` arm.
8745pub const BUILTIN_RETFLAG_CHECK: u16 = 600;
8746/// `BUILTIN_BREAKS_CHECK` constant.
8747pub const BUILTIN_BREAKS_CHECK: u16 = 601;
8748/// `BUILTIN_CONTFLAG_CHECK` constant.
8749pub const BUILTIN_CONTFLAG_CHECK: u16 = 602;
8750/// Fire the DEBUG trap (SIGDEBUG) before each statement.
8751/// c:Src/exec.c:1357-1500 DEBUGBEFORECMD — when a "DEBUG" entry is
8752/// installed via `trap '...' DEBUG`, run the body just before the
8753/// next command. Cheap when no DEBUG trap is set (one hashmap lookup
8754/// returns None and we early-out).
8755pub const BUILTIN_DEBUG_TRAP: u16 = 603;
8756/// `set -n` / `set -o noexec` — parse but don't execute. Returns
8757/// Value::Int(1) when the noexec option is set so the caller's
8758/// JumpIfTrue skips the statement body. c:Src/exec.c:1390 main loop
8759/// check.
8760pub const BUILTIN_NOEXEC_CHECK: u16 = 604;
8761/// Block-level redirect-failure gate. Reads exec.redirect_failed
8762/// (set by host.redirect when a redirect open fails); returns
8763/// Value::Int(1) AND clears the flag if set, else 0. Emit-side at
8764/// compile_zsh.rs::compile_command's Redirected arm pairs with a
8765/// JumpIfTrue → WithRedirectsEnd to abandon the body. Without this,
8766/// a multi-statement block after a failed redir kept running every
8767/// statement after the first (the first builtin consumed the flag,
8768/// subsequent statements ran unimpeded).
8769pub const BUILTIN_REDIRECT_FAILED_CHECK: u16 = 605;
8770/// Drop-in replacement for fusevm's Op::Exec for the dynamic-first-
8771/// word path (`$cmd`, `$(cmd)`, `~/bin/foo`). Returns
8772/// Value::Status(vm.last_status) when post-expansion argv is empty
8773/// (preserves the inner cmd-subst's exit), Value::Status(126) with
8774/// "permission denied" when `argv[0]` is empty, otherwise routes
8775/// through executor.host_exec_external like Op::Exec did.
8776pub const BUILTIN_EXEC_DYNAMIC: u16 = 606;
8777/// `[[ lhs == pat ]]` / `!=` glob compare — cond-specific so the
8778/// bad-pattern diagnostic follows Src/cond.c:308-316: zwarnnam
8779/// "bad pattern: %s" WITHOUT errflag (the script continues) and the
8780/// cond statement exits 2. Stack: [lhs, pat] → Bool. On compile
8781/// failure pushes Bool(false) and arms COND_BAD_PATTERN so
8782/// BUILTIN_COND_STATUS_FROM_BOOL reports 2.
8783pub const BUILTIN_COND_STRMATCH: u16 = 624;
8784/// Pops the cond result Bool → Int status per Src/cond.c: true→0,
8785/// false→1, but 2 when COND_BAD_PATTERN was armed during this cond
8786/// (covers `!=` where LogNot flips the Bool before status time).
8787pub const BUILTIN_COND_STATUS_FROM_BOOL: u16 = 625;
8788/// `[[ ]]` unknown condition. Pops \[op_name\], emits `zerr("unknown
8789/// condition: %s")` and sets ERRFLAG_ERROR so the next BUILTIN_ERREXIT_CHECK
8790/// (trigger 4) aborts the input — matching zsh's COND_MODI "unknown condition"
8791/// path (Src/cond.c:150-188) for a `-X` op with no matching loadable module.
8792/// Returns Bool(false). Replaces a compile-time `eprintln!` hack that printed
8793/// the message but never set errflag (so the line didn't abort).
8794pub const BUILTIN_COND_UNKNOWN: u16 = 632;
8795/// Bare-`exec` redirect epilogue. Consumes `exec.redirect_failed` and
8796/// applies the C `done:` tail of execcmd_exec:
8797///   - c:Src/exec.c:252-259 execerr — `redir_err = lastval = 1` (the
8798///     failed redirect makes the exec statement exit 1, NOT fatal by
8799///     itself);
8800///   - c:Src/exec.c:4367-4386 — `if (isset(POSIXBUILTINS) && (cflags
8801///     & (BINF_PSPECIAL|BINF_EXEC)) ...) { if (redir_err || errflag)
8802///     { if (!isset(INTERACTIVE)) exit(1); } }` — POSIX_BUILTINS makes
8803///     a failed exec redirect fatal in a non-interactive shell.
8804/// Returns Value::Status(0|1) for the trailing SetStatus.
8805pub const BUILTIN_EXEC_REDIR_DONE: u16 = 626;
8806/// Assignment-prefix epilogue for bare `exec` redirects
8807/// (`x=$(cmd) exec >file`). c:Src/exec.c:3969-3976 — nullexec==1
8808/// runs addvars THEN, without POSIX_BUILTINS, restores the params
8809/// (`save_params` / `restore_params`): the RHS side effects fire but
8810/// the values don't persist. With POSIX_BUILTINS the assignments
8811/// stick. Pops the BEGIN_INLINE_ENV frame either way.
8812pub const BUILTIN_EXEC_INLINE_ENV_DONE: u16 = 627;
8813
8814/// `< file` / `> file` with no command word (NULLCMD path).
8815/// Resolves NULLCMD (default "cat") / READNULLCMD (default "more")
8816/// at runtime per Src/exec.c:3340-3364 and exec's it through
8817/// host_exec_external. Argc is 1: the int (0 or 1) on the stack
8818/// indicates whether this is a single REDIR_READ redirect
8819/// (selects READNULLCMD when set + non-empty).
8820pub const BUILTIN_NULLCMD_EXEC: u16 = 607;
8821/// `.` (dot) — alias of source/bin_dot but dispatches with the
8822/// literal name "." so the diagnostic prefix matches zsh's
8823/// (`zsh:.:1: …` vs source's `zsh:source:1: …`).
8824/// c:Src/builtin.c:9308 — `BUILTIN(".", BINF_PSPECIAL, bin_dot, …)`.
8825pub const BUILTIN_DOT: u16 = 608;
8826/// `logout` — fusevm maps this to BUILTIN_EXIT alongside `exit`/`bye`,
8827/// which drops the name and dispatches with BIN_EXIT funcid. zsh's
8828/// `logout` outside a login shell must emit "not login shell" + exit 1,
8829/// which only fires when bin_break sees BIN_LOGOUT funcid. Dedicated
8830/// opcode dispatches via BUILTINS table by literal name "logout".
8831pub const BUILTIN_LOGOUT: u16 = 610;
8832/// `BUILTIN_PARAM_SUBSTRING_EXPR` constant.
8833pub const BUILTIN_PARAM_SUBSTRING_EXPR: u16 = 337;
8834/// `BUILTIN_XTRACE_LINE` constant.
8835pub const BUILTIN_XTRACE_LINE: u16 = 338;
8836/// `BUILTIN_ARRAY_JOIN_STAR` constant.
8837pub const BUILTIN_ARRAY_JOIN_STAR: u16 = 339;
8838/// `BUILTIN_SET_RAW_OPT` constant.
8839pub const BUILTIN_SET_RAW_OPT: u16 = 340;
8840
8841/// `time { compound; ... }` — wall-clock-time the sub-chunk and print
8842/// elapsed seconds. Stack: [sub_chunk_idx as Int]. Runs the sub-chunk
8843/// on the current VM (so positional/local state is shared) and prints
8844/// the timing summary to stderr in zsh's format. Pushes Status.
8845pub const BUILTIN_TIME_SUBLIST: u16 = 316;
8846
8847/// `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocation.
8848/// Stack: [path, varid, op_byte]. Opens `path` per `op_byte`, gets the
8849/// new fd (≥10 in zsh; we use libc::open with O_CLOEXEC bit cleared so
8850/// the inherited fd survives Command::new spawns), stores the fd number
8851/// as a string in `$varid`. Pushes Status (0 success, 1 error).
8852pub const BUILTIN_OPEN_NAMED_FD: u16 = 317;
8853
8854/// Word-segment concat that does cartesian-product distribution over
8855/// arrays. Stack: [lhs, rhs]. Used for RC_EXPAND_PARAM `${arr}` and
8856/// explicit-distribute forms (`${^arr}`, `${(@)…}`).
8857///
8858/// - both scalar: `Value::str(a + b)` (fast path, identical to Op::Concat)
8859/// - lhs Array, rhs scalar: `Value::Array([a + rhs for a in lhs])`
8860/// - lhs scalar, rhs Array: `Value::Array([lhs + b for b in rhs])`
8861/// - both Array: cartesian product `[a + b for a in lhs for b in rhs]`
8862pub const BUILTIN_CONCAT_DISTRIBUTE: u16 = 318;
8863
8864/// Forced-distribute concat — like `BUILTIN_CONCAT_DISTRIBUTE` but
8865/// always distributes cartesian regardless of the `rcexpandparam`
8866/// option. Emitted by the segments fast-path when an
8867/// `is_distribute_expansion` segment is present (`${^arr}`,
8868/// `${(@)arr}`, `${(s.…)arr}` etc.) per zsh: the source-level
8869/// distribution flag overrides the option default.
8870/// Direct port of Src/subst.c:1875 `case Hat: nojoin = 1` and the
8871/// `rcexpandparam` test bypass for the explicit-distribute flags.
8872pub const BUILTIN_CONCAT_DISTRIBUTE_FORCED: u16 = 522;
8873
8874/// Capture current `last_status` into the `TRY_BLOCK_ERROR` variable.
8875/// Emitted between the try block and the always block of `{ … } always
8876/// { … }` so the finally arm can read $TRY_BLOCK_ERROR.
8877pub const BUILTIN_SET_TRY_BLOCK_ERROR: u16 = 320;
8878/// `BUILTIN_RESTORE_TRY_BLOCK_STATUS` constant.
8879pub const BUILTIN_RESTORE_TRY_BLOCK_STATUS: u16 = 432;
8880/// `BUILTIN_BEGIN_INLINE_ENV` constant.
8881pub const BUILTIN_BEGIN_INLINE_ENV: u16 = 433;
8882/// `BUILTIN_END_INLINE_ENV` constant.
8883pub const BUILTIN_END_INLINE_ENV: u16 = 434;
8884
8885/// `[[ -o option ]]` — shell-option-set test. Stack: \[option_name\].
8886/// Normalizes the name (strip underscores, lowercase) and reads
8887/// `exec.options`. Pushes Bool.
8888pub const BUILTIN_OPTION_SET: u16 = 321;
8889/// Tri-state `[[ -o NAME ]]` — same lookup as BUILTIN_OPTION_SET
8890/// but returns a Value::Int (0=set, 1=unset, 3=invalid-name). The
8891/// 3-state code matches zsh's `[[ -o invalid ]]` exit (Src/cond.c
8892/// :502 `optison()`). Used by compile_cond's `-o` arm to skip the
8893/// generic bool→status conversion and preserve the invalid-name
8894/// signal in `$?`.
8895pub const BUILTIN_OPTION_CHECK_TRISTATE: u16 = 609;
8896
8897/// `${var:#pattern}` — array filter: remove elements matching `pattern`.
8898/// Stack: [name, pattern]. For scalar `var`, returns empty if it matches
8899/// the pattern, else the value. For array `var`, returns Array of
8900/// non-matching elements.
8901pub const BUILTIN_PARAM_FILTER: u16 = 322;
8902
8903/// `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()` —
8904/// subscripted-array assign with array-literal RHS. Stack:
8905/// [...elements, name, key]. Empty elements + single-int key `a[i]=()`
8906/// removes that element. Comma-key `a[i,j]=(...)` splices.
8907pub const BUILTIN_SET_SUBSCRIPT_RANGE: u16 = 323;
8908
8909/// `[[ -X file ]]` for unknown unary test op `-X`. Stack: \[op_name\].
8910/// Emits zsh's `unknown condition: -X` diagnostic to stderr and
8911/// pushes Bool(false). Without this, unknown conditions silently
8912/// returned false matching neither zsh's error format nor the
8913/// expected status code (zsh returns 2 for parse error).
8914
8915/// `[[ -t fd ]]` — fd-is-a-tty check. Stack: \[fd_string\].
8916/// Routes through libc::isatty. Pushes Bool.
8917pub const BUILTIN_IS_TTY: u16 = 325;
8918
8919/// Update `$LINENO` to track the source line of the next statement.
8920/// Stack: \[n\] (the line number from `ZshPipe.lineno`). Direct port
8921/// of zsh's `lineno` global tracking (Src/input.c:330) — the
8922/// compiler emits one of these per top-level pipe so `$LINENO`
8923/// reflects the source position at runtime. ID 342 picked because
8924/// the previous `326` collided with `BUILTIN_HAS_STICKY` (the file
8925/// has several other duplicate IDs — 325 has two as well — but
8926/// fixing those is out of scope for this port).
8927pub const BUILTIN_SET_LINENO: u16 = 342;
8928
8929/// Pop a scalar from the VM stack, run expand_glob on it, push the
8930/// result as Value::Array. Used by the segment-concat compile path
8931/// when var refs concatenate with glob meta literals (`$D/*`,
8932/// `${prefix}*`, etc.) — those skip the bridge's pathname-expansion
8933/// pass and would otherwise leak the glob meta to argv as a literal.
8934pub const BUILTIN_GLOB_EXPAND: u16 = 343;
8935
8936/// MULTIOS-gated glob expansion for redirect-target words
8937/// (c:Src/glob.c:2161-2167 xpandredir: "Globbing is only done for
8938/// multios."). Same stack shape as BUILTIN_GLOB_EXPAND; additionally
8939/// passes the word through literally when `unsetopt multios`.
8940// 624 is BUILTIN_COND_STRMATCH — the VM's builtin table is
8941// last-registration-wins, so a duplicate id silently shadows the
8942// earlier handler.
8943pub const BUILTIN_REDIR_GLOB_EXPAND: u16 = 628;
8944
8945/// Reset the default-word glob-pending carrier at the START of a word
8946/// whose source contains a glob metachar (so the flag never leaks from a
8947/// prior word/statement). Paired with BUILTIN_DEFAULT_WORD_GLOB.
8948pub const BUILTIN_DEFAULT_WORD_GLOB_RESET: u16 = 635;
8949
8950/// Filename-generate the ASSEMBLED word ONLY when the default/alternate
8951/// paramsubst arm took a SOURCE word carrying glob metachars
8952/// (subst::DEFAULT_WORD_GLOB_PENDING). A parameter VALUE never sets the
8953/// flag, so `x='*file'; ${x:-d}` stays literal while `${x:-*file}` /
8954/// `${x:-a*}bar` glob. Reads+clears the flag. c:Src/subst.c → globlist.
8955pub const BUILTIN_DEFAULT_WORD_GLOB: u16 = 636;
8956/// `BUILTIN_SET_LOOP_VAR` constant — for-loop variable binding via
8957/// `setloopvar` (Src/params.c:6362): a PM_NAMEREF loop var REBINDS
8958/// to each word (SETREFNAME + setscope) instead of assigning
8959/// through the chain. Returns Bool(false) when zerr fired
8960/// (read-only reference / invalid self reference) so the loop
8961/// driver aborts, mirroring C execfor's errflag check.
8962pub const BUILTIN_SET_LOOP_VAR: u16 = 629;
8963
8964/// EXTEND step of typeset paren-init packing. Pops `argc` values:
8965/// [base, e1, …, eN] — base is either the opener (`name=(` /
8966/// `name+=(`) or a previous EXTEND result. Pushes base with
8967/// `\u{1f}` + element appended per element. Array values SPLICE
8968/// their items as separate elements (`typeset b=( x $arr )` splat);
8969/// an empty Array contributes nothing (unquoted-empty elision).
8970/// CallBuiltin's argc is u8, so the compiler emits one EXTEND per
8971/// ≤200-element chunk — p10k's 408-element `__p9k_colors=( … )`
8972/// overflowed a single-shot pack (argc wrapped mod 256 and the
8973/// stack spilled into the arg list: "not an identifier: 173…").
8974/// BUILTIN_TYPESET_PAREN_CLOSE appends the final `\u{1f})`,
8975/// yielding the exact REJOIN_SEP-delimited one-arg form
8976/// bin_typeset's single-arg splitter consumes (builtin.rs ~4891,
8977/// empties preserved, leading/trailing sentinel-empties trimmed
8978/// once). One arg in → one arg out: bin_typeset's multi-arg rejoin
8979/// (paren-depth scan, unsafe on EXPANDED paren-literal elements
8980/// like p10k's `')' ''`) never runs.
8981pub const BUILTIN_TYPESET_PAREN_PACK: u16 = 630;
8982
8983/// CLOSE step — pops the EXTEND chain's result, pushes it with
8984/// `\u{1f})` appended. See BUILTIN_TYPESET_PAREN_PACK.
8985pub const BUILTIN_TYPESET_PAREN_CLOSE: u16 = 631;
8986
8987/// Shared body of BUILTIN_GLOB_EXPAND / BUILTIN_REDIR_GLOB_EXPAND.
8988/// c:Src/glob.c:1872 — `zglob` runs per-word in the argv pipeline.
8989/// When the upstream EXPAND_TEXT returned an array (e.g. `${a:e}`
8990/// splat → ["txt","md"]), glob each element separately, not a
8991/// sepjoin'd scalar. `skip_glob` short-circuits to a literal
8992/// pass-through (noglob, or a redirect target under
8993/// `unsetopt multios`).
8994fn glob_expand_word_value(raw: Value, skip_glob: bool) -> Value {
8995    let patterns: Vec<String> = match raw {
8996        Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
8997        other => vec![other.to_str()],
8998    };
8999    if skip_glob {
9000        return if patterns.is_empty() {
9001            Value::Array(Vec::new())
9002        } else if patterns.len() == 1 {
9003            Value::str(patterns.into_iter().next().unwrap())
9004        } else {
9005            Value::Array(patterns.into_iter().map(Value::str).collect())
9006        };
9007    }
9008    let mut out: Vec<String> = Vec::with_capacity(patterns.len());
9009    for pattern in &patterns {
9010        let matches = with_executor(|exec| exec.expand_glob(pattern));
9011        if matches.is_empty() {
9012            // c:1872 nullglob — drop this word, don't emit a hole
9013            continue;
9014        }
9015        for m in matches {
9016            out.push(m);
9017        }
9018    }
9019    if out.is_empty() {
9020        return Value::Array(Vec::new());
9021    }
9022    if patterns.len() == 1 && out.len() == 1 && out[0] == patterns[0] {
9023        // No real matches; expand_glob returned the literal. Pass
9024        // back as scalar so downstream ops don't re-flatten.
9025        return Value::str(out.into_iter().next().unwrap());
9026    }
9027    Value::Array(out.into_iter().map(Value::str).collect())
9028}
9029
9030/// Push a `CmdState` token onto the command-context stack. Direct
9031/// port of zsh's `cmdpush(int cmdtok)` (Src/prompt.c:1623). The
9032/// stack is consulted by `%_` in PS4/prompt expansion to produce
9033/// the cumulative control-flow-context labels (`if`, `then`,
9034/// `cmdand`, `cmdor`, `cmdsubst`, …) that `zsh -x` xtrace shows
9035/// in the trace prefix. Compile_zsh emits push/pop pairs around
9036/// each compound command (if/while/[[…]]/((…))/$(…) etc.).
9037/// Token is a `CmdState as u8`.
9038pub const BUILTIN_CMD_PUSH: u16 = 344;
9039
9040/// Pop the top of the command-context stack. Direct port of zsh's
9041/// `cmdpop(void)` (Src/prompt.c:1631).
9042pub const BUILTIN_CMD_POP: u16 = 345;
9043
9044/// Emit an xtrace line built from the top `argc` values on the VM
9045/// stack, peeked WITHOUT consuming. Used to trace simple commands
9046/// AFTER expansion, so `echo for $i` shows as `echo for a` / `echo
9047/// for b`. Direct port of Src/exec.c:2055-2066.
9048pub const BUILTIN_XTRACE_ARGS: u16 = 346;
9049
9050/// Trace one assignment: emits `name=<quoted-value> ` (no newline)
9051/// to xtrerr if XTRACE is on. Coalesces with subsequent
9052/// XTRACE_ASSIGN / XTRACE_ARGS calls onto the SAME line via the
9053/// `XTRACE_DONE_PS4` flag so `a=1 b=2 echo $a $b` produces:
9054///   `<PS4>a=1 b=2 echo 1 2\n`
9055/// matching C zsh's `execcmd_exec` body (Src/exec.c:2517-2582):
9056///   xtr = isset(XTRACE);
9057///   if (xtr) { printprompt4(); doneps4 = 1; }
9058///   while (assign) {
9059///       if (xtr) fprintf(xtrerr, "%s=", name);
9060///       ... eval value ...
9061///       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
9062///   }
9063///
9064/// Stack contract on entry: [..., name, value]. Both peeked, NOT
9065/// consumed (the matching SET_VAR call pops them after). argc = 2.
9066pub const BUILTIN_XTRACE_ASSIGN: u16 = 525;
9067
9068/// Emit a trailing `\n` + flush iff XTRACE is on AND PS4 was
9069/// emitted by an earlier XTRACE_ASSIGN this line. Used at the end
9070/// of compile_simple's assignment-only path so the trace line gets
9071/// terminated. Mirrors C's exec.c:3397-3399 (the assign-only return
9072/// path through execcmd_exec which does `fputc('\n', xtrerr);
9073/// fflush(xtrerr)`).
9074///
9075/// Stack: untouched. argc = 0.
9076pub const BUILTIN_XTRACE_NEWLINE: u16 = 526;
9077
9078/// Push the live `xtrace` opt-state as `Value::Int(1)` (on) or
9079/// `Value::Int(0)` (off). Used by `compile_cond` to gate the
9080/// trace-string-building block on xtrace state at runtime — without
9081/// this the trace path's `compile_word_str` on each operand re-
9082/// evaluates side-effectful expressions (`$((i++))`) once for the
9083/// trace string and once for the actual condition, doubling the
9084/// effective increment. Bug #159 in docs/BUGS.md.
9085///
9086/// Stack: pushes Int(0|1). argc = 0.
9087pub const BUILTIN_XTRACE_IS_ON: u16 = 611;
9088
9089/// Reset the `DONETRAP` flag at the start of each top-level statement
9090/// (sublist boundary). Mirrors C `Src/exec.c:1455` — `donetrap = 0`.
9091/// Stack: untouched. argc = 0. Bug #303 in docs/BUGS.md.
9092pub const BUILTIN_DONETRAP_RESET: u16 = 612;
9093
9094/// `[[ -z X ]]` / `[[ -n X ]]` operand-empty test that honours zsh's
9095/// array-splice semantics. C zsh evaluates `[[ -z X ]]` per
9096/// `Src/cond.c:347` (case 'z'): `s` is the SCALAR operand passed
9097/// through `cond_str`'s singsub. For `"${arr[@]}"` zsh expands per
9098/// `Src/subst.c:multsub` which yields each element as its own word
9099/// list node; cond.c then sees the joined-or-single-element form.
9100///
9101/// The compile-side `-z` shortcut at `compile_zsh.rs:5371` used
9102/// `Op::StringLen` which calls `Value::len` — for `Value::Array`
9103/// that returns ARRAY LENGTH, not string length. `b=("")` produced
9104/// `Value::Array([""])` → `len = 1` → `-z` returned false.
9105///
9106/// This builtin pops one `Value` and pushes `1` (empty) or `0`
9107/// (non-empty) per the cond context:
9108///   - `Value::Str(s)` → s.is_empty()
9109///   - `Value::Array([])` → true (zero words → vacuous-empty)
9110///   - `Value::Array([s])` → s.is_empty() (single-word case)
9111///   - `Value::Array([_; n>=2])` → false (multiple non-empty
9112///     words; zsh would raise "unknown condition" but the
9113///     observable test result is non-empty/false)
9114///
9115/// Companion to BUILTIN_COND_STR_NONEMPTY (#185 in docs/BUGS.md).
9116pub const BUILTIN_COND_STR_EMPTY: u16 = 613;
9117
9118/// `[[ -n X ]]` operand-non-empty test (logical complement of
9119/// BUILTIN_COND_STR_EMPTY).
9120pub const BUILTIN_COND_STR_NONEMPTY: u16 = 614;
9121
9122/// `exec N<<<"str"` — herestring redirect to explicit fd, applied
9123/// permanently to the shell (no scope restoration). Pops `[content,
9124/// fd]` from the stack; creates a temp file, writes
9125/// `content + "\n"`, reopens read-only, dup2's to `fd`, unlinks the
9126/// temp path so it disappears on close. Mirrors C `Src/exec.c:4655
9127/// getherestr` + `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)`
9128/// at c:3766-3780 for the bare-exec-redir code path (nullexec=1).
9129/// Bug #205 in docs/BUGS.md.
9130///
9131/// Stack: pushes `Value::Status(0)` on success, `Status(1)` on
9132/// failure. argc = 2.
9133pub const BUILTIN_EXEC_HERESTR_FD: u16 = 615;
9134
9135/// MULTIOS write/append fan-out for `cmd > a > b` / `cmd > a >> b`
9136/// style redirects (Bug #36 in docs/BUGS.md). zsh's MULTIOS option
9137/// (Src/exec.c:2418 `mfds[fd1]` check + addfd splice) creates a
9138/// pipe at fd1, spawns an internal "tee" process that copies
9139/// stdin → every collected target file. Without this, only the
9140/// LAST redirect target survives because each dup2 overwrites the
9141/// previous binding.
9142///
9143/// Stack layout (pushed by compile_zsh's compile_redirs coalescing
9144/// pass): `[target_1, op_byte_1, target_2, op_byte_2, …, target_N,
9145/// op_byte_N, fd]`. Pops 2N+1 elements; `argc = 2*N + 1`. A target
9146/// may be a Value::Array of glob matches (spliced into one member
9147/// per match, c:Src/glob.c:2195-2203); an op may be DUP_WRITE for a
9148/// numeric `>&N` member (c:Src/exec.c:3895-3917).
9149///
9150/// Runtime (MULTIOS set):
9151///   1. Seed the member list with `dup(1)` when this command's
9152///      stdout is the pipeline output (c:Src/exec.c:3722-3724).
9153///   2. Open/dup all targets per their op_byte in redirect order
9154///      (WRITE truncate + noclobber gate / APPEND / DUP_WRITE live
9155///      dup); the first member replaces the fd (c:2448-2450).
9156///   3. Save `dup(fd)` onto the active redirect_scope_stack so
9157///      `host_redirect_scope_end` restores the original fd.
9158///   4. Create a pipe; spawn a thread that reads from the pipe
9159///      read-end and writes every chunk to every opened target.
9160///   5. dup2 the pipe write-end onto `fd` so the command's writes
9161///      go through the splitter.
9162///   6. Track `(pipe_write_fd, JoinHandle)` so scope-end can close
9163///      the pipe (draining the thread) and join before restoring.
9164///
9165/// MULTIOS unset (c:2418 `unset(MULTIOS)` replace arm): each entry
9166/// is applied as a plain sequential replace via host_apply_redirect
9167/// — every file still opened/truncated, last one wins.
9168pub const BUILTIN_MULTIOS_REDIRECT: u16 = 617;
9169
9170/// MULTIOS input-side concatenation for `cmd < a < b` shapes
9171/// (Bug #36 input arm). C zsh's `Src/exec.c:2418` mfds dispatch
9172/// also covers the read direction — when multiple `<` redirects
9173/// target the same fd, mfds[fd] grows and addfd splices a
9174/// concatenating cat into the pipe.
9175///
9176/// Stack layout (mirrors the write side): `[source_1, op_1,
9177/// source_2, op_2, …, source_N, op_N, fd]`. Pops 2N + 1 elements
9178/// (argc = 2N + 1). op is READ for file sources, DUP_READ for
9179/// numeric `<&N` members; a source may be a Value::Array of glob
9180/// matches (spliced, c:Src/glob.c:2195-2203).
9181///
9182/// Runtime (MULTIOS set):
9183///   1. Open/dup every source in redirect order; first member
9184///      replaces the fd (c:Src/exec.c:2448-2450).
9185///   2. Save `dup(fd)` onto the redirect_scope_stack.
9186///   3. Create a pipe; spawn a thread that reads each source in
9187///      order and writes every chunk to the pipe write-end. Close
9188///      write-end when done so the consumer sees EOF.
9189///   4. dup2 the pipe read-end onto `fd`.
9190///   5. Track the JoinHandle so scope-end joins (no fd-close needed
9191///      here — the producer thread closes its own pipe write-end
9192///      on exit).
9193///
9194/// MULTIOS unset: sequential replace via host_apply_redirect — last
9195/// source wins (c:2418).
9196pub const BUILTIN_MULTIOS_READ: u16 = 618;
9197
9198/// Toggle `ShellExecutor::exec_redirs_permanent`. Emitted by
9199/// compile_zsh's bare-`exec`-with-redirects arm tightly around each
9200/// `Op::Redirect`: `LoadInt(1); CallBuiltin; …Redirect…; LoadInt(0);
9201/// CallBuiltin`. While set, `host_apply_redirect` skips pushing the
9202/// saved fd into the enclosing redirect scope, making the fd change
9203/// permanent.
9204///
9205/// c:Src/exec.c:3978-3986 — nullexec==1 (`exec` carrying only
9206/// redirections): "If nullexec is 1 we specifically *don't* restore
9207/// the original fd's before returning" — the per-execcmd `save[]`
9208/// dups are closed unrestored. An ENCLOSING group's own saves are a
9209/// different execcmd's `save[]` and still restore (verified:
9210/// `{ exec 2>/dev/null; } 2>&1; ls /nope` prints the ls error in zsh).
9211pub const BUILTIN_EXEC_PERM_REDIRS: u16 = 619;
9212
9213/// Set `ShellExecutor::pipe_output_pending`. Emitted by compile_pipe
9214/// at the head of a NON-LAST pipeline-stage sub-chunk when that
9215/// stage's top-level command carries redirects (`Simple` with redirs
9216/// or `Redirected` compound). The forked stage child runs the chunk
9217/// with stdout already dup2'd onto the pipe; the first
9218/// `host_redirect_scope_begin` (the stage command's own redirect
9219/// list) consumes the flag into `pipe_output_scope`, enabling the
9220/// MULTIOS stream-split for fd-1 write redirects in that list.
9221///
9222/// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output, 1,
9223/// NULL)` registers the pipe in mfds[1] in the SAME execcmd that
9224/// walks the stage command's redirect list; mfds is per-execcmd, so
9225/// nested body commands (`{ echo a > f; } | cat`) never see it.
9226pub const BUILTIN_PIPE_OUTPUT_MARK: u16 = 620;
9227
9228/// Magic-equals prefork for a single arg word of a
9229/// `BINF_MAGICEQUALS` builtin head (`alias`). Direct port of
9230/// c:Src/exec.c:3298-3304 — `esprefork = PREFORK_TYPESET;
9231/// prefork(args, esprefork, NULL)` runs on the argv BEFORE the addfd
9232/// redirect loop at c:3720, so an expansion zerr (`alias bad===` →
9233/// equalsubstr "= not found" at Src/subst.c:726) prints to the
9234/// command's UN-redirected stderr. argc=1: pops the just-pushed
9235/// word value, runs shtokenize → prefork(PREFORK_TYPESET) →
9236/// untokenize on it (each element for Array splices), pushes the
9237/// result back. Emitted by compile_simple per arg word when the
9238/// dispatch head is `alias`; BUILTIN_ALIAS itself no longer
9239/// preforks (it would double-fire the diagnostic).
9240pub const BUILTIN_MAGIC_EQUALS_PREFORK: u16 = 621;
9241
9242/// Bare (unbraced) `$name[idx]` subscript — same dispatch as
9243/// `BUILTIN_ARRAY_INDEX` while KSHARRAYS is unset, but under
9244/// KSHARRAYS the unbraced form does NOT subscript (c:Src/subst.c:
9245/// 2800-2802 + 2867): `$name` expands bare and `[idx]` stays literal
9246/// trailing text that undergoes filename generation. Operands:
9247/// [name, idx, suffix, quoted].
9248pub const BUILTIN_ARRAY_INDEX_UNBRACED: u16 = 622;
9249
9250/// Assignment-only simple-command exit status. Direct port of
9251/// `lv = (errflag ? errflag : cmdoutval)` (c:Src/exec.c:1322,
9252/// execsimple's WC_ASSIGN arm) / `if (errflag) lastval = 1; else
9253/// lastval = cmdoutval;` (c:Src/exec.c:3393-3396, execcmd_exec's
9254/// no-command-word varspc path; redir variant at c:3977). Pops
9255/// [had_cmd_subst]; cmdoutval is the live vm.last_status when a
9256/// `$()` ran in any RHS of the chain, 0 otherwise. Writes the
9257/// canonical LASTVAL (C's single `lastval` global) so the
9258/// non-interactive errflag abort exits with this value per
9259/// Src/init.c:234. Caller pairs with SetStatus.
9260pub const BUILTIN_ASSIGN_ONLY_STATUS: u16 = 623;
9261
9262/// c:Src/exec.c addvars — `if (!pm) { lastval = 1; if (!cmdoutval)
9263/// cmdoutval = 1; }`. Set by BUILTIN_SET_VAR on assignsparam
9264/// failure, consumed by BUILTIN_ASSIGN_ONLY_STATUS so the
9265/// assignment-only command reports status 1. Process-global like
9266/// C's `cmdoutval` (function bodies may run on a different thread
9267/// than the opcode that reads the status back).
9268pub static ASSIGN_FAILED_FLAG: std::sync::atomic::AtomicBool =
9269    std::sync::atomic::AtomicBool::new(false);
9270
9271/// `redirection with no command` parse-time error for bare
9272/// `builtin 2>&1` / `command < file` / `exec >&-` precmd-keyword
9273/// shapes with a redirect but no following command. Direct port
9274/// of `Src/exec.c:3342 zerr("redirection with no command")`.
9275/// argc=0; pushes Value::Status(1).
9276pub const BUILTIN_REDIR_NO_CMD: u16 = 616;
9277
9278/// GLOB_SUBST guard for `[[ x == $pat ]]` pattern RHS coming from
9279/// parameter / command substitution. C-zsh's `[[ == ]]` semantics
9280/// (Src/options.c GLOB_SUBST default OFF + Src/cond.c:552
9281/// `cond_match` + Src/pattern.c patcompile tokenization-based
9282/// meta detection) treat chars from substitution as LITERAL
9283/// unless GLOB_SUBST is on. The Rust patcompile accepts both
9284/// tokenized and raw-ASCII meta chars, losing the distinction,
9285/// so `pat="h*"; [[ hello == $pat ]]` matched in zshrs but not
9286/// in zsh. Bug #116 in docs/BUGS.md.
9287///
9288/// Compile-time signal: emitted by `compile_cond_expr` ONLY when
9289/// the RHS contains `$` or backtick. Runtime checks the live
9290/// option state. If GLOB_SUBST is OFF, the popped string has
9291/// its glob metachars escaped with `\` so the downstream StrMatch
9292/// → patcompile treats them as literals. If GLOB_SUBST is ON,
9293/// the value passes through unchanged so `setopt glob_subst`
9294/// restores zsh's pattern-on-expansion behavior.
9295///
9296/// Stack: pops one string, pushes the (possibly escaped) result.
9297/// argc = 1.
9298pub const BUILTIN_GLOB_SUBST_GUARD: u16 = 528;
9299
9300/// Coerce a string parameter value to a math number (Int or Float)
9301/// for arithmetic-context reads, mirroring C-zsh's `getmathparam`
9302/// (Src/math.c:337). When the variable holds a string like "hello"
9303/// that isn't numeric, C falls back to recursively evaluating the
9304/// raw string as an arith expression; if that fails too, returns 0.
9305///
9306/// Used by the ArithCompiler pre-load path so `(( y = x ))` with
9307/// `x="hello"` reads `x` as integer 0, then assigns y as integer 0
9308/// — matching zsh's behaviour. The previous Rust port used
9309/// BUILTIN_GET_VAR which returned the raw string "hello"; the
9310/// ArithCompiler stored it verbatim in y's slot, and the post-sync
9311/// BUILTIN_SET_VAR wrote y="hello" as scalar instead of y=0 as
9312/// integer. Bug #118 in docs/BUGS.md.
9313///
9314/// Stack: pops `name` (string), pushes coerced numeric Value.
9315/// argc = 1.
9316pub const BUILTIN_GET_MATH_VAR: u16 = 529;
9317
9318/// GLOB_SUBST runtime gate for words containing parameter / command
9319/// substitution. C-zsh's `prefork` (Src/subst.c) runs `shtokenize`
9320/// on the substituted value when `GLOB_SUBST` is set, making the
9321/// substituted chars eligible for filename generation. With the
9322/// option off, substituted chars stay literal.
9323///
9324/// The Rust port's compile_zsh emits `compile_word_str` for words
9325/// like `/tmp/X/$pat`, which returns the post-expansion string but
9326/// never runs glob expansion (no path here triggers
9327/// BUILTIN_GLOB_EXPAND). Bug #119 in docs/BUGS.md: with `setopt
9328/// glob_subst`, `for f in /tmp/X/$pat` (pat="*.txt") never matched
9329/// `*.txt` files.
9330///
9331/// This opcode wraps the substitution result and dispatches at
9332/// runtime: when GLOB_SUBST is OFF, return unchanged; when ON,
9333/// pass the value through `expand_glob` so glob metas become
9334/// active. Emitted by `compile_for_words` (and similar sites)
9335/// after WORD_SPLIT for words with unquoted expansion.
9336///
9337/// Stack: pops a Value (Str or Array of Str), pushes the glob-
9338/// expanded result (still Str or Array depending on input shape).
9339/// argc = 1.
9340pub const BUILTIN_GLOB_SUBST_EXPAND: u16 = 530;
9341/// `BUILTIN_ASSOC_HAS_KEY` constant — `${(k)assoc[name]}` key-existence
9342/// query. Returns the key text on hit, empty string on miss. Bug #145.
9343pub const BUILTIN_ASSOC_HAS_KEY: u16 = 531;
9344/// `BUILTIN_ARRAY_DROP_EMPTY` constant — filter empty elements from
9345/// an Array on the stack. Used by `for x in $@` / `for x in $*`
9346/// unquoted forms. Bug #166.
9347pub const BUILTIN_ARRAY_DROP_EMPTY: u16 = 532;
9348/// `BUILTIN_QUOTEDZPUTS` constant — run top-of-stack value through
9349/// `crate::ported::utils::quotedzputs` and push the quoted result.
9350/// Used by the cond xtrace path so non-printable bytes (e.g.
9351/// `$'\C-[OP'` expanded ESC+OP) get re-wrapped in `$'…'` form for
9352/// the trace prefix line, matching zsh's `Src/exec.c` cond trace
9353/// which calls `quotedzputs(operand, xtrerr)` on each side. Bug
9354/// surfaced when `[[ -n $'\C-[OP' ]]` traced as `[[ -n OP ]]`
9355/// (raw bytes leaked through the terminal) vs zsh's
9356/// `[[ -n $'\C-[OP' ]]` source-form preservation.
9357pub const BUILTIN_QUOTEDZPUTS: u16 = 533;
9358/// `BUILTIN_QUOTE_TOKENIZED_OUTPUT` — port of
9359/// `crate::ported::exec::quote_tokenized_output` (Src/exec.c:2114)
9360/// applied to top-of-stack scalar. Used by cond xtrace for the RHS
9361/// of pattern-context comparisons (`=` / `==` / `!=`) where C zsh
9362/// emits the SOURCE form: untokenize lexer tokens (Star → `*`,
9363/// Inpar → `(`, …) and backslash-escape special chars, but
9364/// preserve literal ASCII unchanged. Distinct from quotedzputs
9365/// which wraps the whole string in `'…'` / `$'…'` based on
9366/// non-printability — that's wrong for `[[ x = a* ]]` which must
9367/// render as `[[ x = a* ]]`, not `'a*'`.
9368pub const BUILTIN_QUOTE_TOKENIZED_OUTPUT: u16 = 534;
9369
9370/// Bridge into subst_port::substitute_brace_array for nested forms
9371/// that need to PRESERVE array shape across the expand_string
9372/// boundary. Stack: `[content_string]`. Returns Value::Array of the
9373/// per-element words. Used by the compile path for
9374/// `${(@)<nested>...##pat}` shapes — the standard substitute_brace
9375/// returns String which collapses array→scalar; this builtin
9376/// preserves the multi-word output via paramsubst's third return
9377/// (`nodes` vec, the C source's `aval` thread).
9378pub const BUILTIN_BRIDGE_BRACE_ARRAY: u16 = 347;
9379
9380/// Word-segment concat with FIRST/LAST sticking. Stack: [lhs, rhs].
9381/// Used for default unquoted splice forms (`${arr[@]}`, `$@`, `$*`)
9382/// where prefix sticks to first element only and suffix to last only.
9383///
9384/// Distribution table:
9385/// - both scalar: `Value::str(a + b)` (fast path)
9386/// - lhs scalar, rhs Array(b₀..bₙ): `Value::Array([lhs+b₀, b₁, …, bₙ])`
9387/// - lhs Array(a₀..aₙ), rhs scalar: `Value::Array([a₀, …, aₙ₋₁, aₙ+rhs])`
9388/// - both Array: `Value::Array([a₀, …, aₙ₋₁, aₙ+b₀, b₁, …, bₙ])`
9389///   (last of lhs merges with first of rhs; the rest stay separate)
9390///
9391/// This is the default zsh semantics for `print -l X${arr[@]}Y` →
9392/// "Xa", "b", "cY" — three distinct args, surrounding text only on ends.
9393pub const BUILTIN_CONCAT_SPLICE: u16 = 319;
9394
9395/// `${(flags)name}` — zsh parameter expansion flags. Stack: [name, flags].
9396/// Flags applied left-to-right. Supported subset (high-value, used by zpwr):
9397///
9398///   `L` — lowercase the value (scalar; or each element if array)
9399///   `U` — uppercase
9400///   `j:sep:` — join array with `sep` (delim is the char after `j`)
9401///   `s:sep:` — split scalar on `sep` (returns Value::Array)
9402///   `f` — split on newlines (shorthand for `s.\n.`)
9403///   `o` — sort array ascending
9404///   `O` — sort array descending
9405///   `P` — indirect: read name's value as another var name, return that's value
9406///   `@` — keep as array (returns Value::Array — useful before `j` etc.)
9407///   `k` — keys of assoc array
9408///   `v` — values of assoc array
9409///   `#` — word count (array length as scalar)
9410///
9411/// Flags can stack: `(jL)` joins then lowercases; `(s.,.U)` splits on `,`
9412/// then uppercases each element. The long-tail flags (`q`, `qq`, `qqq` for
9413/// quoting, `A` for assoc, `%` for prompt expansion, `e`/`g` for re-eval,
9414/// `n`/`p` for numeric, `t` for type, etc.) are deferred — they hit the
9415/// runtime fallback via the catch-all expansion path.
9416pub const BUILTIN_PARAM_FLAG: u16 = 297;
9417
9418/// `ShellHost` implementation that delegates to the current `ShellExecutor`
9419/// via the `with_executor` thread-local.
9420///
9421/// Construct fresh on each VM run (it carries no state itself). The VM
9422/// dispatches host method calls during `vm.run()`, and `with_executor`
9423/// resolves to the executor pointer set by `ExecutorContext::enter`.
9424/// fusevm-host implementation tying bytecode ops to the
9425/// shell executor.
9426/// zshrs-original — no C counterpart. C zsh has no bytecode VM
9427/// to host; everything runs through `execlist()`/`execpline()`
9428/// directly (Src/exec.c lines 1349/1668).
9429pub struct ZshrsHost;
9430
9431impl fusevm::ShellHost for ZshrsHost {
9432    fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> {
9433        with_executor(|exec| exec.expand_glob(pattern))
9434    }
9435
9436    fn tilde_expand(&mut self, s: &str) -> String {
9437        with_executor(|exec| s.to_string())
9438    }
9439
9440    fn brace_expand(&mut self, s: &str) -> Vec<String> {
9441        // Direct call to the canonical brace expander
9442        // (Src/glob.c::xpandbraces port at glob.rs:1678). Was
9443        // routing through singsub which uses PREFORK_SINGLE — that
9444        // flag explicitly suppresses brace expansion in subst.c:166,
9445        // so `print X{1,2,3}Y` returned the literal string.
9446        //
9447        // brace_ccl: respect the BRACE_CCL option which the bracket-
9448        // class form `{a-z}` requires. Pull from executor options.
9449        let brace_ccl = with_executor(|exec| opt_state_get("braceccl").unwrap_or(false));
9450        crate::ported::glob::xpandbraces(s, brace_ccl)
9451    }
9452
9453    fn str_match(&mut self, s: &str, pattern: &str) -> bool {
9454        // Shell glob match — `*`, `?`, `[...]`, alternation. After the
9455        // cond path moved to BUILTIN_COND_STRMATCH, the consumer here
9456        // is the `case` arm dispatch, whose bad-pattern semantics are
9457        // Src/loop.c:663-667: `if (!(pprog = patcompile(pat, ...)))
9458        // zerr("bad pattern: %s", pat);` — errflag set, the arm
9459        // doesn't match, and the script aborts at the next command
9460        // boundary (matching `zsh -fc 'case x in [a-) ...'` printing
9461        // the diagnostic with exit 0 = untouched lastval).
9462        let mut pat_tok = pattern.to_string();
9463        crate::ported::glob::tokenize(&mut pat_tok);
9464        if crate::ported::pattern::patcompile(
9465            &pat_tok,
9466            crate::ported::zsh_h::PAT_STATIC as i32,
9467            None,
9468        )
9469        .is_none()
9470        {
9471            crate::ported::utils::zerr(&format!("bad pattern: {}", pattern)); // c:667
9472            return false;
9473        }
9474        glob_match_static(s, pattern)
9475    }
9476
9477    fn expand_param(&mut self, name: &str, _modifier: u8, _args: &[Value]) -> Value {
9478        // Sole funnel: route through `getsparam` matching C zsh's
9479        // `getsparam(name)` → `getvalue` → `getstrvalue` →
9480        // `Param.gsu->getfn` dispatch (Src/params.c:3076 / 2335).
9481        //
9482        // The lookup chain (GSU dispatch + variables + env + array-
9483        // join) lives in `params::getsparam`; subst.rs and this
9484        // bridge both call into it so the logic is in exactly one
9485        // place — mirroring C's "every read goes through getsparam"
9486        // architecture. fuseVM bytecode triggers this bridge when
9487        // the VM hits a PARAM opcode, equivalent to C's wordcode VM
9488        // resolving a parameter read during `exec.c` execution.
9489        //
9490        // Modifier handling: the `_modifier` / `_args` parameters
9491        // are populated by the bytecode compiler but applied by
9492        // separate VM opcodes (LENGTH/STRIP/SUBST/etc.) downstream
9493        // of this fetch — matching C's split between getsparam
9494        // (value fetch) and paramsubst's modifier-walk loop. This
9495        // bridge is the value-fetch step only.
9496        let val_str = crate::ported::params::getsparam(name).unwrap_or_default();
9497        Value::str(val_str)
9498    }
9499
9500    fn process_sub_in(&mut self, sub: &fusevm::Chunk) -> String {
9501        // c:Src/exec.c::getproc — `<(cmd)` uses pipe + fork + the
9502        // `/dev/fd/N` filesystem entry (where N is the read end of
9503        // the pipe held open in the parent). Consumer opens
9504        // `/dev/fd/N`, reads the cmd's stdout through the pipe.
9505        // Both macOS and Linux expose `/dev/fd` for held-open file
9506        // descriptors. Previous Rust port captured stdout into
9507        // `/tmp/zshrs_psub_*` tempfiles synchronously — works for
9508        // `diff <(a) <(b)` style readers that scan once but diverges
9509        // from zsh's observable path string and breaks any consumer
9510        // that introspects the path or expects a non-seekable pipe.
9511        let mut fds: [libc::c_int; 2] = [-1, -1];
9512        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
9513            // Pipe creation failed — fall back to tempfile so we at
9514            // least return SOMETHING.
9515            let fifo_path = format!(
9516                "/tmp/zshrs_psub_fallback_{}_{}",
9517                std::process::id(),
9518                with_executor(|e| {
9519                    let n = e.process_sub_counter;
9520                    e.process_sub_counter += 1;
9521                    n
9522                })
9523            );
9524            let _ = fs::remove_file(&fifo_path);
9525            return fifo_path;
9526        }
9527        let (read_end, write_end) = (fds[0], fds[1]);
9528        let sub_for_child = sub.clone();
9529        match unsafe { libc::fork() } {
9530            -1 => {
9531                unsafe {
9532                    libc::close(read_end);
9533                    libc::close(write_end);
9534                }
9535                return String::from("/dev/null");
9536            }
9537            0 => {
9538                // Child: close read end, dup write end to stdout,
9539                // run the sub-chunk, exit. The exit closes the
9540                // write end automatically, so the parent's reader
9541                // gets EOF when the cmd finishes.
9542                unsafe {
9543                    libc::close(read_end);
9544                    libc::dup2(write_end, libc::STDOUT_FILENO);
9545                    libc::close(write_end);
9546                }
9547                crate::fusevm_disasm::maybe_print_stdout("process_subst_in", &sub_for_child);
9548                let mut vm = fusevm::VM::new(sub_for_child);
9549                register_builtins(&mut vm);
9550                vm.set_shell_host(Box::new(ZshrsHost));
9551                let _ = vm.run();
9552                let _ = std::io::stdout().flush();
9553                unsafe { libc::_exit(0) };
9554            }
9555            _ => {
9556                // Parent: close write end, keep read end open under
9557                // the same fd value so `/dev/fd/N` resolves to the
9558                // pipe's read side. NOTE: FD_CLOEXEC must STAY clear
9559                // — consumers like `cat <(cmd)` and `diff <(a) <(b)`
9560                // discover the fd via exec inheritance, so closing
9561                // on exec defeats the whole point. C zsh's getproc
9562                // (Src/exec.c:5045+) leaves the fd open across exec.
9563                unsafe {
9564                    libc::close(write_end);
9565                }
9566            }
9567        }
9568        format!("/dev/fd/{}", read_end)
9569    }
9570
9571    fn process_sub_out(&mut self, sub: &fusevm::Chunk) -> String {
9572        // c:Src/exec.c:5025 getproc, PATH_DEV_FD branch — `>(cmd)`
9573        // (out == 0): `mpipe(pipes)`, fork; the CHILD `redup(pipes[0],
9574        // 0)` (pipe read end onto stdin) and `closem` drops the write
9575        // end; the PARENT closes pipes[0] and hands the consumer
9576        // `/dev/fd/<pipes[1]>` (the write end). The previous Rust port
9577        // used mkfifo + a child that BLOCKED in open(FIFO, O_RDONLY)
9578        // before running cmd — with no writer the child never started,
9579        // never exited, and kept its inherited stdout (e.g. a `$()`
9580        // capture pipe) open forever: `a=$(print -r -- >(true))` hung.
9581        // With the pipe shape the child runs immediately and exits,
9582        // releasing inherited fds exactly like zsh (verified: zsh
9583        // blocks ~2s on `a=$(print -r -- >(sleep 2))`, then EOFs).
9584        let mut fds: [libc::c_int; 2] = [-1, -1];
9585        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
9586            // Pipe creation failed — fall back to a plain temp file so
9587            // the consumer at least has a writable path.
9588            let fallback = format!(
9589                "/tmp/zshrs_psub_out_{}_{}",
9590                std::process::id(),
9591                with_executor(|e| {
9592                    let n = e.process_sub_counter;
9593                    e.process_sub_counter += 1;
9594                    n
9595                })
9596            );
9597            let _ = fs::write(&fallback, "");
9598            return fallback;
9599        }
9600        let (read_end, write_end) = (fds[0], fds[1]);
9601        let sub_for_child = sub.clone();
9602        match unsafe { libc::fork() } {
9603            -1 => {
9604                unsafe {
9605                    libc::close(read_end);
9606                    libc::close(write_end);
9607                }
9608                String::from("/dev/null")
9609            }
9610            0 => {
9611                // Child: close the write end (c: closem after redup),
9612                // dup the read end onto stdin (c: redup(pipes[0], 0)),
9613                // run the sub-chunk, exit. Other std fds stay
9614                // inherited — zsh's child keeps the surrounding
9615                // command's stdout/stderr.
9616                unsafe {
9617                    libc::close(write_end);
9618                    libc::dup2(read_end, libc::STDIN_FILENO);
9619                    libc::close(read_end);
9620                }
9621                crate::fusevm_disasm::maybe_print_stdout("process_subst_out:child", &sub_for_child);
9622                let mut vm = fusevm::VM::new(sub_for_child);
9623                register_builtins(&mut vm);
9624                vm.set_shell_host(Box::new(ZshrsHost));
9625                let _ = vm.run();
9626                unsafe { libc::_exit(0) };
9627            }
9628            _ => {
9629                // Parent: close the read end, keep the write end open
9630                // under its fd value so `/dev/fd/N` resolves to the
9631                // pipe's write side. FD_CLOEXEC must STAY clear —
9632                // consumers (`tee >(cmd)`) discover the fd via exec
9633                // inheritance, matching process_sub_in above and C's
9634                // fdtable[fd] = FDT_PROC_SUBST bookkeeping. Park the
9635                // fd for close-after-consuming-command (c: addfilelist
9636                // (NULL, fd) → deletefilelist) so the child's reader
9637                // EOFs — without this `tee >(wc -c) </dev/null` left
9638                // wc blocked until shell exit.
9639                unsafe {
9640                    libc::close(read_end);
9641                }
9642                let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
9643                PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, write_end)));
9644                format!("/dev/fd/{}", write_end)
9645            }
9646        }
9647    }
9648
9649    fn subshell_begin(&mut self) {
9650        with_executor(|exec| {
9651            // libc::umask returns the previous mask AND sets the new
9652            // one; call with current value to read without changing.
9653            let cur_umask = unsafe {
9654                let m = libc::umask(0o022);
9655                libc::umask(m);
9656                m as u32
9657            };
9658            // Snapshot paramtab + hashed-storage too (step 1 of the
9659            // store unification mirrors writes there; restoring only
9660            // the HashMaps leaks subshell-scoped writes to the parent
9661            // via paramtab readers like `paramsubst → vars_get`).
9662            let paramtab_snap = crate::ported::params::paramtab()
9663                .read()
9664                .ok()
9665                .map(|t| t.clone())
9666                .unwrap_or_default();
9667            let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
9668                .lock()
9669                .ok()
9670                .map(|m| m.clone())
9671                .unwrap_or_default();
9672            exec.subshell_snapshots.push(SubshellSnapshot {
9673                paramtab: paramtab_snap,
9674                paramtab_hashed_storage: paramtab_hashed_snap,
9675                positional_params: exec.pparams(),
9676                env_vars: env::vars().collect(),
9677                // Save the LOGICAL pwd ($PWD env), not `current_dir()`'s
9678                // symlink-resolved path. zsh's subshell isolation per
9679                // Src/exec.c at the `entersubsh` path treats `pwd` (the
9680                // shell-tracked logical PWD) as the carrier — see
9681                // `Src/builtin.c:1239-1242` where cd writes the logical
9682                // dest into `pwd`. Falling back to current_dir() only
9683                // when PWD is unset matches `setupvals` at
9684                // `Src/init.c:1100+`.
9685                cwd: env::var("PWD")
9686                    .ok()
9687                    .map(PathBuf::from)
9688                    .or_else(|| env::current_dir().ok()),
9689                umask: cur_umask,
9690                // Snapshot canonical `traps_table` — bin_trap writes
9691                // there (`Src/builtin.c`).
9692                traps: crate::ported::builtin::traps_table()
9693                    .lock()
9694                    .map(|t| t.clone())
9695                    .unwrap_or_default(),
9696                // Snapshot option store so `(set -e)` /
9697                // `(setopt extendedglob)` don't leak to parent.
9698                opts: crate::ported::options::opt_state_snapshot(),
9699                // c:Src/exec.c — fork() copies the alias table to
9700                // the subshell. `(alias x=y)` inside the subshell
9701                // dies with the child; the parent doesn't see x.
9702                // Snapshot here so subshell_end can restore.
9703                // Bug #209 in docs/BUGS.md.
9704                aliases: crate::ported::hashtable::aliastab_lock()
9705                    .read()
9706                    .ok()
9707                    .map(|t| {
9708                        t.iter()
9709                            .map(|(k, v)| (k.clone(), v.text.clone()))
9710                            .collect()
9711                    })
9712                    .unwrap_or_default(),
9713                // c:Src/exec.c::entersubsh — same fork-copy
9714                //   semantics for shfunctab. `(f() { ... })` defined
9715                //   inside the subshell dies with the child; parent's
9716                //   `type f` reports "not found". Bug #208 in
9717                //   docs/BUGS.md.
9718                shfuncs: crate::ported::hashtable::shfunctab_lock()
9719                    .read()
9720                    .ok()
9721                    .map(|t| t.snapshot())
9722                    .unwrap_or_default(),
9723                functions_compiled: exec.functions_compiled.clone(),
9724                function_source: exec.function_source.clone(),
9725                // c:Src/exec.c::entersubsh — subshell forks its own
9726                // modulestab. A `(zmodload zsh/X)` inside the
9727                // subshell flips MOD_INIT_B on the CHILD's
9728                // modulestab; when the child exits the change
9729                // dies with it. zshrs's in-process subshell would
9730                // otherwise leak the load to the parent.
9731                // Bug #210 in docs/BUGS.md. Snapshot just the
9732                // (name → flags) pairs since the only mutating
9733                // field is the flags bitmask (MOD_INIT_B for
9734                // loaded, MOD_UNLOAD for unloaded).
9735                modules: crate::ported::module::MODULESTAB
9736                    .lock()
9737                    .ok()
9738                    .map(|t| {
9739                        t.modules
9740                            .iter()
9741                            .map(|(k, v)| (k.clone(), v.node.flags))
9742                            .collect()
9743                    })
9744                    .unwrap_or_default(),
9745                // c:Src/exec.c::entersubsh — fork-copy semantics for
9746                // THINGYTAB (ZLE widget registry). A subshell `zle -N`
9747                // / `zle -D` mutation dies with the child in C zsh;
9748                // mirror via in-process snapshot. Bug #453.
9749                thingytab: crate::ported::zle::zle_thingy::thingytab()
9750                    .lock()
9751                    .ok()
9752                    .map(|t| t.clone())
9753                    .unwrap_or_default(),
9754                // c:Src/exec.c::entersubsh — same fork-copy for the
9755                // KEYMAPNAMTAB (named keymap registry). `bindkey -N km`
9756                // / `bindkey -D km` inside a subshell dies with the
9757                // child. Bug #454.
9758                keymapnamtab: crate::ported::zle::zle_keymap::keymapnamtab()
9759                    .lock()
9760                    .ok()
9761                    .map(|t| t.clone())
9762                    .unwrap_or_default(),
9763                // c:Src/exec.c::entersubsh fork semantics — `$!`
9764                // (clone::lastpid) set by a `&` INSIDE the subshell
9765                // dies with the child: `( : & ); echo $!` -> 0.
9766                lastpid: crate::ported::modules::clone::lastpid
9767                    .load(std::sync::atomic::Ordering::Relaxed),
9768                // c:Src/exec.c::entersubsh fork semantics — the
9769                // subshell gets a COPY of the job table; its disown/
9770                // wait/`&` mutations die with it. Bug #462.
9771                jobtab: crate::ported::jobs::JOBTAB
9772                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
9773                    .lock()
9774                    .map(|t| t.clone())
9775                    .unwrap_or_default(),
9776                curjob: *crate::ported::jobs::CURJOB
9777                    .get_or_init(|| std::sync::Mutex::new(-1))
9778                    .lock()
9779                    .unwrap(),
9780                prevjob: *crate::ported::jobs::PREVJOB
9781                    .get_or_init(|| std::sync::Mutex::new(-1))
9782                    .lock()
9783                    .unwrap(),
9784                maxjob: *crate::ported::jobs::MAXJOB
9785                    .get_or_init(|| std::sync::Mutex::new(0))
9786                    .lock()
9787                    .unwrap(),
9788                thisjob: *crate::ported::jobs::THISJOB
9789                    .get_or_init(|| std::sync::Mutex::new(-1))
9790                    .lock()
9791                    .unwrap(),
9792                // c:Src/exec.c entersubsh — fork copies the fd table;
9793                // the child's `exec >file` / `exec N<&-` mutations die
9794                // with it. Dup each user-range fd to >= 10 so
9795                // subshell_end can restore the parent's exact table.
9796                saved_fds: (0..10)
9797                    .map(|fd| {
9798                        let dup = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
9799                        (fd, dup)
9800                    })
9801                    .collect(),
9802            });
9803            // C forks for `(...)` — count the fork-equivalent so
9804            // `time (builtin)` reports like zsh (see FORK_EVENTS).
9805            crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9806            // c:Src/exec.c:2862 — subshell fork flags carry ESUB_PGRP,
9807            // so entersubsh runs `clearjobtab(monitor)` (c:1219): the
9808            // child gets an EMPTY job table plus the procless control
9809            // job grabbed at Src/jobs.c:1828 (`thisjob = initjob()`).
9810            // That's why zsh's `(jobs)` prints nothing and `(kill %1)`
9811            // hits the empty control job instead of the parent's job 1.
9812            // The snapshot pushed above restores the parent's table at
9813            // subshell_end. Bug #462.
9814            let monitor =
9815                crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
9816            crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
9817            // clearjobtab left THISJOB on the control job (Src/jobs.c:
9818            // 1828). In C the very next pipeline's execpline reassigns
9819            // thisjob (Src/exec.c:1700 `thisjob = newjob = initjob()`),
9820            // so by the time any builtin runs, thisjob never aliases
9821            // the control job. zshrs has no per-pipeline job slot —
9822            // model the between-pipelines state (-1) so getjob's
9823            // `jobnum != thisjob` (c:jobs.c:2107) doesn't reject %1 and
9824            // setcurjob doesn't demote an inherited curjob that
9825            // collides with the control slot.
9826            *crate::ported::jobs::THISJOB
9827                .get_or_init(|| std::sync::Mutex::new(-1))
9828                .lock()
9829                .unwrap() = -1;
9830            // Subshell starts with EXIT trap cleared so the parent's
9831            // EXIT handler doesn't fire when the subshell ends. zsh:
9832            // each subshell has its own trap context. Other signals
9833            // are inherited (well, parent's are still in place — but
9834            // a trap set INSIDE the subshell shouldn't leak out).
9835            if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
9836                t.remove("EXIT");
9837            }
9838            let level = exec
9839                .scalar("ZSH_SUBSHELL")
9840                .and_then(|s| s.parse::<i32>().ok())
9841                .unwrap_or(0);
9842            // c:Src/exec.c — ZSH_SUBSHELL carries PM_READONLY (declared
9843            // in params.rs special_params); setsparam would be rejected
9844            // by assignstrvalue's PM_READONLY guard. Write u_val
9845            // directly — same bypass pattern as BUILTIN_SET_LINENO at
9846            // line 2784. C zsh's PM_SPECIAL GSU vtable handles this
9847            // implicitly via the setfn callback.
9848            let new_level = (level + 1) as i64;
9849            if let Ok(mut tab) = crate::ported::params::paramtab().write() {
9850                if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
9851                    pm.u_val = new_level;
9852                    pm.u_str = Some(new_level.to_string());
9853                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
9854                }
9855            }
9856        });
9857        // Bump SUBSHELL_DEPTH so zexit defers process::exit (see
9858        // SUBSHELL_DEPTH declaration in src/ported/builtin.rs for
9859        // rationale).
9860        crate::ported::builtin::SUBSHELL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
9861        // c:Src/exec.c::entersubsh — C zsh's subshell is a forked
9862        // child process: signals sent to the parent (via `kill $$`
9863        // inside the subshell, where `$$` is the parent's pid)
9864        // never reach the child's signal handlers. zshrs's
9865        // in-process subshell shares the process pid with the
9866        // parent, so without queueing the subshell's trap handler
9867        // fires for signals that zsh would deliver only to the
9868        // parent. Queue signals across the subshell body so the
9869        // parent's restored trap table sees them after
9870        // subshell_end's unqueue drain. Bug #450.
9871        crate::ported::signals_h::queue_signals();
9872    }
9873
9874    fn subshell_end(&mut self) -> Option<i32> {
9875        // Fire subshell's EXIT trap BEFORE restoring parent state so
9876        // the trap body sees the subshell's vars and exit status. zsh
9877        // forks for `(...)` so the trap runs in the child process,
9878        // before exit. We mirror by running it here, just before the
9879        // pop+restore. REMOVE the trap before firing so the inner
9880        // execute_script doesn't fire it again at its own end.
9881        let exit_trap_body = crate::ported::builtin::traps_table()
9882            .lock()
9883            .ok()
9884            .and_then(|mut t| t.remove("EXIT"));
9885        if let Some(body) = exit_trap_body {
9886            // Execute the trap body. Errors during trap execution
9887            // don't bubble — zsh ignores trap-body errors.
9888            with_executor(|exec| {
9889                let _ = exec.execute_script(&body);
9890            });
9891        }
9892        with_executor(|exec| {
9893            if let Some(snap) = exec.subshell_snapshots.pop() {
9894                // Restore paramtab + hashed storage so subshell-scoped
9895                // writes via setsparam/setaparam/sethparam don't leak
9896                // to the parent via paramtab readers.
9897                if let Some(tab) = crate::ported::params::paramtab()
9898                    .write()
9899                    .ok()
9900                    .as_deref_mut()
9901                {
9902                    *tab = snap.paramtab;
9903                }
9904                // c:Src/exec.c::entersubsh fork semantics — restore
9905                // the parent's `$!`; a background job inside `(...)`
9906                // dies with the child in C zsh.
9907                crate::ported::modules::clone::lastpid
9908                    .store(snap.lastpid, std::sync::atomic::Ordering::Relaxed);
9909                // c:Src/exec.c::entersubsh fork semantics — restore the
9910                // parent's job table + curjob/prevjob/maxjob/thisjob.
9911                // The subshell mutated only its own copy. Bug #462.
9912                if let Ok(mut t) = crate::ported::jobs::JOBTAB
9913                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
9914                    .lock()
9915                {
9916                    *t = snap.jobtab;
9917                }
9918                *crate::ported::jobs::CURJOB
9919                    .get_or_init(|| std::sync::Mutex::new(-1))
9920                    .lock()
9921                    .unwrap() = snap.curjob;
9922                *crate::ported::jobs::PREVJOB
9923                    .get_or_init(|| std::sync::Mutex::new(-1))
9924                    .lock()
9925                    .unwrap() = snap.prevjob;
9926                *crate::ported::jobs::MAXJOB
9927                    .get_or_init(|| std::sync::Mutex::new(0))
9928                    .lock()
9929                    .unwrap() = snap.maxjob;
9930                *crate::ported::jobs::THISJOB
9931                    .get_or_init(|| std::sync::Mutex::new(-1))
9932                    .lock()
9933                    .unwrap() = snap.thisjob;
9934                if let Some(m) = crate::ported::params::paramtab_hashed_storage()
9935                    .lock()
9936                    .ok()
9937                    .as_deref_mut()
9938                {
9939                    *m = snap.paramtab_hashed_storage;
9940                }
9941                exec.set_pparams(snap.positional_params);
9942                // Restore the OS env to its pre-subshell state.
9943                // Removes any `export` writes the subshell made, and
9944                // restores any vars the subshell unset. Without this
9945                // `(export y=sub)` would leak `y` to the parent shell.
9946                let current: HashMap<String, String> = env::vars().collect();
9947                for k in current.keys() {
9948                    if !snap.env_vars.contains_key(k) {
9949                        env::remove_var(k);
9950                    }
9951                }
9952                for (k, v) in &snap.env_vars {
9953                    if current.get(k) != Some(v) {
9954                        env::set_var(k, v);
9955                    }
9956                }
9957                if let Some(cwd) = snap.cwd {
9958                    let _ = env::set_current_dir(&cwd);
9959                    // Resync $PWD env so a parent `pwd` doesn't read
9960                    // the cwd the subshell `cd`'d into.
9961                    env::set_var("PWD", &cwd);
9962                }
9963                // Restore umask. zsh's `(umask 077)` doesn't leak to
9964                // parent because the subshell forks; we run in-process
9965                // so we manually reset.
9966                unsafe {
9967                    libc::umask(snap.umask as libc::mode_t);
9968                }
9969                // Restore parent's traps (the subshell's own traps die
9970                // with it). zsh: `(trap "X" USR1)` doesn't leak the
9971                // USR1 trap out of the subshell. Write back to the
9972                // canonical `traps_table` (bin_trap writes there).
9973                if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
9974                    *t = snap.traps;
9975                }
9976                // Restore parent's option store so `(set -e)` /
9977                // `(setopt extendedglob)` don't leak. zsh forks
9978                // subshells so child option changes die with the
9979                // child; we run in-process and must restore.
9980                crate::ported::options::opt_state_restore(snap.opts);
9981                // c:Src/exec.c — fork() means alias mutations in a
9982                // subshell die with the child. Restore parent's
9983                // alias table from snapshot. Clear current entries
9984                // then re-add parent's. Bug #209 in docs/BUGS.md.
9985                if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
9986                    tab.clear();
9987                    for (name, text) in snap.aliases {
9988                        tab.add(crate::ported::zsh_h::alias {
9989                            node: crate::ported::zsh_h::hashnode {
9990                                next: None,
9991                                nam: name,
9992                                flags: 0,
9993                            },
9994                            text,
9995                            inuse: 0,
9996                        });
9997                    }
9998                }
9999                // c:Src/exec.c::entersubsh — same fork-copy
10000                //   semantics for shfunctab. Restore parent's function
10001                //   table from snapshot so `(f() { ... })` definitions
10002                //   inside the subshell don't leak to the parent.
10003                //   Bug #208 in docs/BUGS.md.
10004                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
10005                    tab.restore(snap.shfuncs);
10006                }
10007                // Restore the runtime dispatch tables (compiled chunks
10008                // + source). Without these, a subshell-defined
10009                // override leaves its bytecode in place even after
10010                // shfunctab is restored — `g` after the subshell would
10011                // still run the override.
10012                exec.functions_compiled = snap.functions_compiled;
10013                exec.function_source = snap.function_source;
10014                // c:Src/exec.c::entersubsh — restore parent's
10015                // modulestab so a subshell `(zmodload zsh/X)` doesn't
10016                // leak to the parent. Bug #210 in docs/BUGS.md.
10017                // Restore via per-module flag write since the
10018                // snapshot is `(name → flags)` only.
10019                if let Ok(mut t) = crate::ported::module::MODULESTAB.lock() {
10020                    for (name, saved_flags) in &snap.modules {
10021                        if let Some(m) = t.modules.get_mut(name) {
10022                            m.node.flags = *saved_flags;
10023                        }
10024                    }
10025                }
10026                // c:Src/exec.c::entersubsh — restore parent's THINGYTAB
10027                // so a subshell's `zle -N w f` / `zle -D w` doesn't
10028                // affect the parent's widget registry. Bug #453.
10029                if let Ok(mut t) = crate::ported::zle::zle_thingy::thingytab().lock() {
10030                    *t = snap.thingytab;
10031                }
10032                // Same for KEYMAPNAMTAB. Bug #454.
10033                if let Ok(mut t) = crate::ported::zle::zle_keymap::keymapnamtab().lock() {
10034                    *t = snap.keymapnamtab;
10035                }
10036                // c:Src/exec.c entersubsh fork semantics — restore the
10037                // parent's user-range fd table. A bare `exec >file` /
10038                // `exec N>&-` inside `(...)` died with the C child;
10039                // the in-process subshell must undo it here. Flush
10040                // Rust's stdout buffer FIRST so bytes the subshell
10041                // printed drain to the SUBSHELL's fd 1, not the
10042                // restored parent fd.
10043                {
10044                    use std::io::Write;
10045                    let _ = std::io::stdout().flush();
10046                }
10047                for (fd, saved) in snap.saved_fds {
10048                    unsafe {
10049                        if saved >= 0 {
10050                            libc::dup2(saved, fd);
10051                            libc::close(saved);
10052                        } else {
10053                            // fd was closed at entry; close whatever
10054                            // the subshell opened on that slot.
10055                            libc::close(fd);
10056                        }
10057                    }
10058                }
10059            }
10060        });
10061        // Decrement SUBSHELL_DEPTH. If a deferred subshell exit
10062        // landed inside (EXIT_PENDING set with depth > 0), promote
10063        // the deferred status into the subshell's exit status now
10064        // that we're at the boundary, then clear so the parent
10065        // continues. Matches C zsh's "subshell-exit-via-fork"
10066        // boundary where the child's process::exit(N) becomes
10067        // $WAITSTATUS / $? in the parent.
10068        crate::ported::builtin::SUBSHELL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
10069        // c:Src/exec.c — drain the signal queue against the now-
10070        // restored parent trap table. Pairs with the
10071        // queue_signals() call at the end of subshell_begin.
10072        // Any `kill $$` from inside the subshell is processed
10073        // here against OUTER's trap, matching C zsh's
10074        // signal-delivery-to-parent semantics. Bug #450.
10075        crate::ported::signals_h::unqueue_signals();
10076        // c:Src/exec.c — a `( … )` subshell is a FORK in C: an errflag
10077        // abort inside the child ends the child with its lastval as
10078        // the exit status, and the flag dies with the child process.
10079        // The parent's $? picks up the status and the parent's lists
10080        // keep running. zsh 5.9: `(readonly r=1; r=2); echo "after
10081        // $?"` prints `after 1`. zshrs runs the subshell in-process,
10082        // so mirror the fork isolation by clearing ERRFLAG_ERROR at
10083        // the subshell boundary — exec.last_status() already carries
10084        // the child's lastval (synced by ERREXIT_CHECK trigger 4).
10085        //
10086        // ERRFLAG_HARD must die at this boundary too: `${u:?msg}` sets
10087        // errflag |= ERRFLAG_HARD (c:Src/subst.c:3344) and then, in a
10088        // C forked subshell, `_exit(1)` (c:3353) — the parent never
10089        // sees ANY errflag bit. A leaked HARD bit here made every
10090        // subsequent zerr() take the silent arm (c:Src/utils.c:175-177
10091        // `if (errflag || noerrs) { errflag |= ERRFLAG_ERROR; return; }`),
10092        // so the next eval/source's parse silently "failed" and the
10093        // D04 harness shell wedged after chunk 10's
10094        // `(print ${unset1:?exiting1})`.
10095        crate::ported::utils::errflag.fetch_and(
10096            !(crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
10097            std::sync::atomic::Ordering::Relaxed,
10098        );
10099        let exit_pending =
10100            crate::ported::builtin::EXIT_PENDING.load(std::sync::atomic::Ordering::Relaxed);
10101        if exit_pending != 0 {
10102            // c:Src/builtin.c — `exit N` masks N to 8 bits because
10103            // POSIX _exit takes the low byte as status. `(exit 256)`
10104            // and `(exit 0)` are indistinguishable to the parent;
10105            // `(exit 257)` exits with 1. Without the mask zshrs's
10106            // in-process subshell propagated the full i32 (256) into
10107            // the parent's $?, diverging from zsh.
10108            let raw = crate::ported::builtin::EXIT_VAL.load(std::sync::atomic::Ordering::Relaxed);
10109            let val = raw & 0xFF;
10110            with_executor(|exec| exec.set_last_status(val));
10111            crate::ported::builtin::EXIT_PENDING.store(0, std::sync::atomic::Ordering::Relaxed);
10112            crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
10113            crate::ported::builtin::BREAKS.store(0, std::sync::atomic::Ordering::Relaxed);
10114            // Set the post-subshell-exit guard. The next GET_VAR
10115            // sync_status path consults this to skip its
10116            // vm.last_status→LASTVAL sync (which would overwrite the
10117            // deferred-exit status we just set with stale vm state
10118            // since SubshellEnd doesn't propagate status into the
10119            // VM). Cleared as soon as the next sync_status sees it.
10120            SUBSHELL_EXIT_STATUS_PENDING.with(|c| c.set(true));
10121            // Return the deferred-exit status so the VM updates its
10122            // own `last_status`. Otherwise run_chunk's post-script
10123            // `set_last_status(vm.last_status)` would clobber LASTVAL
10124            // back to the stale pre-subshell value.
10125            return Some(val);
10126        }
10127        None
10128    }
10129
10130    fn redirect(&mut self, fd: u8, op: u8, target: &str) {
10131        // Apply a redirection at the OS level for the next command/builtin.
10132        // The host tracks saved fds in a per-executor stack so a future
10133        // `with_redirects_end` can restore. For now, this is a thin wrapper
10134        // that performs the dup2; pairing with explicit save/restore is
10135        // delivered by `with_redirects_begin/end`.
10136        with_executor(|exec| exec.host_apply_redirect(fd, op, target));
10137    }
10138
10139    fn with_redirects_begin(&mut self, count: u8) {
10140        with_executor(|exec| exec.host_redirect_scope_begin(count));
10141    }
10142
10143    fn regex_match(&mut self, s: &str, regex: &str) -> bool {
10144        // c:Src/Modules/regex.c:54 `zcond_regex_match` — POSIX ERE
10145        // matching + populate `$MATCH` / `$MBEGIN` / `$MEND` /
10146        // `$match[]` / `$mbegin[]` / `$mend[]` (or `$BASH_REMATCH`
10147        // under BASHREMATCH). Direct delegation to the canonical
10148        // port at src/ported/modules/regex.rs:58.
10149        //
10150        // The bridge passthru path delivers TOKEN-form bytes here
10151        // (Inbrack \u{91}, Outbrack \u{92}, Star \u{87}, Quest
10152        // \u{86}, etc.) since the lexer tokenizes regex meta chars
10153        // inside `[[ ]]`. The host regex engine expects ASCII, so
10154        // untokenize the pattern (and subject, for safety) once at
10155        // this boundary. zsh C reaches its POSIX-ERE engine through
10156        // the same untokenize path inside zcond_regex_match.
10157        let s_clean = crate::lex::untokenize(s);
10158        let regex_clean = crate::lex::untokenize(regex);
10159        crate::ported::modules::regex::zcond_regex_match(
10160            &[s_clean.as_str(), regex_clean.as_str()],
10161            crate::ported::modules::regex::ZREGEX_EXTENDED,
10162        ) != 0
10163    }
10164
10165    fn with_redirects_end(&mut self) {
10166        with_executor(|exec| exec.host_redirect_scope_end());
10167        // c:Src/exec.c:5172 — if any redirect in this scope failed
10168        // (noclobber-blocked, ENOENT for read, etc.), the command's
10169        // exit status is forced to 1 regardless of what the (still-
10170        // executed) command's own exit was. C zsh prevents the
10171        // command from running at all when a redirect fails; the
10172        // Rust port still runs it (sinking output to /dev/null in
10173        // the noclobber arm at host_apply_redirect:5481) and then
10174        // overrides $? here. Same observable effect for the common
10175        // pattern `echo x > existing-file` under noclobber.
10176        let failed = with_executor(|exec| {
10177            let f = exec.redirect_failed;
10178            exec.redirect_failed = false;
10179            f
10180        });
10181        if failed {
10182            with_executor(|exec| exec.set_last_status(1));
10183        }
10184    }
10185
10186    fn heredoc(&mut self, content: &str) {
10187        // C `Src/exec.c:4641` — `parsestr(&buf)` runs parameter +
10188        // command substitution on the heredoc body. The lexer's
10189        // quoted-delimiter detection (`<<'EOF'`) routes through the
10190        // `Op::HereDoc` path in `compile_zsh.rs` which short-circuits
10191        // before reaching here; unquoted forms route through the
10192        // BUILTIN_EXPAND_TEXT mode-4 emit path that calls singsub.
10193        // This handler covers the verbatim/quoted case.
10194        with_executor(|exec| exec.host_set_pending_stdin(content.to_string()));
10195    }
10196
10197    fn herestring(&mut self, content: &str) {
10198        // Shell semantics: herestring appends a newline. `<<<` body
10199        // substitution (`Src/exec.c:4655 getherestr` calls
10200        // `quotesubst` + `untokenize`) lands here verbatim; the
10201        // upstream compiler routes through `Op::HereString` after
10202        // BUILTIN_EXPAND_TEXT for the substitution pass, so callers
10203        // of `host.herestring` see the already-expanded form.
10204        let mut s = content.to_string();
10205        s.push('\n');
10206        with_executor(|exec| exec.host_set_pending_stdin(s));
10207    }
10208
10209    fn exec(&mut self, args: Vec<String>) -> i32 {
10210        // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close
10211        // any `>(cmd)` write ends owned by this command once it
10212        // finishes (drops on every return path below).
10213        let _psub_fds = PsubFdGuard;
10214        // c:Src/subst.c paramsubst — when `${var:?msg}` or `${var?msg}`
10215        // triggered the "parameter null or not set" error, errflag
10216        // is raised and zsh aborts the simple command without
10217        // attempting exec. The expansion may have produced empty
10218        // argv[0] which falls into the c:?/permission-denied path
10219        // below, masking the real diagnostic with a spurious
10220        // "permission denied:" line and rc=126 instead of rc=1.
10221        // Honour errflag here so the script ends with the
10222        // paramsubst error as the sole diagnostic. Bug #86.
10223        //
10224        // c:Src/exec.c — C's execlist loop clears ERRFLAG_ERROR
10225        // between sublists when the error came from a NOMATCH-style
10226        // command failure (glob no-match, etc.) so subsequent
10227        // sublists run. zshrs's vm dispatch handles this at the
10228        // post-command-boundary HERE: if THIS command has its
10229        // `current_command_glob_failed` cell set (meaning the glob
10230        // NOMATCH happened during this command's argv prep), surface
10231        // status 1 and clear BOTH the cell AND ERRFLAG_ERROR so the
10232        // NEXT exec call sees a clean state. The errflag from
10233        // genuine script-fatal errors (parse, redirect, paramsubst
10234        // `${:?msg}`) does NOT come paired with glob_failed, so
10235        // those still short-circuit + propagate.
10236        consume_tilde_globsubst_carrier();
10237    let glob_failed = with_executor(|exec| {
10238            let f = exec.current_command_glob_failed.get();
10239            exec.current_command_glob_failed.set(false);
10240            f
10241        });
10242        if glob_failed {
10243            crate::ported::utils::errflag.fetch_and(
10244                !crate::ported::zsh_h::ERRFLAG_ERROR,
10245                std::sync::atomic::Ordering::Relaxed,
10246            );
10247            with_executor(|exec| exec.set_last_status(1));
10248            return 1;
10249        }
10250        // c:Src/subst.c:505-507 — CSH_NULL_GLOB external-path
10251        // boundary: command skipped with `no match` but the NEXT
10252        // sublist runs (zsh -fc 'setopt cshnullglob; ls *nope*;
10253        // print after' prints the error then `after` — verified
10254        // zsh 5.9.1), so clear ERRFLAG like the glob_failed arm.
10255        if consume_badcshglob() {
10256            crate::ported::utils::errflag.fetch_and(
10257                !crate::ported::zsh_h::ERRFLAG_ERROR,
10258                std::sync::atomic::Ordering::Relaxed,
10259            );
10260            with_executor(|exec| exec.set_last_status(1));
10261            return 1;
10262        }
10263        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
10264            & crate::ported::zsh_h::ERRFLAG_ERROR)
10265            != 0
10266        {
10267            return 1;
10268        }
10269        // c:Src/exec.c — two distinct empty-command cases:
10270        //
10271        // 1. args=[""]  — an explicit empty-string command word
10272        //    (`""`, `"\$unset"`, `\$'\$x'`). zsh attempts exec(2)
10273        //    on the empty path → EACCES → "permission denied", \$?
10274        //    = 126.
10275        //
10276        // 2. args=[]    — the WORD LIST is empty (unquoted \$(\$cmd)
10277        //    that produced empty, or an unquoted unset \$var that
10278        //    elided). zsh: no exec is attempted; \$? becomes the
10279        //    last cmd-subst's exit status (the inner sub-VM
10280        //    already set last_status), and the line completes
10281        //    silently. Critically NOT 126.
10282        if args.is_empty() {
10283            // c:Src/exec.c — empty word list passes through to a
10284            // no-op; preserve whatever the inner cmd-subst's exit
10285            // is. Return last_status so the caller's SetStatus
10286            // round-trips correctly.
10287            return with_executor(|exec| exec.last_status());
10288        }
10289        if args[0].is_empty() {
10290            let script_name =
10291                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
10292            let lineno: u64 = with_executor(|exec| {
10293                exec.scalar("LINENO")
10294                    .and_then(|s| s.parse::<u64>().ok())
10295                    .unwrap_or(1)
10296            });
10297            eprintln!("{}:{}: permission denied: ", script_name, lineno);
10298            return 126;
10299        }
10300        // c:Src/exec.c — when any redirect in the current scope
10301        // failed (e.g. noclobber blocked a `>` overwrite), zsh
10302        // refuses to execute the command and exits with status 1.
10303        // The Rust port still applied the command (writing to the
10304        // /dev/null sink installed by host_apply_redirect's
10305        // noclobber arm), but the success status overwrote the
10306        // intended `1`. Short-circuit here so the exec returns 1
10307        // without running the body.
10308        let redir_failed = with_executor(|exec| {
10309            let f = exec.redirect_failed;
10310            exec.redirect_failed = false;
10311            f
10312        });
10313        if redir_failed {
10314            return 1;
10315        }
10316        // Track `$_` as the last argument of the last command (zsh /
10317        // bash convention). Empty arglists leave it untouched.
10318        if let Some(last) = args.last() {
10319            with_executor(|exec| {
10320                exec.set_scalar("_".to_string(), last.clone());
10321            });
10322        }
10323        // Route external command spawning through `executor.execute_external`
10324        // so intercepts (AOP before/after/around), command_hash lookups,
10325        // pre/postexec hooks, and zsh-specific fork-then-exec all apply.
10326        // Without this override, fusevm's default `host.exec` calls
10327        // `Command::new` directly, bypassing zshrs's dispatch logic.
10328        let status = with_executor(|exec| exec.host_exec_external(&args));
10329        // c:Src/jobs.c:1748 waitonejob (no-procs else-branch). zshrs's
10330        // exec model routes external commands through host_exec_external
10331        // (which already waitpid'd in-line); the canonical waitonejob
10332        // expects a Job to derive lastval, but here we already know
10333        // it. Synthesize a procs-less job so waitonejob's no-procs
10334        // branch fires the `pipestats[0]=lastval; numpipestats=1;`
10335        // update via the canonical port.
10336        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
10337        let mut synth = crate::ported::zsh_h::job::default();
10338        crate::ported::jobs::waitonejob(&mut synth);
10339        status
10340    }
10341
10342    fn cmd_subst(&mut self, sub: &fusevm::Chunk) -> String {
10343        // Run the sub-chunk on a nested VM with the same host wired up,
10344        // capturing stdout. The current executor remains active via the
10345        // thread-local — the nested VM uses CallBuiltin to dispatch shell
10346        // ops back through `with_executor`.
10347        let (read_end, write_end) = match os_pipe::pipe() {
10348            Ok(p) => p,
10349            Err(_) => return String::new(),
10350        };
10351        let saved_stdout = unsafe { libc::dup(libc::STDOUT_FILENO) };
10352        if saved_stdout < 0 {
10353            return String::new();
10354        }
10355        let saved_stderr = unsafe { libc::dup(libc::STDERR_FILENO) };
10356        let write_fd = AsRawFd::as_raw_fd(&write_end);
10357        unsafe {
10358            libc::dup2(write_fd, libc::STDOUT_FILENO);
10359        }
10360        drop(write_end);
10361
10362        // c:Bug #56 — publish the saved outer fds so a trap firing
10363        // during the nested VM run can route its body output to the
10364        // PARENT's stdout instead of the cmdsub's pipe-bound fd 1.
10365        // zsh's forked cmdsub gets this for free (trap runs in the
10366        // parent process whose fd 1 is untouched). zshrs's
10367        // in-process cmdsub needs this thread-local stack so the
10368        // trap dispatcher can find the right destination fd.
10369        CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().push((saved_stdout, saved_stderr)));
10370
10371        // Nested scope for `>(cmd)` fd ownership — commands inside
10372        // the cmdsub must not drain the enclosing command's pending
10373        // psub fds (see PSUB_SCOPE_DEPTH).
10374        let _psub_scope = PsubScope::enter();
10375
10376        // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
10377        // which does `zsh_subshell++`; in-process equivalent.
10378        let _subshell_bump = CmdSubstSubshellBump::enter();
10379
10380        crate::fusevm_disasm::maybe_print_stdout("host.cmd_subst", sub);
10381        let mut vm = fusevm::VM::new(sub.clone());
10382        register_builtins(&mut vm);
10383        vm.set_shell_host(Box::new(ZshrsHost));
10384        let _ = vm.run();
10385        let cmd_status = vm.last_status;
10386
10387        CMDSUBST_OUTER_FDS.with(|s| {
10388            s.borrow_mut().pop();
10389        });
10390
10391        unsafe {
10392            libc::dup2(saved_stdout, libc::STDOUT_FILENO);
10393            libc::close(saved_stdout);
10394            if saved_stderr >= 0 {
10395                libc::close(saved_stderr);
10396            }
10397        }
10398
10399        // Inner cmd's status not propagated for the same reason as
10400        // run_command_substitution — see GAPS.md.
10401        let _ = cmd_status;
10402
10403        let mut buf = String::new();
10404        let mut reader = read_end;
10405        let _ = reader.read_to_string(&mut buf);
10406        // Strip trailing newlines (POSIX command substitution semantics)
10407        while buf.ends_with('\n') {
10408            buf.pop();
10409        }
10410        buf
10411    }
10412
10413    fn call_function(&mut self, name: &str, args: Vec<String>) -> Option<i32> {
10414        // c:Src/exec.c — when the command word is empty (e.g. `""`
10415        // or `"$nonexistent"`), zsh attempts the exec(2) which
10416        // returns EACCES ("permission denied") and exits 126. The
10417        // Rust port silently treated empty as a no-op (status 0).
10418        // Match zsh by emitting the diagnostic and returning 126.
10419        if name.is_empty() {
10420            let script_name =
10421                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
10422            let lineno: u64 = with_executor(|exec| {
10423                exec.scalar("LINENO")
10424                    .and_then(|s| s.parse::<u64>().ok())
10425                    .unwrap_or(1)
10426            });
10427            eprintln!("{}:{}: permission denied: ", script_name, lineno);
10428            with_executor(|exec| exec.set_last_status(126));
10429            return Some(126);
10430        }
10431        // c:Src/exec.c — redirect failure in this scope means the
10432        // command should NOT run. The Host::exec path already has
10433        // this gate (at fn exec above); call_function takes external
10434        // commands like `cat <&3` through a different code path, so
10435        // gate here too. Without this, bad-fd redirects produced
10436        // the diagnostic but the external command still ran, so $?
10437        // came out from the command's natural exit instead of the
10438        // forced 1.
10439        let redir_failed = with_executor(|exec| {
10440            let f = exec.redirect_failed;
10441            exec.redirect_failed = false;
10442            f
10443        });
10444        if redir_failed {
10445            with_executor(|exec| exec.set_last_status(1));
10446            return Some(1);
10447        }
10448        // zsh-bundled rename helpers + zcalc: short-circuit BEFORE the
10449        // function/autoload lookup so the autoloaded zsh source (which
10450        // can hang zshrs's parser on zsh-specific syntax) never runs.
10451        // Native Rust impls live in builtin_zmv / builtin_zcalc.
10452        match name {
10453            "zmv" => {
10454                return Some(crate::extensions::ext_builtins::zmv(&args, "mv"));
10455            }
10456            "zcp" => {
10457                return Some(crate::extensions::ext_builtins::zmv(&args, "cp"));
10458            }
10459            "zln" => {
10460                return Some(crate::extensions::ext_builtins::zmv(&args, "ln"));
10461            }
10462            "zcalc" => {
10463                return Some(crate::extensions::ext_builtins::zcalc(&args));
10464            }
10465            // ztest framework (src/extensions/ztest.rs — port of
10466            // ../strykelang's unit-test framework). All zassert_*/
10467            // ztest_* names route through the single try_dispatch
10468            // helper so adding/removing assertions only touches
10469            // ztest.rs.
10470            n if crate::extensions::ztest::try_dispatch_known(n) => {
10471                let status = with_executor(|exec| {
10472                    crate::extensions::ztest::try_dispatch(exec, n, &args).unwrap_or(1)
10473                });
10474                return Some(status);
10475            }
10476            // Daemon-managed z* builtins — thin IPC wrappers. Short-circuit BEFORE
10477            // the function-lookup path so a missing daemon doesn't fall through to
10478            // "command not found". The name list is owned by the daemon crate
10479            // (zshrs_daemon::builtins::ZSHRS_BUILTIN_NAMES); routing through
10480            // try_dispatch keeps this site zero-touch as new z* builtins land.
10481            n if crate::daemon::builtins::is_zshrs_builtin(n) => {
10482                let argv: Vec<String> = std::iter::once(name.to_string()).chain(args).collect();
10483                return Some(crate::daemon::builtins::try_dispatch(n, &argv).unwrap_or(1));
10484            }
10485            _ => {}
10486        }
10487
10488        // c:Src/exec.c:3050-3068 — module-provided builtins (registered
10489        // via each module's `bintab` and folded into the canonical
10490        // `builtintab` by `createbuiltintable`) must dispatch BEFORE
10491        // PATH lookup. fusevm's `shell_builtins::builtin_id` doesn't
10492        // know about per-module entries like `log`
10493        // (Src/Modules/watch.c:693) — they reach call_function as
10494        // CallFunction ops. Consult the merged builtintab here so
10495        // `log` runs the canonical `bin_log` instead of falling
10496        // through to `/usr/bin/log` on macOS. Bug #72 in docs/BUGS.md.
10497        //
10498        // User-defined functions still take precedence over builtins
10499        // (zsh's `alias → function → builtin → external` resolution
10500        // order, c:Src/exec.c:3038-3068). Check `functions_compiled`
10501        // first so a user `log() { ... }` shadows the module bin_log.
10502        // c:Src/exec.c — shfunctab->getnode (the DISABLED-filtering
10503        // accessor) returns NULL for entries flipped to DISABLED via
10504        // `disable -f NAME`. functions_compiled holds the body
10505        // independently of the DISABLED flag, so check shfunctab first
10506        // and mask the lookup when the entry is disabled. Bug #221
10507        // in docs/BUGS.md.
10508        let user_fn_disabled = crate::ported::hashtable::shfunctab_lock()
10509            .read()
10510            .ok()
10511            .and_then(|t| {
10512                let entry = t.get_including_disabled(name)?;
10513                Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
10514            })
10515            .unwrap_or(false);
10516        let has_user_fn = !user_fn_disabled
10517            && with_executor(|exec| exec.functions_compiled.contains_key(name));
10518        if !has_user_fn {
10519            // c:Src/exec.c:3056 — `builtintab->getnode(builtintab,
10520            // cmdarg)` returns NULL for DISABLED entries, falling
10521            // execcmd through to PATH lookup. Mirror by gating the
10522            // bn_in_tab match on the BUILTINS_DISABLED set. Bug #106
10523            // in docs/BUGS.md.
10524            let disabled = crate::ported::builtin::BUILTINS_DISABLED
10525                .lock()
10526                .map(|s| s.contains(name))
10527                .unwrap_or(false);
10528            let bn_in_tab = !disabled
10529                && crate::ported::builtin::createbuiltintable().contains_key(name);
10530            if bn_in_tab {
10531                return Some(dispatch_builtin_raw(name, args));
10532            }
10533        }
10534
10535        // c:Src/lex.c — alias expansion is a LEXER-TIME pass, not a
10536        // run-time lookup. zsh parses the whole `-c` argument (or
10537        // script) before executing, so aliases defined in the same
10538        // parse unit don't apply to commands parsed earlier. Only at
10539        // an INTERACTIVE prompt does each line parse separately with
10540        // the latest aliastab visible.
10541        //
10542        // Gate the run-time alias-rewrite path on `interactive` so
10543        // `alias hi='echo hello'; hi` in `-c` mode falls through to
10544        // "command not found" (matching zsh) while interactive REPL
10545        // input still re-parses with the live aliastab.
10546        let interactive = crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE);
10547        let already_expanding = if interactive {
10548            crate::ported::hashtable::aliastab_lock()
10549                .read()
10550                .ok()
10551                .and_then(|tab| tab.get(name).map(|a| a.inuse != 0))
10552                .unwrap_or(false)
10553        } else {
10554            true // suppress lookup entirely in non-interactive mode
10555        };
10556        let alias_body = if already_expanding {
10557            None
10558        } else {
10559            with_executor(|exec| exec.alias(name))
10560        };
10561        if let Some(body) = alias_body {
10562            let combined = if args.is_empty() {
10563                body
10564            } else {
10565                let quoted: Vec<String> = args
10566                    .iter()
10567                    .map(|a| {
10568                        let escaped = a.replace('\'', "'\\''");
10569                        format!("'{}'", escaped)
10570                    })
10571                    .collect();
10572                format!("{} {}", body, quoted.join(" "))
10573            };
10574            // Bump inuse → run → clear, matching C's lexer behavior.
10575            if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
10576                if let Some(a) = tab.get_mut(name) {
10577                    a.inuse += 1;
10578                }
10579            }
10580            let status = with_executor(|exec| exec.execute_script(&combined).unwrap_or(1));
10581            if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
10582                if let Some(a) = tab.get_mut(name) {
10583                    a.inuse = (a.inuse - 1).max(0);
10584                }
10585            }
10586            return Some(status);
10587        }
10588
10589        // $_ pre-body bump and pending-underscore tracking are
10590        // ZshrsHost-only concerns (prompt rendering). Apply BEFORE
10591        // delegating to dispatch_function_call so the body sees the
10592        // bumped value.
10593        //
10594        // c:Src/exec.c:3491 — `setunderscore((args && nonempty(args))
10595        // ? ((char *) getdata(lastnode(args))) : "")`. C sets $_ to
10596        // the LAST node of the WHOLE args list (which includes argv[0]
10597        // == the function name). So for a no-arg `f`, $_ becomes "f"
10598        // inside the function body. The Rust port at the CallFunction
10599        // op-handler receives `args` WITHOUT the command name
10600        // (compile_zsh.rs:1571 only pushes simple.words[1..]). The
10601        // last() fallback `|| fn_name.clone()` already covers the
10602        // no-arg case, but `exec.set_scalar("_", ...)` writes paramtab
10603        // — the canonical `$_` read goes through `underscoregetfn`
10604        // (params.rs:7836) which reads the `zunderscore` Mutex.
10605        // setsparam("_") doesn't update that mutex, so the body's
10606        // `${_}` returned empty. Bug #279 in docs/BUGS.md. Mirror the
10607        // C `setunderscore` by writing via `set_zunderscore` directly.
10608        let fn_name = name.to_string();
10609        with_executor(|exec| {
10610            let dollar_underscore = args.last().cloned().unwrap_or_else(|| fn_name.clone());
10611            exec.set_scalar("_".to_string(), dollar_underscore.clone());
10612            crate::ported::params::set_zunderscore(std::slice::from_ref(&dollar_underscore));
10613            exec.pending_underscore = Some(dollar_underscore);
10614        });
10615
10616        // Delegate the actual function dispatch to the canonical
10617        // `dispatch_function_call` (which itself wraps the canonical
10618        // `doshfunc` port from `Src/exec.c:5823`). Single doshfunc
10619        // call-site keeps scope-mgmt invariants in one place.
10620        let status = with_executor(|exec| exec.dispatch_function_call(&fn_name, &args));
10621
10622        // $_ post-body — last call-arg or function name. Mirrors the
10623        // C `setunderscore` invocation after the body returns.
10624        with_executor(|exec| {
10625            let last_call_arg = args.last().cloned().unwrap_or_else(|| fn_name.clone());
10626            exec.set_scalar("_".to_string(), last_call_arg.clone());
10627            exec.pending_underscore = Some(last_call_arg);
10628        });
10629
10630        status
10631    }
10632}
10633
10634// ───────────────────────────────────────────────────────────────────────────
10635// Host-routed shell ops: ShellExecutor methods invoked by ZshrsHost from the
10636// fusevm VM. Not a port of Src/exec.c (see file-level docs above) — they're
10637// the bridge between fusevm opcodes and ShellExecutor state.
10638// ───────────────────────────────────────────────────────────────────────────
10639impl ShellExecutor {
10640    // ─── Host-routed shell ops (called by ZshrsHost from fusevm) ────────────
10641
10642    /// Apply a single redirection. The current scope's saved-fd vec gets a
10643    /// dup of the original fd so it can be restored by `host_redirect_scope_end`.
10644    /// `op_byte` matches `fusevm::op::redirect_op::*`.
10645    /// Apply a file-open result to a redirect fd; on error, emit
10646    /// zsh-format diagnostic, set redirect_failed, sink fd to /dev/null.
10647    /// Shared between WRITE/APPEND/READ/CLOBBER arms in
10648    /// host_apply_redirect to keep the error-handling identical.
10649    fn redir_open_or_fail(
10650        fd: i32,
10651        result: std::io::Result<fs::File>,
10652        target: &str,
10653        redirect_failed: &mut bool,
10654    ) -> bool {
10655        match result {
10656            Ok(file) => {
10657                let new_fd = file.into_raw_fd();
10658                unsafe {
10659                    libc::dup2(new_fd, fd);
10660                    libc::close(new_fd);
10661                }
10662                true
10663            }
10664            Err(e) => {
10665                let msg = match e.kind() {
10666                    std::io::ErrorKind::PermissionDenied => "permission denied",
10667                    std::io::ErrorKind::NotFound => "no such file or directory",
10668                    std::io::ErrorKind::IsADirectory => "is a directory",
10669                    _ => "redirect failed",
10670                };
10671                // c:Src/exec.c:3741 — zwarn with real lineno prefix.
10672                crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
10673                *redirect_failed = true;
10674                // The /dev/null sink keeps a failed scoped redirect
10675                // from leaking the aborted command's output to the
10676                // wrong fd until scope-end restores it. For a bare
10677                // `exec` redirect (permanent, no scope restore) C
10678                // leaves the fd UNTOUCHED — execerr() aborts the
10679                // statement and the original fd 1 keeps flowing
10680                // (A04redirect: `exec >./nonexistent/x` then `echo
10681                // output` still prints). c:Src/exec.c:3735-3742.
10682                let permanent = with_executor(|exec| exec.exec_redirs_permanent);
10683                if !permanent {
10684                    if let Ok(devnull) = fs::OpenOptions::new()
10685                        .read(true)
10686                        .write(true)
10687                        .open("/dev/null")
10688                    {
10689                        let new_fd = devnull.into_raw_fd();
10690                        unsafe {
10691                            libc::dup2(new_fd, fd);
10692                            libc::close(new_fd);
10693                        }
10694                    }
10695                }
10696                false
10697            }
10698        }
10699    }
10700    /// `host_apply_redirect` — see implementation.
10701    pub fn host_apply_redirect(&mut self, fd: u8, op_byte: u8, target: &str) {
10702        // `&>` / `&>>` always target both fd 1 and fd 2 regardless of the
10703        // fd byte the parser supplied (the lexer's tokfd clamp makes the
10704        // raw value unreliable for these forms).
10705        let fd: i32 = if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
10706            1
10707        } else {
10708            fd as i32
10709        };
10710        // c:Src/exec.c — for DUP_READ / DUP_WRITE forms (<&N / >&N),
10711        // validate the source fd is open BEFORE the save-and-dup
10712        // dance below. The save's `dup(fd)` reclaims the lowest free
10713        // fd, which on closed-fd reuse would let dup2(src=N, …)
10714        // succeed against the freshly-claimed slot — masking the
10715        // user's "bad file descriptor" error. Check src_fd first.
10716        if matches!(op_byte, r::DUP_READ | r::DUP_WRITE) {
10717            let n_check = target.trim_start_matches('&');
10718            if n_check != "-" {
10719                if let Ok(src_fd) = n_check.parse::<i32>() {
10720                    if unsafe { libc::fcntl(src_fd, libc::F_GETFD) } == -1 {
10721                        // c:Src/exec.c — zwarn with real lineno prefix.
10722                        crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src_fd));
10723                        self.set_last_status(1);
10724                        self.redirect_failed = true;
10725                        return;
10726                    }
10727                }
10728            }
10729        }
10730        // c:Src/exec.c:3978-3986 — bare `exec` redirects (nullexec==1)
10731        // skip the save entirely: "we specifically *don't* restore the
10732        // original fd's". C's save[] is per-execcmd, so exec's redirs
10733        // never enter an enclosing group's save list either; pushing
10734        // into `redirect_scope_stack.last_mut()` here (the enclosing
10735        // group's scope) made `{ exec 1>&-; … } 2>/dev/null` restore
10736        // stdout at group end — diverging from zsh, which keeps fd 1
10737        // closed for the rest of the script.
10738        if !self.exec_redirs_permanent {
10739            let saved = unsafe { libc::dup(fd) };
10740            if saved >= 0 {
10741                if let Some(top) = self.redirect_scope_stack.last_mut() {
10742                    top.push((fd, saved));
10743                } else {
10744                    // No scope — leave saved fd open and let the next scope
10745                    // reclaim it. (Caller without a scope leaks the dup; this
10746                    // matches `WithRedirects` parser construction always wrapping.)
10747                    unsafe { libc::close(saved) };
10748                }
10749            }
10750            // For `&>` / `&>>` also save fd 2 so the scope restores it after
10751            // the body. Otherwise stderr stays redirected past the command.
10752            if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
10753                let saved2 = unsafe { libc::dup(2) };
10754                if saved2 >= 0 {
10755                    if let Some(top) = self.redirect_scope_stack.last_mut() {
10756                        top.push((2, saved2));
10757                    } else {
10758                        unsafe { libc::close(saved2) };
10759                    }
10760                }
10761            }
10762        }
10763        // c:Src/exec.c:3722-3724 + 2447-2480 — MULTIOS split when this
10764        // command's stdout IS the pipeline output. C registers the pipe
10765        // in mfds[1] (`addfd(forked, save, mfds, 1, output, 1, NULL)`)
10766        // BEFORE walking the explicit redirect list, so a write-side
10767        // redirect of fd 1 finds mfds[1] occupied and, with MULTIOS
10768        // set, "split[s] the stream": fd 1 becomes the write end of an
10769        // internal pipe whose reader tees every chunk to BOTH the
10770        // pipeline pipe and the new target. That is why
10771        // `{ echo a; echo b >&2; } 3>&1 1>&2 2>&3 3>&- | cat` sends
10772        // `a` to the pipe (via the tee) AND to stderr — plain dup2
10773        // replacement loses the pipe stream. The scope-depth gate
10774        // mirrors mfds being per-execcmd: only the redirect list
10775        // attached to the stage's own command joins the pipe; nested
10776        // commands inside the body (`{ echo a > f; } | cat`) get a
10777        // fresh "mfds" and replace as usual.
10778        if fd == 1
10779            && self
10780                .pipe_output_scope
10781                .is_some_and(|d| d + 1 == self.redirect_scope_stack.len())
10782            && crate::ported::options::opt_state_get("multios").unwrap_or(true)
10783        {
10784            // Resolve the new write target exactly as the plain arms
10785            // below would, but as a raw fd for the tee.
10786            let new_target_fd: i32 = match op_byte {
10787                r::DUP_WRITE => {
10788                    // Numeric `>&N` only; `-` (close) and `p` (coproc)
10789                    // fall through to the plain arms.
10790                    target
10791                        .trim_start_matches('&')
10792                        .parse::<i32>()
10793                        .map(|src| unsafe { libc::dup(src) })
10794                        .unwrap_or(-1)
10795                }
10796                r::WRITE | r::CLOBBER => fs::File::create(target)
10797                    .map(|f| f.into_raw_fd())
10798                    .unwrap_or(-1),
10799                r::APPEND => fs::OpenOptions::new()
10800                    .create(true)
10801                    .append(true)
10802                    .open(target)
10803                    .map(|f| f.into_raw_fd())
10804                    .unwrap_or(-1),
10805                _ => -1,
10806            };
10807            if new_target_fd >= 0 {
10808                let pipe_dup = unsafe { libc::dup(1) };
10809                match (pipe_dup >= 0).then(os_pipe::pipe) {
10810                    Some(Ok((read_end, write_end))) => {
10811                        // Splitter: same read-loop shape as
10812                        // BUILTIN_MULTIOS_REDIRECT, with one ordering
10813                        // refinement. C's tee is a forked process
10814                        // (closemn → teeproc) whose wakeup latency lets
10815                        // the stage's DIRECT pipe writes land first —
10816                        // observed zsh output for `{ echo a; echo b >&2; }
10817                        // 3>&1 1>&2 2>&3 3>&- | cat` is `b` then `a`,
10818                        // 15/15 runs. A Rust thread wakes faster than
10819                        // the debug-build VM dispatches the next echo,
10820                        // inverting the order. Emulate the C timing
10821                        // observably: stream to the NEW target (file /
10822                        // stderr dup) immediately, but defer the
10823                        // pipe-bound copy until EOF (or a 64KB cap so a
10824                        // long-running stream still flows instead of
10825                        // growing memory unboundedly).
10826                        let write_now = |tfd: i32, data: &[u8]| {
10827                            let mut off = 0;
10828                            while off < data.len() {
10829                                let w = unsafe {
10830                                    libc::write(
10831                                        tfd,
10832                                        data[off..].as_ptr() as *const libc::c_void,
10833                                        data.len() - off,
10834                                    )
10835                                };
10836                                if w <= 0 {
10837                                    break;
10838                                }
10839                                off += w as usize;
10840                            }
10841                        };
10842                        let handle = std::thread::spawn(move || {
10843                            let mut rd = read_end;
10844                            let mut buf = [0u8; 8192];
10845                            let mut pipe_pending: Vec<u8> = Vec::new();
10846                            loop {
10847                                match std::io::Read::read(&mut rd, &mut buf) {
10848                                    Ok(0) | Err(_) => break,
10849                                    Ok(n) => {
10850                                        write_now(new_target_fd, &buf[..n]);
10851                                        pipe_pending.extend_from_slice(&buf[..n]);
10852                                        if pipe_pending.len() >= 65536 {
10853                                            write_now(pipe_dup, &pipe_pending);
10854                                            pipe_pending.clear();
10855                                        }
10856                                    }
10857                                }
10858                            }
10859                            write_now(pipe_dup, &pipe_pending);
10860                            unsafe {
10861                                libc::close(pipe_dup);
10862                                libc::close(new_target_fd);
10863                            }
10864                        });
10865                        let write_raw = AsRawFd::as_raw_fd(&write_end);
10866                        unsafe { libc::dup2(write_raw, 1) };
10867                        drop(write_end);
10868                        // Scope-end closes this dup (the last writer once
10869                        // the saved fd 1 is restored) → EOF → join.
10870                        let close_on_end = unsafe { libc::dup(1) };
10871                        if let Some(top) = self.multios_scope_stack.last_mut() {
10872                            top.push((close_on_end, handle));
10873                        } else {
10874                            unsafe { libc::close(close_on_end) };
10875                            let _ = handle.join();
10876                        }
10877                        return;
10878                    }
10879                    _ => unsafe {
10880                        // pipe()/dup failure — fall through to plain replace.
10881                        if pipe_dup >= 0 {
10882                            libc::close(pipe_dup);
10883                        }
10884                        libc::close(new_target_fd);
10885                    },
10886                }
10887            }
10888        }
10889        match op_byte {
10890            r::WRITE => {
10891                // Honor `setopt noclobber`: refuse to overwrite an
10892                // existing regular file unless `>!` / `>|` (CLOBBER).
10893                // zsh internally stores the inverted-name `clobber`
10894                // (default ON); `setopt noclobber` writes
10895                // `clobber=false`. Honor both keys.
10896                //
10897                // c:Src/exec.c:2241-2245 clobber_open recover path:
10898                // after O_EXCL fails, reopen and `if (!S_ISREG(...))
10899                // return fd;` — non-regular targets (char/block-
10900                // special, FIFO, socket) bypass the noclobber check.
10901                // Bug #30 in docs/BUGS.md: this bridge-side check did
10902                // a bare `Path::exists()` and treated `/dev/null` as
10903                // a protected file, breaking `setopt no_clobber; echo
10904                // hi > /dev/null` and every `2> /dev/null` idiom.
10905                // Add a regular-file stat gate that matches the C
10906                // semantic. The canonical clobber_open at
10907                // src/ported/exec.rs:2123 already handles this; the
10908                // bridge duplicates a stripped-down version here and
10909                // must mirror the same check.
10910                let noclobber = opt_state_get("noclobber").unwrap_or(false)
10911                    || !opt_state_get("clobber").unwrap_or(true);
10912                let target_meta = std::fs::metadata(target).ok();
10913                let target_is_regular_file = target_meta
10914                    .as_ref()
10915                    .map(|m| m.file_type().is_file())
10916                    .unwrap_or(false);
10917                // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY permits
10918                // re-using an EMPTY regular file under noclobber: `setopt
10919                // noclobber clobberempty; : >f; echo hi >f` overwrites f.
10920                // The inline bridge check ignored this and errored.
10921                let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
10922                    && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
10923                if noclobber && target_is_regular_file && !clobber_empty_ok {
10924                    eprintln!("{}:1: file exists: {}", shname(), target);
10925                    self.set_last_status(1);
10926                    // c:Src/exec.c — set redirect_failed so the scope-end
10927                    // hook (`with_redirects_end` in this file) forces
10928                    // $? to 1 regardless of the still-running command's
10929                    // own exit. Without this the next command (e.g.
10930                    // `echo x` writing to /dev/null below) succeeds
10931                    // and overwrites the redirect-failure status,
10932                    // making noclobber unobservable from $?.
10933                    self.redirect_failed = true;
10934                    // Sink the upcoming command's stdout to /dev/null
10935                    // so we don't leak its output to the terminal.
10936                    // zsh skips the command entirely; we approximate by
10937                    // discarding the output (the redirect target was
10938                    // the user's chosen sink, but with noclobber the
10939                    // file is protected — discarding matches the
10940                    // user's intent better than printing to terminal).
10941                    if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
10942                        let new_fd = file.into_raw_fd();
10943                        unsafe {
10944                            libc::dup2(new_fd, fd);
10945                            libc::close(new_fd);
10946                        }
10947                    }
10948                    return;
10949                }
10950                if !Self::redir_open_or_fail(
10951                    fd,
10952                    fs::File::create(target),
10953                    target,
10954                    &mut self.redirect_failed,
10955                ) {
10956                    self.set_last_status(1);
10957                }
10958            }
10959            r::CLOBBER => {
10960                if !Self::redir_open_or_fail(
10961                    fd,
10962                    fs::File::create(target),
10963                    target,
10964                    &mut self.redirect_failed,
10965                ) {
10966                    self.set_last_status(1);
10967                }
10968            }
10969            r::APPEND => {
10970                // c:Src/exec.c:3924-3927 — `>>` honors NO_CLOBBER+!APPENDCREATE
10971                // by opening O_APPEND|O_WRONLY WITHOUT O_CREAT, so missing
10972                // files yield ENOENT. zsh source:
10973                //   if (!isset(CLOBBER) && !isset(APPENDCREATE) &&
10974                //       !IS_CLOBBER_REDIR(fn->type))
10975                //       mode = O_WRONLY|O_APPEND|O_NOCTTY;
10976                //   else mode = O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY;
10977                // (IS_CLOBBER_REDIR — `>>!`/`>>|` — is currently flattened
10978                // to plain APPEND at compile time in
10979                // src/extensions/compile_zsh.rs:1654-1655, so the bang/pipe
10980                // forms can't be distinguished here yet.)
10981                let noclobber = opt_state_get("noclobber").unwrap_or(false)
10982                    || !opt_state_get("clobber").unwrap_or(true);
10983                let append_create = opt_state_get("appendcreate").unwrap_or(false)
10984                    || opt_state_get("append_create").unwrap_or(false);
10985                let open_result = if noclobber && !append_create {
10986                    fs::OpenOptions::new().append(true).open(target) // no create
10987                } else {
10988                    fs::OpenOptions::new()
10989                        .create(true)
10990                        .append(true)
10991                        .open(target)
10992                };
10993                if !Self::redir_open_or_fail(
10994                    fd,
10995                    open_result,
10996                    target,
10997                    &mut self.redirect_failed,
10998                ) {
10999                    self.set_last_status(1);
11000                }
11001            }
11002            r::READ => {
11003                if !Self::redir_open_or_fail(
11004                    fd,
11005                    fs::File::open(target),
11006                    target,
11007                    &mut self.redirect_failed,
11008                ) {
11009                    self.set_last_status(1);
11010                }
11011            }
11012            r::READ_WRITE => {
11013                if let Ok(file) = fs::OpenOptions::new()
11014                    .create(true)
11015                    .truncate(false) // <> opens existing-or-new without truncating
11016                    .read(true)
11017                    .write(true)
11018                    .open(target)
11019                {
11020                    let new_fd = file.into_raw_fd();
11021                    unsafe {
11022                        libc::dup2(new_fd, fd);
11023                        libc::close(new_fd);
11024                    }
11025                }
11026            }
11027            r::DUP_READ | r::DUP_WRITE => {
11028                // Target is a numeric fd reference like `&3`. The parser
11029                // strips the `&` prefix before we get here in some paths,
11030                // others retain it — accept both. Also support `-` for
11031                // close-fd (`<&-` / `>&-`) per POSIX. The src_fd
11032                // validity check ran above before the save-and-dup.
11033                let n = target.trim_start_matches('&');
11034                if n == "-" {
11035                    unsafe { libc::close(fd) };
11036                } else if n == "p" {
11037                    // c:Src/exec.c — `<&p` / `>&p` route through the
11038                    // coprocin / coprocout globals. zsh's `coproc CMD`
11039                    // launch publishes those fds; the canonical
11040                    // bin_print / bin_read `-p` arms already consume
11041                    // them. The DUP redirect form is the third
11042                    // consumer: it must dup the coproc fd onto the
11043                    // target slot so the next command's stdin/stdout
11044                    // is wired to the running coprocess. Bug #388.
11045                    let coproc_fd = if op_byte == r::DUP_READ {
11046                        crate::ported::modules::clone::coprocin
11047                            .load(std::sync::atomic::Ordering::Relaxed)
11048                    } else {
11049                        crate::ported::modules::clone::coprocout
11050                            .load(std::sync::atomic::Ordering::Relaxed)
11051                    };
11052                    if coproc_fd < 0 {
11053                        eprintln!("{}:1: no coprocess", shname());
11054                        self.set_last_status(1);
11055                        self.redirect_failed = true;
11056                    } else {
11057                        unsafe {
11058                            libc::dup2(coproc_fd, fd);
11059                        }
11060                    }
11061                } else if let Ok(src_fd) = n.parse::<i32>() {
11062                    unsafe { libc::dup2(src_fd, fd) };
11063                } else if op_byte == r::DUP_WRITE {
11064                    // c:Src/glob.c:2184-2187 xpandredir — a MERGEOUT
11065                    // word that expands to a non-number becomes
11066                    // REDIR_ERRWRITE: `cmd >& word` opens `word` and
11067                    // routes BOTH fd 1 and fd 2 there. Reached only
11068                    // for dynamic words (`>&$var`); static filenames
11069                    // were converted at compile time.
11070                    if let Ok(file) = fs::File::create(target) {
11071                        let new_fd = file.into_raw_fd();
11072                        unsafe {
11073                            libc::dup2(new_fd, 1);
11074                            libc::dup2(new_fd, 2);
11075                            libc::close(new_fd);
11076                        }
11077                    }
11078                } else {
11079                    // c:Src/glob.c:2185 — MERGEIN non-number:
11080                    // `zerr("file number expected")`.
11081                    crate::ported::utils::zerr("file number expected");
11082                    self.set_last_status(1);
11083                    self.redirect_failed = true;
11084                }
11085            }
11086            r::WRITE_BOTH => {
11087                if let Ok(file) = fs::File::create(target) {
11088                    let new_fd = file.into_raw_fd();
11089                    unsafe {
11090                        libc::dup2(new_fd, 1);
11091                        libc::dup2(new_fd, 2);
11092                        libc::close(new_fd);
11093                    }
11094                }
11095            }
11096            r::APPEND_BOTH => {
11097                if let Ok(file) = fs::OpenOptions::new()
11098                    .create(true)
11099                    .append(true)
11100                    .open(target)
11101                {
11102                    let new_fd = file.into_raw_fd();
11103                    unsafe {
11104                        libc::dup2(new_fd, 1);
11105                        libc::dup2(new_fd, 2);
11106                        libc::close(new_fd);
11107                    }
11108                }
11109            }
11110            _ => {}
11111        }
11112    }
11113
11114    /// Push a fresh redirect scope. `_count` is informational — the actual
11115    /// saved fds are appended by host_apply_redirect into the top scope.
11116    pub fn host_redirect_scope_begin(&mut self, _count: u8) {
11117        // c:Src/exec.c:3722-3724 — the pipeline child set
11118        // `pipe_output_pending` right after dup2'ing its stdout onto
11119        // the pipe; the FIRST redirect scope opened in that child is
11120        // the stage command's own redirect list (same execcmd as the
11121        // pipe's addfd into mfds[1]). Capture the depth so only THAT
11122        // list's fd-1 write redirects MULTIOS-join the pipe.
11123        if self.pipe_output_pending {
11124            self.pipe_output_pending = false;
11125            self.pipe_output_scope = Some(self.redirect_scope_stack.len());
11126        }
11127        self.redirect_scope_stack.push(Vec::new());
11128        self.multios_scope_stack.push(Vec::new());
11129    }
11130
11131    /// Pop the top redirect scope, restoring saved fds.
11132    pub fn host_redirect_scope_end(&mut self) {
11133        // c:Src/exec.c — restore saved fds FIRST so the multios
11134        // pipe-write end is released from `fd`, then close our
11135        // tracked close_on_end (the last surviving writer dup), then
11136        // join the splitter thread. If we closed close_on_end before
11137        // restoring saved, `fd` would still hold a pipe writer and
11138        // the thread would block forever waiting for EOF.
11139        if let Some(saved) = self.redirect_scope_stack.pop() {
11140            for (fd, saved_fd) in saved.into_iter().rev() {
11141                unsafe {
11142                    libc::dup2(saved_fd, fd);
11143                    libc::close(saved_fd);
11144                }
11145            }
11146        }
11147        if let Some(scope) = self.multios_scope_stack.pop() {
11148            // Close ALL tracked writer dups BEFORE joining any
11149            // thread. When one splitter holds a dup of another's
11150            // pipe write-end (two multios in one scope where a later
11151            // one duped fd 1 while an earlier splitter owned it),
11152            // joining in push order deadlocks: splitter A's EOF
11153            // waits on splitter B's writer dup, which only closes
11154            // after B's thread exits — blocked behind A's join.
11155            let mut handles = Vec::with_capacity(scope.len());
11156            for (write_fd, handle) in scope {
11157                if write_fd >= 0 {
11158                    unsafe {
11159                        libc::close(write_fd);
11160                    }
11161                }
11162                handles.push(handle);
11163            }
11164            for handle in handles {
11165                let _ = handle.join();
11166            }
11167        }
11168        // The scope that captured the pipeline-output marker is gone;
11169        // deeper-nested future scopes must not re-match its depth.
11170        if self.pipe_output_scope == Some(self.redirect_scope_stack.len()) {
11171            self.pipe_output_scope = None;
11172        }
11173    }
11174
11175    /// Set up `content` as stdin (fd 0) for the next command.
11176    /// Used by `Op::HereDoc(idx)` and `Op::HereString`.
11177    ///
11178    /// c:Src/exec.c:4655 getherestr — C writes the body to a TEMP
11179    /// FILE (gettempfile → write_loop → close → reopen O_RDONLY →
11180    /// unlink), NOT a pipe. The previous pipe+writer-thread shape
11181    /// SIGPIPE'd the whole shell when the consumer never read the
11182    /// body (`: <<< ${(F)x/y}` — D04parameter chunk 211, flaky
11183    /// rc=141): the redirect-scope teardown closed the read end
11184    /// while the detached thread was still in write_all, and the
11185    /// shell's SIGPIPE disposition is SIG_DFL. A temp file has no
11186    /// reader/writer coupling — matching C exactly, including
11187    /// lseek-ability of fd 0, which pipes don't give.
11188    pub fn host_set_pending_stdin(&mut self, content: String) {
11189        // c:4673 — `gettempfile(NULL, 1, &s)`.
11190        let mut tmp = std::env::temp_dir();
11191        tmp.push(format!(
11192            "zshrs-herestr-{}-{:x}",
11193            std::process::id(),
11194            std::time::SystemTime::now()
11195                .duration_since(std::time::UNIX_EPOCH)
11196                .map(|d| d.as_nanos())
11197                .unwrap_or(0)
11198        ));
11199        // c:4675 — `write_loop(fd, t, len); close(fd);`
11200        if std::fs::write(&tmp, content.as_bytes()).is_err() {
11201            return; // c:4674 — tempfile failure → no redirect
11202        }
11203        // c:Src/utils.c gettempfile → mkstemp creates the temp file mode
11204        // 0600 IGNORING the umask, so the O_RDONLY reopen below always
11205        // succeeds. `std::fs::write` honors the umask, so under `umask
11206        // 0777` the file landed mode 0000 and the reopen failed with
11207        // EACCES — `cat <<<x` then read empty stdin. Force 0600 to match
11208        // mkstemp's umask-independent permissions.
11209        let _ = std::fs::set_permissions(
11210            &tmp,
11211            <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
11212        );
11213        // c:4677 — `fd = open(s, O_RDONLY | O_NOCTTY);`
11214        let file = match std::fs::File::open(&tmp) {
11215            Ok(f) => f,
11216            Err(_) => {
11217                let _ = std::fs::remove_file(&tmp);
11218                return;
11219            }
11220        };
11221        // c:4678 — `unlink(s);` — fd stays valid, name disappears.
11222        let _ = std::fs::remove_file(&tmp);
11223        let saved = unsafe { libc::dup(libc::STDIN_FILENO) };
11224        if saved >= 0 {
11225            if let Some(top) = self.redirect_scope_stack.last_mut() {
11226                top.push((libc::STDIN_FILENO, saved));
11227            } else {
11228                unsafe { libc::close(saved) };
11229            }
11230        }
11231        let read_fd = AsRawFd::as_raw_fd(&file);
11232        unsafe { libc::dup2(read_fd, libc::STDIN_FILENO) };
11233        drop(file);
11234    }
11235
11236    /// Spawn an external command using zshrs's full dispatch logic
11237    /// (intercepts, command_hash, redirect handling). Used by
11238    /// `ZshrsHost::exec` so the bytecode VM's `Op::Exec` and
11239    /// `Op::CallFunction` external fallback get the same semantics as
11240    /// the tree-walker's `execute_external` rather than a plain
11241    /// `Command::new` shortcut. Returns the exit status.
11242    pub fn host_exec_external(&mut self, args: &[String]) -> i32 {
11243        // If a glob expansion in this command's argv triggered the
11244        // nomatch error path, suppress the actual exec and return
11245        // status 1 — mirrors zsh's command-aborted-on-glob-error
11246        // behaviour. The flag is reset BEFORE returning so the next
11247        // command starts clean.
11248        //
11249        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH sets
11250        // ERRFLAG_ERROR but C's execlist clears the bit per-sublist
11251        // so subsequent commands run. Symmetric with the builtin
11252        // dispatcher's clear at fusevm_bridge.rs:299 — clear it here
11253        // too at the external-command post-command-boundary.
11254        consume_tilde_globsubst_carrier();
11255        if self.current_command_glob_failed.get() {
11256            self.current_command_glob_failed.set(false);
11257            crate::ported::utils::errflag.fetch_and(
11258                !crate::ported::zsh_h::ERRFLAG_ERROR,
11259                std::sync::atomic::Ordering::Relaxed,
11260            );
11261            self.set_last_status(1);
11262            return 1;
11263        }
11264        // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the
11265        // NOMATCH gate above, same external-path semantics (skip
11266        // command, `no match`, clear ERRFLAG so the next sublist
11267        // runs).
11268        if consume_badcshglob() {
11269            crate::ported::utils::errflag.fetch_and(
11270                !crate::ported::zsh_h::ERRFLAG_ERROR,
11271                std::sync::atomic::Ordering::Relaxed,
11272            );
11273            self.set_last_status(1);
11274            return 1;
11275        }
11276        let Some((cmd, rest)) = args.split_first() else {
11277            return 0;
11278        };
11279        // Empty command name (e.g. result of an empty `$(false)`
11280        // command-sub being the only word) — zsh: no command runs,
11281        // exit status preserved from prior step. Was hitting the
11282        // "command not found: " path with empty name.
11283        if cmd.is_empty() && rest.is_empty() {
11284            return self.last_status();
11285        }
11286        let rest_vec: Vec<String> = rest.to_vec();
11287        // Update `$_` with the just-arriving argv so the next command
11288        // reads `_=<last_arg>`. Mirrors C zsh's writeback in
11289        // `execcmd_exec` (Src/exec.c). Per `args.last()` semantics,
11290        // when invoked as `cmd a b c`, `$_` becomes "c" — for a bare
11291        // command with no args, `$_` becomes the command name itself.
11292        crate::ported::params::set_zunderscore(args);
11293
11294        // Builtins not in fusevm's name→id table fall through to
11295        // host.exec. Catch them here before the OS-level exec attempts
11296        // to spawn a non-existent binary.
11297        match cmd.as_str() {
11298            "sched" => return dispatch_builtin("sched", rest_vec.clone()),
11299            "echotc" => return dispatch_builtin("echotc", rest_vec.clone()),
11300            "echoti" => return dispatch_builtin("echoti", rest_vec.clone()),
11301            "zpty" => return dispatch_builtin("zpty", rest_vec.clone()),
11302            "ztcp" => return dispatch_builtin("ztcp", rest_vec.clone()),
11303            "zsocket" => {
11304                // c:Src/Modules/socket.c:276 BUILTIN spec — BUILTINS["zsocket"]
11305                // optstr "ad:ltv" parsed by execbuiltin.
11306                return dispatch_builtin("zsocket", rest_vec.clone());
11307            }
11308            "private" => {
11309                // c:Src/Modules/param_private.c:217 — bin_private via
11310                // BUILTINS["private"]. The autoload require_module
11311                // (exec.c:2700-2717) fires inside
11312                // dispatch_builtin_raw, the chokepoint for all routes.
11313                return dispatch_builtin("private", rest_vec.clone());
11314            }
11315            "zformat" => return dispatch_builtin("zformat", rest_vec.clone()),
11316            "zregexparse" => return dispatch_builtin("zregexparse", rest_vec.clone()),
11317            // `unalias`/`unhash`/`unfunction` share `bin_unhash` but
11318            // each carries its own funcid (BIN_UNALIAS / BIN_UNHASH /
11319            // BIN_UNFUNCTION) — dispatch_builtin handles the BUILTINS
11320            // lookup + funcid propagation via execbuiltin.
11321            "unalias" | "unhash" | "unfunction" => {
11322                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
11323            }
11324            // zsh-bundled rename helpers — implemented natively in
11325            // Rust so `autoload -U zmv` works without shipping the
11326            // function source. (Without this, the autoload path hangs.)
11327            "zmv" => return crate::extensions::ext_builtins::zmv(&rest_vec, "mv"),
11328            "zcp" => return crate::extensions::ext_builtins::zmv(&rest_vec, "cp"),
11329            "zln" => return crate::extensions::ext_builtins::zmv(&rest_vec, "ln"),
11330            "zcalc" => return crate::extensions::ext_builtins::zcalc(&rest_vec),
11331            "zselect" => {
11332                // Route through canonical dispatch_builtin which goes
11333                // via execbuiltin → BUILTINS["zselect"] (zselect.c:272).
11334                return dispatch_builtin("zselect", rest_vec.clone());
11335            }
11336            "cap" => return dispatch_builtin("cap", rest_vec.clone()),
11337            "getcap" => return dispatch_builtin("getcap", rest_vec.clone()),
11338            "setcap" => return dispatch_builtin("setcap", rest_vec.clone()),
11339            "yes" => return self.builtin_yes(&rest_vec),
11340            "nl" => return self.builtin_nl(&rest_vec),
11341            "env" => return self.builtin_env(&rest_vec),
11342            "printenv" => return self.builtin_printenv(&rest_vec),
11343            "tty" => return self.builtin_tty(&rest_vec),
11344            // c:Src/Modules/files.c:806 — BUILTINS["chgrp"] with
11345            // BIN_CHGRP funcid + "hRs" optstr.
11346            "chgrp" => return dispatch_builtin("chgrp", rest_vec.clone()),
11347            "nproc" => return self.builtin_nproc(&rest_vec),
11348            "expr" => return self.builtin_expr(&rest_vec),
11349            "sha256sum" => return self.builtin_sha256sum(&rest_vec),
11350            "base64" => return self.builtin_base64(&rest_vec),
11351            "tac" => return self.builtin_tac(&rest_vec),
11352            "expand" => return self.builtin_expand(&rest_vec),
11353            "unexpand" => return self.builtin_unexpand(&rest_vec),
11354            "paste" => return self.builtin_paste(&rest_vec),
11355            "fold" => return self.builtin_fold(&rest_vec),
11356            "shuf" => return self.builtin_shuf(&rest_vec),
11357            "comm" => return self.builtin_comm(&rest_vec),
11358            "cksum" => return self.builtin_cksum(&rest_vec),
11359            "factor" => return self.builtin_factor(&rest_vec),
11360            "tsort" => return self.builtin_tsort(&rest_vec),
11361            "sum" => return self.builtin_sum(&rest_vec),
11362            "mkfifo" => return self.builtin_mkfifo(&rest_vec),
11363            "link" => return self.builtin_link(&rest_vec),
11364            "unlink" => return self.builtin_unlink(&rest_vec),
11365            "dircolors" => return self.builtin_dircolors(&rest_vec),
11366            "groups" => return self.builtin_groups(&rest_vec),
11367            "arch" => return self.builtin_arch(&rest_vec),
11368            "nice" => return self.builtin_nice(&rest_vec),
11369            "logname" => return self.builtin_logname(&rest_vec),
11370            "tput" => return self.builtin_tput(&rest_vec),
11371            "users" => return self.builtin_users(&rest_vec),
11372            // "sync" => return self.bin_sync(&rest_vec),
11373            "zbuild" => return self.builtin_zbuild(&rest_vec),
11374            // `zf_*` aliases from `zsh/files` (Src/Modules/files.c
11375            // BUILTIN table at line 816-824). The C source binds
11376            // both unprefixed (`chmod`) and prefixed (`zf_chmod`)
11377            // names to the SAME `bin_chmod` etc. handlers — the
11378            // prefixed forms exist so a script can portably reach
11379            // the builtin even when a function or alias has shadowed
11380            // the bare name. Each arm routes through the canonical
11381            // zf_* aliases route through canonical BUILTINS entries
11382            // (files.c:816-824) — execbuiltin parses each fn's optstr
11383            // automatically.
11384            "mkdir" | "zf_mkdir" | "zf_rm" | "zf_rmdir" | "zf_chmod" | "zf_chown" | "zf_chgrp"
11385            | "zf_ln" | "zf_mv" | "zf_sync"
11386                // `--zsh` parity gate: zsh -fc has zsh/files UNLOADED
11387                // — bare `mkdir` is /bin/mkdir (so `command mkdir -p`
11388                // honors the system flag set; zconvey.plugin.zsh:44
11389                // got "File exists" from the in-process bin_mkdir
11390                // that this arm intercepted) and `zf_*` names are
11391                // command-not-found 127 until `zmodload zsh/files`.
11392                // Fall through to the external/exec path in --zsh
11393                // mode; default zshrs mode keeps the anti-fork
11394                // intercept.
11395                if !crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) =>
11396            {
11397                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
11398            }
11399            // `zstat` — port of zsh/stat module (Src/Modules/stat.c
11400            // BUILTIN("zstat", …)). Returns file metadata as
11401            // `field value` pairs / an assoc / a plus-separated
11402            // list depending on flags. zsh ALSO registers `stat`
11403            // bound to the same handler, but that name conflicts
11404            // with the system `stat(1)` binary (every script that
11405            // calls `stat -f '%Lp' …` would break). zsh resolves
11406            // this through opt-in `zmodload`; zshrs's modules are
11407            // statically linked so we keep `stat` routing to the
11408            // external command and only intercept the unambiguous
11409            // `zstat` name.
11410            "zstat" => {
11411                // Canonical bin_stat per stat.c:638 via BUILTINS["zstat"].
11412                return dispatch_builtin("zstat", rest_vec.clone());
11413            }
11414            _ => {}
11415        }
11416
11417        // AOP intercepts: when an `intercept :before/:around/:after foo` block
11418        // is registered, dynamic-command-name dispatch must consult it before
11419        // spawning. Without this, `cmd=ls; $cmd` bypasses every intercept that
11420        // a literal `ls` would trigger. The full_cmd string mirrors what the
11421        // tree-walker era passed (cmd + args joined by space) so existing
11422        // pattern matchers continue to work.
11423        if !self.intercepts.is_empty() {
11424            let full_cmd = if rest_vec.is_empty() {
11425                cmd.clone()
11426            } else {
11427                format!("{} {}", cmd, rest_vec.join(" "))
11428            };
11429            if let Some(intercept_result) = self.run_intercepts(cmd, &full_cmd, &rest_vec) {
11430                return intercept_result.unwrap_or(127);
11431            }
11432        }
11433
11434        // User-defined function lookup before OS-level exec. zsh's
11435        // dynamic-command-name dispatch (`cmd=hook1; $cmd`) checks
11436        // the function table FIRST — without this, `$f` for a
11437        // function-name `f` was always falling through to
11438        // `execute_external` and erroring "command not found".
11439        // Plugin code uses this pattern constantly:
11440        //   for f in "${precmd_functions[@]}"; do "$f"; done
11441        if self.function_exists(cmd) {
11442            if let Some(status) = self.dispatch_function_call(cmd, &rest_vec) {
11443                return status;
11444            }
11445        }
11446
11447        self.execute_external(cmd, &rest_vec, &[]).unwrap_or(127)
11448    }
11449}