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    /// Port of C's `FILE *xtrerr` xtrace stream (Src/exec.c:81). C builds
91    /// each trace line in this stdio buffer — `printprompt4` does
92    /// `fprintf(xtrerr, …)`, then args via `fputs`/`fputc` — and
93    /// `fflush(xtrerr)` writes the WHOLE line to stderr in one syscall
94    /// (makecline c:2122-2123, addvars c:2588, condition c:1372). That
95    /// single flush is exactly why a forked pipeline stage's trace line
96    /// reaches the shared stderr fd atomically and never interleaves with
97    /// a concurrent stage. zshrs previously emitted PS4 and the command
98    /// text as separate `eprint!` writes, which raced under load. Model
99    /// the FILE buffer as this thread-local String (a forked child owns
100    /// its own copy); `xtrerr_fputs` appends, `xtrerr_flush` does the
101    /// single write.
102    static XTRERR: RefCell<String> = const { RefCell::new(String::new()) };
103
104    /// Stack of (RETFLAG, BREAKS, CONTFLAG, EXIT_PENDING) tuples saved
105    /// at try-block exit so the always-arm body can run cleanly even
106    /// when the try-block fired `return` / `break` / `continue` /
107    /// `exit`. Restored right before the post-always re-jump so the
108    /// escape resumes propagation past the construct.
109    /// c:Src/exec.c WC_TRYBLOCK — zsh's wordcode walker handles this
110    /// inline; the zshrs port lifts it into a paired SET / RESTORE
111    /// pair around the always-arm.
112    static TRY_ESCAPE_SAVE: RefCell<Vec<(i32, i32, i32, i32)>> =
113        const { RefCell::new(Vec::new()) };
114    /// Re-entry guard for BUILTIN_DEBUG_TRAP. While the DEBUG trap
115    /// body is running, the per-statement DEBUG_TRAP dispatch in the
116    /// trap body must NOT re-fire (otherwise infinite recursion +
117    /// stack overflow). zsh's in_trap counter at Src/signals.c
118    /// serves the same purpose.
119    static DEBUG_TRAP_REENTRY: Cell<bool> = const { Cell::new(false) };
120    /// Stack of (saved_stdout, saved_stderr) tuples pushed by
121    /// `cmd_subst` around its nested-VM run. RUST-ONLY: zsh forks
122    /// each cmdsub so trap output during the cmdsub naturally
123    /// lands on the PARENT's stdout. zshrs's in-process cmdsub
124    /// dups fd 1 → pipe, so a trap firing during cmdsub would
125    /// emit into the captured value. Traps consult this stack
126    /// to route their body output to the topmost saved_stdout
127    /// instead of the cmdsub's fd 1. Bug #56 in docs/BUGS.md.
128    pub static CMDSUBST_OUTER_FDS: RefCell<Vec<(i32, i32)>> =
129        const { RefCell::new(Vec::new()) };
130    /// c:Src/exec.c:5025 getproc (PATH_DEV_FD branch) — the parent
131    /// keeps the `>(cmd)` pipe WRITE end open under `/dev/fd/N`,
132    /// parks it in the job's filelist (`fdtable[fd] =
133    /// FDT_PROC_SUBST; addfilelist(NULL, fd)`), and deletefilelist
134    /// closes it when the consuming job finishes — that close is
135    /// what lets the `>(cmd)` child's reader see EOF. zshrs runs
136    /// commands in-process, so the equivalent is: record
137    /// `(scope_depth, fd)` here and drain after the consuming
138    /// command (external exec or builtin dispatch) completes.
139    static PSUB_PENDING_FDS: RefCell<Vec<(usize, i32)>> = const { RefCell::new(Vec::new()) };
140    /// Scope depth for PSUB_PENDING_FDS tagging. Incremented around
141    /// nested execution contexts (cmd-subst bodies, shell-function
142    /// bodies) so a command running INSIDE the nested context only
143    /// drains its own `>(cmd)` fds, never the enclosing command's
144    /// (e.g. `tee >(wc) $(print x)` — print must not close tee's
145    /// fd). Mirrors C's per-job filelist ownership.
146    static PSUB_SCOPE_DEPTH: Cell<usize> = const { Cell::new(0) };
147    /// Forked `<(cmd)`/`>(cmd)` child pids awaiting reap. Drained
148    /// non-blockingly (WNOHANG) by note_psub_child so proc-sub children
149    /// don't accumulate as zombies across a shell session.
150    static PSUB_CHILDREN: RefCell<Vec<i32>> = const { RefCell::new(Vec::new()) };
151}
152
153/// Record a proc-sub child pid and best-effort reap any already-exited
154/// proc-sub children (WNOHANG). Non-blocking: still-running children
155/// stay parked and get reaped on a later call.
156pub(crate) fn note_psub_child(pid: i32) {
157    if pid <= 0 {
158        return;
159    }
160    PSUB_CHILDREN.with(|v| {
161        let mut v = v.borrow_mut();
162        v.push(pid);
163        v.retain(|&p| {
164            let mut status = 0;
165            // WNOHANG: reap if exited, else keep parked.
166            let r = unsafe { libc::waitpid(p, &mut status, libc::WNOHANG) };
167            // r == p → reaped; r == 0 → still running (keep); r < 0 →
168            // already gone/not ours (drop).
169            r == 0
170        });
171    });
172}
173
174/// Port of `fputs(s, xtrerr)` / `fprintf(xtrerr, "%s", s)` (Src/exec.c):
175/// append `s` to the buffered xtrace line. Nothing reaches stderr until
176/// [`xtrerr_flush`] (the port of `fflush(xtrerr)`) writes the line whole.
177pub(crate) fn xtrerr_fputs(s: &str) {
178    XTRERR.with(|b| b.borrow_mut().push_str(s));
179}
180
181/// Port of `fflush(xtrerr)` (Src/exec.c:1373/2123/2596): write the
182/// buffered xtrace line to stderr in ONE `write` and clear the buffer, so
183/// the line lands on the shared fd atomically (no interleaving across
184/// concurrent pipeline stages).
185pub(crate) fn xtrerr_flush() {
186    XTRERR.with(|b| {
187        let mut buf = b.borrow_mut();
188        if !buf.is_empty() {
189            use std::io::Write;
190            let _ = std::io::stderr().write_all(buf.as_bytes());
191            buf.clear();
192        }
193    });
194}
195
196/// RAII guard bumping the psub scope depth — see PSUB_SCOPE_DEPTH.
197pub(crate) struct PsubScope;
198
199impl PsubScope {
200    pub(crate) fn enter() -> Self {
201        PSUB_SCOPE_DEPTH.with(|d| d.set(d.get() + 1));
202        PsubScope
203    }
204}
205
206impl Drop for PsubScope {
207    fn drop(&mut self) {
208        PSUB_SCOPE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
209    }
210}
211
212/// RAII guard bumping `$ZSH_SUBSHELL` for the duration of an
213/// in-process command substitution.
214///
215/// c:Src/exec.c:1161 — entersubsh() does `zsh_subshell++;` and zsh's
216/// cmdsub FORKS, so the increment dies with the child and the parent's
217/// value is untouched. zshrs runs cmdsubs in-process on a nested VM,
218/// so the visible param must be bumped on entry and restored on exit.
219/// Writes paramtab u_val directly because ZSH_SUBSHELL is PM_READONLY
220/// (same bypass pattern as the subshell-builtin bump below); also
221/// mirrors into ported::exec::zsh_subshell so exec.c:4376-style
222/// `forked | zsh_subshell` reads agree.
223pub(crate) struct CmdSubstSubshellBump {
224    saved_val: i64,
225    saved_str: Option<String>,
226}
227
228impl CmdSubstSubshellBump {
229    pub(crate) fn enter() -> Self {
230        let mut saved_val = 0i64;
231        let mut saved_str = None;
232        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
233            if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
234                saved_val = pm.u_val;
235                saved_str = pm.u_str.clone();
236                pm.u_val = saved_val + 1;
237                pm.u_str = Some((saved_val + 1).to_string());
238                pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
239            }
240        }
241        crate::ported::exec::zsh_subshell.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
242        CmdSubstSubshellBump {
243            saved_val,
244            saved_str,
245        }
246    }
247}
248
249impl Drop for CmdSubstSubshellBump {
250    fn drop(&mut self) {
251        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
252            if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
253                pm.u_val = self.saved_val;
254                pm.u_str = self.saved_str.take();
255            }
256        }
257        crate::ported::exec::zsh_subshell.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
258    }
259}
260
261/// Port of deletefilelist() from Src/jobs.c (the `>(cmd)` fd arm):
262/// closes every pending proc-subst write end created at or inside
263/// the current scope depth, exactly when C deletes the consuming
264/// job's filelist (getproc parks the fd there via
265/// `addfilelist(NULL, fd)`, Src/exec.c:5025+).
266fn close_pending_psub_fds() {
267    let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
268    PSUB_PENDING_FDS.with(|v| {
269        v.borrow_mut().retain(|&(d, fd)| {
270            if d >= depth {
271                unsafe { libc::close(fd) };
272                false
273            } else {
274                true
275            }
276        });
277    });
278}
279
280/// RAII drain guard — instantiated at the top of the consuming-
281/// command paths (dispatch_builtin, ZshrsHost::exec) so the pending
282/// `>(cmd)` write ends close on every exit path once the command
283/// finished, exactly when C's job filelist would be deleted.
284struct PsubFdGuard;
285
286impl Drop for PsubFdGuard {
287    fn drop(&mut self) {
288        close_pending_psub_fds();
289    }
290}
291
292/// Peek the outermost cmdsub-saved (stdout, stderr) fds, if any.
293/// Returns None when no cmdsub is currently capturing. Used by the
294/// trap dispatcher in `src/ported/signals.rs::dotrap` to route trap
295/// body output to the parent's real stdout (matching zsh's forked
296/// cmdsub behaviour) instead of the cmdsub's pipe-bound fd 1.
297/// Bug #56 in docs/BUGS.md.
298pub fn cmdsubst_outer_stdout() -> Option<i32> {
299    CMDSUBST_OUTER_FDS.with(|s| s.borrow().last().map(|(o, _)| *o))
300}
301
302// Thread-local pointer to the current ShellExecutor.
303// Set before VM execution, cleared after. Used by builtin handlers.
304thread_local! {
305    static CURRENT_EXECUTOR: RefCell<Option<*mut ShellExecutor>> = const { RefCell::new(None) };
306    /// Set by subshell_end after a deferred subshell `exit N` lands.
307    /// Read + cleared by the next GET_VAR sync_status path so the
308    /// vm.last_status → LASTVAL sync doesn't clobber the deferred
309    /// exit status. RUST-ONLY: needed because zshrs runs subshells
310    /// in-process (no fork) so vm.last_status doesn't track the
311    /// subshell's exit; C zsh's subshell forks and the child's
312    /// process::exit(N) becomes $? in the parent automatically.
313    static SUBSHELL_EXIT_STATUS_PENDING: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
314    /// The installed session executor, registered by
315    /// `exec::install_session_executor`. Lets [`with_session_context`]
316    /// establish a VM execution context for STARTUP work that runs
317    /// before the loop's first `execode` enters one (rc-file sourcing in
318    /// `run_init_scripts`, c:1914). Mirrors exec.rs's own
319    /// `SESSION_EXECUTOR`; kept here so the context helper lives next to
320    /// `ExecutorContext`/`CURRENT_EXECUTOR`.
321    static SESSION_EXECUTOR_PTR: std::cell::Cell<Option<*mut ShellExecutor>> = const { std::cell::Cell::new(None) };
322    /// GLOB_ASSIGN eligibility carrier. Set true by BUILTIN_MARK_GLOB_ELIGIBLE
323    /// (emitted by the compiler ONLY when a scalar-assignment RHS carries an
324    /// UNQUOTED glob token — Star/Quest/Inbrack), read+cleared by the next
325    /// BUILTIN_SET_VAR. Matches C zsh's `GLOB_ASSIGN` (Src/exec.c:2554): only
326    /// a literal unquoted glob pattern in the wordcode is globbed; values from
327    /// `$param` / `$(cmd)` / quoted strings are NOT (verified against zsh).
328    /// The runtime SET_VAR value arrives untokenized (the compiler DQ-wraps to
329    /// suppress compile-time globbing), so quoting can no longer be recovered
330    /// from the value bytes — this flag carries the compile-time decision.
331    static SET_VAR_GLOB_ELIGIBLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
332}
333
334/// Register the session executor pointer (called from
335/// `install_session_executor`). See [`with_session_context`].
336pub fn register_session_executor(exec: &mut ShellExecutor) {
337    SESSION_EXECUTOR_PTR.with(|c| c.set(Some(exec as *mut ShellExecutor)));
338}
339
340/// Run `f` with the registered session executor established as
341/// `CURRENT_EXECUTOR`, so code reaching the live executor via
342/// `try_with_executor` works even when no per-command `execode` context
343/// is active yet.
344///
345/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
346/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
347/// first `execode`. Without an active context those sourced bodies
348/// `try_with_executor` → `None` → no-op, so the shell silently ignored
349/// the user's dotfiles. The scope is entered once around the startup
350/// sourcing window and dropped before the loop begins — deliberately NOT
351/// a global fallback inside `execute_script_zsh_pipeline`, which would
352/// re-enter the executor on nested command substitution and block on
353/// input.
354pub fn with_session_context<R>(f: impl FnOnce() -> R) -> R {
355    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
356    match ptr {
357        // SAFETY: set by install_session_executor to an executor that
358        // outlives the single-threaded interactive session.
359        Some(ptr) => {
360            let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
361            f()
362        }
363        None => f(),
364    }
365}
366
367/// RAII guard that sets/clears the thread-local executor pointer.
368///
369/// Idempotent: calling `enter` when a context is already active is a no-op
370/// for the entry side, and the guard's drop only clears the thread-local if
371/// *this* call was the one that set it. Nested `execute_command` invocations
372/// (e.g. from inside a builtin handler) reuse the outer pointer instead of
373/// stomping it.
374pub(crate) struct ExecutorContext {
375    we_set_it: bool,
376}
377
378impl ExecutorContext {
379    pub(crate) fn enter(executor: &mut ShellExecutor) -> Self {
380        let we_set_it = CURRENT_EXECUTOR.with(|cell| {
381            let mut slot = cell.borrow_mut();
382            if slot.is_some() {
383                false
384            } else {
385                *slot = Some(executor as *mut ShellExecutor);
386                true
387            }
388        });
389        ExecutorContext { we_set_it }
390    }
391}
392
393impl Drop for ExecutorContext {
394    fn drop(&mut self) {
395        if self.we_set_it {
396            CURRENT_EXECUTOR.with(|cell| {
397                *cell.borrow_mut() = None;
398            });
399        }
400    }
401}
402
403/// Access the current executor from a builtin handler.
404/// # Safety
405/// Only call this from within a VM execution context (after ExecutorContext::enter).
406#[inline]
407pub(crate) fn with_executor<F, R>(f: F) -> R
408where
409    F: FnOnce(&mut ShellExecutor) -> R,
410{
411    CURRENT_EXECUTOR.with(|cell| {
412        let ptr = cell
413            .borrow()
414            .expect("with_executor called outside VM context");
415        // SAFETY: The pointer is valid for the duration of VM execution,
416        // and we're single-threaded within the executor.
417        let executor = unsafe { &mut *ptr };
418        f(executor)
419    })
420}
421
422/// Non-panicking variant of [`with_executor`]: runs `f` against the
423/// current executor and returns `Some(result)`, or `None` when no
424/// executor is in scope (`CURRENT_EXECUTOR` unset — e.g. unit tests /
425/// compsys contexts with no fusevm bridge running).
426///
427/// This is the primitive the `crate::ported::exec` accessor wrappers
428/// (array/assoc/dispatch_function_call/execute_script/...) use to
429/// reach the live executor while preserving the exact "no executor →
430/// fall back to the direct param table / default value" semantics that
431/// the deleted `exec_hooks` OnceLock layer encoded via its
432/// "is-the-hook-installed?" check. `CURRENT_EXECUTOR` being set is the
433/// faithful equivalent of "the bridge installed the hooks".
434#[inline]
435pub(crate) fn try_with_executor<F, R>(f: F) -> Option<R>
436where
437    F: FnOnce(&mut ShellExecutor) -> R,
438{
439    CURRENT_EXECUTOR.with(|cell| {
440        let ptr = (*cell.borrow())?;
441        // SAFETY: same contract as with_executor — the pointer is valid
442        // for the duration of VM execution and access is single-threaded.
443        let executor = unsafe { &mut *ptr };
444        Some(f(executor))
445    })
446}
447
448/// Look up a canonical builtin by name in `BUILTINS` and dispatch
449/// via `execbuiltin` (Src/builtin.c:250). NO shadow check — calls the
450/// builtin even if a user function with the same name exists. Used by
451/// the `builtin foo` prefix opcode (which explicitly bypasses function
452/// lookup per zsh semantics) and by internal call sites where shadowing
453/// is unwanted. For zsh's normal name-resolution order (function shadows
454/// builtin), use `dispatch_builtin` instead.
455/// Shell-identifier prefix for diagnostic lines. Reads the canonical
456/// scriptname (`zsh` in `--zsh` parity mode, `zshrs` otherwise) so a
457/// single helper replaces hardcoded `"zshrs:"` literals across the
458/// file's eprintln paths.
459fn shname() -> String {
460    crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string())
461}
462
463/// c:Src/subst.c:505-507 + Src/exec.c:3378-3380 — per-command
464/// CSH_NULL_GLOB outcome check. During this command's word expansion
465/// `expand_glob` accumulated `badcshglob |= 1` per failed glob and
466/// `|= 2` per successful one (Src/glob.c:1871-1875). Exactly 1 —
467/// failures and no successes — is the csh-style error: `no match`,
468/// command skipped, status 1. Any other value (0 = no globs, 2/3 =
469/// at least one matched) is silent. Always resets the counter for
470/// the next command (C resets at prefork entry, subst.rs:1307).
471/// Returns true when the error fired; callers mirror their
472/// glob_failed handling (builtins leave ERRFLAG_ERROR set so the
473/// script aborts, externals clear it so the next sublist runs —
474/// verified against zsh 5.9.1).
475/// Restore the user's GLOB_SUBST after a `${~spec}` carrier flip
476/// (see subst::TILDE_GLOBSUBST_CARRIER). Runs at the same
477/// command-dispatch boundaries that consume glob_failed /
478/// badcshglob — by then every glob op of the current word pipeline
479/// has read the carrier.
480pub(crate) fn consume_tilde_globsubst_carrier() {
481    crate::ported::subst::TILDE_GLOBSUBST_CARRIER.with(|c| {
482        if let Some(saved) = c.take() {
483            crate::ported::options::opt_state_set("globsubst", saved);
484        }
485    });
486}
487
488/// Pop `argc` stack slots for a whole-array assignment: the LAST popped
489/// (deepest pushed) is the param name, the rest are the values in stack
490/// order, with any `Value::Array` flattened to its elements. Mirrors the
491/// Flatten an array-assignment RHS value into scalar strings, descending
492/// through nested `Value::Array`s. zsh arrays are always flat, so recursion
493/// only collapses the wrapper layers the compiler introduces — in particular
494/// the single `Value::Array` built by `Op::MakeArray` for `arr=(...)` literals
495/// (used to dodge `CallBuiltin`'s u8 argc cap), whose own elements may
496/// themselves be arrays from an unquoted `$other_array` expansion. A
497/// one-level flatten would stringify those inner arrays into a single element.
498fn flatten_array_value(v: Value, out: &mut Vec<String>) {
499    match v {
500        Value::Array(items) => {
501            for it in items {
502                flatten_array_value(it, out);
503            }
504        }
505        other => out.push(other.to_str()),
506    }
507}
508
509/// pop/flatten prologue of BUILTIN_SET_ARRAY / BUILTIN_APPEND_ARRAY.
510fn pop_array_args_with_name(vm: &mut fusevm::VM, argc: u8) -> (String, Vec<String>) {
511    let n = argc as usize;
512    let mut popped: Vec<Value> = Vec::with_capacity(n);
513    for _ in 0..n {
514        popped.push(vm.pop());
515    }
516    popped.reverse();
517    let name = popped.pop().map(|v| v.to_str()).unwrap_or_default();
518    let mut values: Vec<String> = Vec::new();
519    for v in popped {
520        flatten_array_value(v, &mut values);
521    }
522    (name, values)
523}
524
525fn consume_badcshglob() -> bool {
526    let v = crate::ported::glob::BADCSHGLOB.swap(0, std::sync::atomic::Ordering::Relaxed);
527    if v == 1 {
528        crate::ported::utils::zerr("no match"); // c:Src/subst.c:507
529        true
530    } else {
531        false
532    }
533}
534
535/// Map a builtin name to the zsh module that owns it, IFF zsh does
536/// not auto-load that builtin on first use. Used by
537/// `dispatch_builtin_raw` to gate `--zsh` mode dispatch behind
538/// `zmodload`, mirroring `zsh -fc <name>` returning 127 for these
539/// names without an explicit module load.
540///
541/// Returns `Some(module_name)` if `name` belongs to a non-auto-load
542/// module per the per-module `Src/Modules/<x>.c` `bintab[]` plus
543/// the auto-load flag set at module-build time. `None` for core
544/// builtins and for auto-loaded module builtins (sched, log, echotc,
545/// echoti, zformat, zparseopts, zregexparse, zstyle, strftime,
546/// private, vared, zle, bindkey, comp*) which work without zmodload.
547fn module_bound_builtin_module(name: &str) -> Option<&'static str> {
548    match name {
549        "zftp" => Some("zsh/zftp"),
550        "zsocket" => Some("zsh/net/socket"),
551        "ztcp" => Some("zsh/net/tcp"),
552        "zstat" => Some("zsh/stat"),
553        "zselect" => Some("zsh/zselect"),
554        "zpty" => Some("zsh/zpty"),
555        "zprof" => Some("zsh/zprof"),
556        "zsystem" | "syserror" => Some("zsh/system"),
557        "clone" => Some("zsh/clone"),
558        "zcurses" => Some("zsh/curses"),
559        "ztie" | "zuntie" | "zgdbmpath" => Some("zsh/db/gdbm"),
560        "pcre_compile" | "pcre_match" | "pcre_study" => Some("zsh/pcre"),
561        "example" => Some("zsh/example"),
562        "cap" | "getcap" | "setcap" => Some("zsh/cap"),
563        "zgetattr" | "zsetattr" | "zdelattr" | "zlistattr" => Some("zsh/attr"),
564        // c:Src/Modules/datetime.c — `strftime` is registered via
565        // partab[] when zsh/datetime loads. Verified by
566        // `zsh -fc 'strftime -s s %Y 0'` → 127 "command not found".
567        "strftime" => Some("zsh/datetime"),
568        _ => None,
569    }
570}
571
572pub(crate) fn dispatch_builtin_raw(name: &str, args: Vec<String>) -> i32 {
573    // c:Src/exec.c:2700-2717 — `private` is an autoloaded builtin in
574    // zsh (autofeature b:private of zsh/param/private): first use
575    // runs ensurefeature → require_module → load_module → boot_,
576    // marking the module MOD_INIT_B (what `zmodload -e` reads) and
577    // installing the wrap_private FuncWrap (param_private.c:712).
578    // doshfunc gates the wrapper dispatch on that load state, so
579    // this require_module is what activates private scoping. The
580    // raw dispatcher is the chokepoint every builtin route funnels
581    // through; require_module is idempotent after the first call
582    // (needs_load checks MOD_INIT_B).
583    if name == "private" {
584        if let Ok(mut tab) = crate::ported::module::MODULESTAB.lock() {
585            let _ = crate::ported::module::require_module(&mut tab, "zsh/param/private", None, 0);
586            // c:2710 ensurefeature
587        }
588    }
589    // c:Src/Modules/param_private.c:682-685 setup_ — loading
590    // zsh/param/private REPLACES the `local` builtintab node's
591    // handlerfunc + optstr with bin_private's ("Even more horrible
592    // hack"), so once the module is loaded `local` IS bin_private: it
593    // accepts the -P/-Pa private-scope flags, and without -P delegates
594    // to bin_typeset, which already treats `local` and `private`
595    // identically (is_locallike, builtin.rs:3666). Replicate the swap by
596    // routing `local` through the `private` node only after the module
597    // is loaded — before then, `local -P` still errors "bad option: -P"
598    // exactly like stock zsh. The `private` node carries the augmented
599    // optstr (with P) that the `local` node lacks.
600    if name == "local"
601        && crate::ported::module::MODULESTAB
602            .lock()
603            .map(|t| t.is_bound("zsh/param/private"))
604            .unwrap_or(false)
605    {
606        return dispatch_builtin_raw("private", args);
607    }
608    // c:Bugs #475/#504/#555 — bash-only builtins (`mapfile`,
609    // `readarray`, `compopt`) should emit "command not found" in
610    // `--zsh` mode matching zsh's external-command-lookup miss.
611    // The per-opcode closures for caller/help/complete/compgen
612    // already gate via IS_ZSH_MODE at their registration sites;
613    // names without dedicated opcodes (compopt/mapfile/readarray)
614    // route through this generic builtintab lookup and need the
615    // gate here.
616    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
617        && matches!(name, "compopt" | "mapfile" | "readarray")
618    {
619        eprintln!("zsh:1: command not found: {}", name);
620        let _ = args;
621        return 127;
622    }
623    // c:Src/exec.c:2700-2724 resolvebuiltin — autoloaded-builtin stub
624    // (registered by `zmodload -ab MOD NAME`, Src/module.c:426
625    // add_autobin) fires on first use: ensurefeature loads the owning
626    // module, then dispatch proceeds against the real builtin. Must
627    // run BEFORE the module-bound 127 gate below — `zmodload -ab
628    // zsh/zselect zselect; zselect` previously died there with
629    // `command not found` because the gate only checked is_loaded,
630    // never the autoload ledger.
631    if let Some(rc) = crate::ported::module::resolvebuiltin(name) {
632        if rc != 0 {
633            // Load failed or feature undefined — diagnostics already
634            // printed (load_module zwarn / resolvebuiltin zerr).
635            // C's execbuiltin head returns 1 (Src/builtin.c:264-267).
636            return 1;
637        }
638        // Module loaded — fall through; the is_loaded gates below now
639        // pass and the normal dispatch chain runs the real builtin.
640    }
641    // c:Src/Modules/<mod>.c boot_/setup_ chain — module-bound builtins
642    // (zftp, zsocket, ztcp, zstat, etc.) are only registered into
643    // `builtintab` when their module is loaded via `zmodload`. In
644    // zsh `-fc` (the parity test harness's invocation), the modules
645    // are NOT pre-loaded, so each name reports "command not found"
646    // with exit 127. zshrs intentionally pre-loads all module bintabs
647    // in `createbuiltintable` (builtin.rs:131-152) for the default
648    // mode so users can call these without `zmodload`; that auto-load
649    // diverges from zsh's gate behavior. Match zsh's stance only when
650    // the user explicitly asked for parity via `--zsh`.
651    //
652    // The list is the union of builtins from modules that zsh does
653    // NOT auto-load (verified via `zsh -fc <name>` returning 127):
654    //   zsh/zftp          → zftp
655    //   zsh/net/socket    → zsocket
656    //   zsh/net/tcp       → ztcp
657    //   zsh/stat          → zstat (NOT `stat`; that name resolves to
658    //                              /bin/stat on PATH per zsh's setup)
659    //   zsh/zselect       → zselect
660    //   zsh/zpty          → zpty
661    //   zsh/zprof         → zprof
662    //   zsh/system        → zsystem, syserror
663    //   zsh/clone         → clone
664    //   zsh/curses        → zcurses
665    //   zsh/db/gdbm       → ztie, zuntie, zgdbmpath
666    //   zsh/pcre          → pcre_compile, pcre_match, pcre_study
667    //   zsh/example       → example
668    //   zsh/cap           → cap, getcap, setcap
669    //   zsh/attr          → zgetattr, zsetattr, zdelattr, zlistattr
670    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
671        && module_bound_builtin_module(name)
672            .map(|m| {
673                !crate::ported::module::MODULESTAB
674                    .lock()
675                    .map(|t| t.is_loaded(m))
676                    .unwrap_or(false)
677            })
678            .unwrap_or(false)
679    {
680        eprintln!("zsh:1: command not found: {}", name);
681        let _ = args;
682        return 127;
683    }
684    // c:Src/Modules/files.c:806-824 — zsh/files registers `chmod`,
685    // `chown`, `chgrp`, `ln`, `mkdir`, `mv`, `rm`, `rmdir`, `sync`
686    // (plus their `zf_*` aliases) into builtintab on module load.
687    // Without an explicit `zmodload zsh/files`, zsh resolves the
688    // names through PATH lookup — `zsh -fc 'chmod +x f'` runs
689    // `/bin/chmod`, whose argv-parser accepts symbolic modes like
690    // `+x` that bin_chmod's octal-only parser rejects with
691    // "invalid mode `+x'". The shadow-aware wrapper at
692    // `dispatch_builtin` (line 438) already has this gate, but the
693    // direct `dispatch_builtin_raw` path used by fusevm's
694    // CallBuiltin opcode bypasses it. Mirror the gate here so the
695    // low-level dispatch matches C's PATH-fall-through behavior.
696    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
697        && module_gated_files_builtin(name)
698        && !crate::ported::module::MODULESTAB
699            .lock()
700            .map(|t| t.is_loaded("zsh/files"))
701            .unwrap_or(false)
702    {
703        // PATH lookup uses the LITERAL name: bare `mkdir` finds
704        // /bin/mkdir; a `zf_*` alias finds nothing and exits 127 —
705        // matching zsh -fc `zf_mkdir d` → "command not found:
706        // zf_mkdir" (the aliases exist ONLY in the loaded module's
707        // builtintab, Src/Modules/files.c:816-824; PATH has no
708        // /bin/zf_rm). The previous zf_-strip silently ran the
709        // system binary instead.
710        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
711        return status;
712    }
713    // c:Src/Modules/stat.c:637-638 — zsh/stat registers BOTH `stat`
714    // and `zstat`. `zstat` is in the module_bound 127-gate above (no
715    // /usr/bin/zstat exists), but the bare `stat` name must FALL
716    // THROUGH to PATH when zsh/stat isn't loaded — zsh -fc
717    // 'stat -f %Lp f' runs /usr/bin/stat, while bin_stat's parser
718    // rejects stat(1) flags ("bad option: -c"). Same fall-through
719    // shape as the zsh/files gate above.
720    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
721        && name == "stat"
722        && !crate::ported::module::MODULESTAB
723            .lock()
724            .map(|t| t.is_loaded("zsh/stat"))
725            .unwrap_or(false)
726    {
727        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
728        return status;
729    }
730    // c:Src/exec.c:3050-3068 — builtin lookup hits `builtintab` (the
731    // merged table containing module-provided builtins). The previous
732    // port walked only the core `BUILTINS` slice, so per-module
733    // entries like `log` (Src/Modules/watch.c:693 `BUILTIN("log", …,
734    // bin_log, …)`) were registered into builtintab via
735    // createbuiltintable but never reached at dispatch — `log` fell
736    // through to PATH and ran `/usr/bin/log`. Bug #72 in docs/BUGS.md.
737    let tab = crate::ported::builtin::createbuiltintable();
738    if let Some(bn_static) = tab.get(name) {
739        let bn_ptr = *bn_static as *const _ as *mut _;
740        return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
741    }
742    1
743}
744
745/// Shadow-aware dispatch matching zsh's name-resolution order:
746/// alias → reserved word → **function (shadows builtin)** → builtin →
747/// external. All `BUILTIN_X` opcode handlers route through here so a
748/// user-defined `cd () { … }` (or `r`, `fc`, `which`, … anything in
749/// fusevm's name→opcode map) takes precedence over the C builtin —
750/// matching `Src/exec.c:execcmd_exec`'s dispatch at c:3050-3068.
751/// Without this, compile-time builtin resolution silently ignored
752/// user wrappers (e.g. ZPWR's `cd () { builtin cd "$@"; … }`).
753/// True for builtins that are bound by zsh/files's boot_/setup_
754/// chain (Src/Modules/files.c:806-824). These are the bare-name
755/// `mkdir`/`rm`/`mv`/`ln`/`chmod`/`chown`/`chgrp`/`sync`/`rmdir`
756/// AND their `zf_*` aliases at c:816-824. Without explicit
757/// `zmodload zsh/files`, the names fall through to PATH lookup
758/// (zsh's `type rm` reports `/bin/rm`). Bug #28.
759fn module_gated_files_builtin(name: &str) -> bool {
760    matches!(
761        name,
762        "mkdir"
763            | "rmdir"
764            | "rm"
765            | "mv"
766            | "ln"
767            | "chmod"
768            | "chown"
769            | "chgrp"
770            | "sync"
771            | "zf_mkdir"
772            | "zf_rmdir"
773            | "zf_rm"
774            | "zf_mv"
775            | "zf_ln"
776            | "zf_chmod"
777            | "zf_chown"
778            | "zf_chgrp"
779            | "zf_sync"
780    )
781}
782
783pub(crate) fn dispatch_builtin(name: &str, args: Vec<String>) -> i32 {
784    // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close any
785    // `>(cmd)` write ends owned by this command once it finishes
786    // (drops on every return path below).
787    let _psub_fds = PsubFdGuard;
788    // c:Src/exec.c — when any redirect in the current scope failed
789    // (e.g. noclobber blocked a `>` overwrite), zsh refuses to
790    // execute the command and exits with status 1. The Rust port
791    // still applied the command (writing to the /dev/null sink
792    // installed by host_apply_redirect's noclobber arm) so the
793    // success status overwrote the intended 1. Short-circuit here
794    // for builtins (the external-exec equivalent lives in
795    // ZshrsHost::exec).
796    let redir_failed = with_executor(|exec| {
797        let f = exec.redirect_failed;
798        exec.redirect_failed = false;
799        f
800    });
801    if redir_failed {
802        // c:Src/exec.c:4367-4386 — POSIX special-builtin escalation:
803        // a failed redirect on a PSPECIAL builtin (set, readonly,
804        // typeset, ...) under POSIX_BUILTINS is FATAL in a
805        // non-interactive shell (`exit(1)` at c:4383). The `command`
806        // prefix resets this (BINF_COMMAND, c:4369) — that path
807        // dispatches through bin_command, not here.
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        {
812            use std::sync::atomic::Ordering;
813            crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
814            crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
815        }
816        return 1;
817    }
818    // c:Src/glob.c:1876-1880 NOMATCH path — when expand_glob() failed
819    // on a no-match glob, zsh aborts the simple command after zerr()
820    // printed "no matches found". In C, this works because zerr()
821    // sets ERRFLAG_ERROR (Src/utils.c) and execcmd_exec()
822    // (Src/exec.c:3050+) checks errflag before invoking the builtin
823    // table. Rust's builtin dispatch doesn't sit on the same errflag
824    // gate, so we explicitly consume the per-command glob-fail cell
825    // and short-circuit with status 1. Mirrors the external-path
826    // guard at host_exec_external (line 5167). Without this:
827    // `echo /never/*` would print empty (silently rolled back to ""
828    // by the empty glob expansion). Parity bug #13.
829    consume_tilde_globsubst_carrier();
830    let glob_failed = with_executor(|exec| {
831        let f = exec.current_command_glob_failed.get();
832        exec.current_command_glob_failed.set(false); // c:1879 cleanup
833        f
834    });
835    if glob_failed {
836        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH zerr sets
837        // ERRFLAG_ERROR (via utils.c:184). For a BUILTIN command the
838        // expansion runs IN the shell process, so errflag stays set
839        // and the rest of the input aborts (zsh -fc 'echo /nope_*;
840        // echo after' prints nothing after the error — verified
841        // against zsh 5.9). The continue-after-nomatch behaviour
842        // belongs ONLY to externals: C forks BEFORE expansion there,
843        // so the child's zerr can't touch the parent's errflag (zsh
844        // -fc 'ls /nope_*; echo after' prints `after`) — that path's
845        // clear lives in fn exec / execute_external. Leave
846        // ERRFLAG_ERROR set here; BUILTIN_ERREXIT_CHECK trigger 4
847        // aborts the remaining script at the next command boundary.
848        return 1; // c:1880 — command aborted, status 1
849    }
850    // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the NOMATCH
851    // gate above: all of this command's globs failed silently (words
852    // dropped, badcshglob accumulated 1s and no 2s) → `no match`,
853    // skip the builtin, status 1. Like the NOMATCH path, ERRFLAG
854    // from zerr stays set for builtins so the rest of the script
855    // aborts (zsh -fc 'setopt cshnullglob; print *nope* x; print
856    // after' prints only the error — verified zsh 5.9.1).
857    if consume_badcshglob() {
858        // c:Src/exec.c:3380 — `lastval = 1;` so the shell's final
859        // exit status reflects the aborted command.
860        with_executor(|exec| exec.set_last_status(1));
861        return 1;
862    }
863    // c:Src/exec.c:4162-4295 — assignment-builtin (BINF_ASSIGN family:
864    // typeset / declare / local / export / readonly / integer / float /
865    // private) whose `name=value` postassign arg raised errflag while
866    // its RHS was preforked (PREFORK_ASSIGN, c:4239-4245) — the classic
867    // case is a math error in `typeset -F fv=$((1/0))`. The postassign
868    // loop `break`s on errflag (c:4243) and then `if (!errflag)
869    // execbuiltin(...)` (c:4287) SKIPS the builtin entirely, so `lastval`
870    // is left UNCHANGED from before the command (0 fresh, 1 after
871    // `false`). This differs from a PLAIN assignment `x=$((1/0))`, which
872    // goes through execsimple c:1375 `lv = errflag ? errflag : cmdoutval`
873    // → 1, and from a NON-assign builtin `print $((1/0))`, whose main
874    // args-prefork errflag lands on c:3760 `lastval = 1`. Only the
875    // assignment-BUILTIN postassign path preserves the prior status.
876    // Mirror it here: the fusevm reg_passthru dispatch still calls us
877    // with errflag set (unlike C's pre-invoke gate), so consume that
878    // state and return the prior LASTVAL instead of running the builtin.
879    {
880        use std::sync::atomic::Ordering;
881        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
882        let ef = live & crate::ported::zsh_h::ERRFLAG_ERROR;
883        let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
884        // Only the SOFT recoverable error (math failure like `$((1/0))`,
885        // ERRFLAG_ERROR without ERRFLAG_HARD) preserves the prior status
886        // per c:4287. A HARD error (`${var?msg}`, which c:Src/subst.c
887        // OR's ERRFLAG_HARD onto errflag) is a script-abort that yields
888        // status 1 regardless of the prior status — leave that to the
889        // normal dispatch/abort path below (which returns 1 and keeps
890        // ERRFLAG_HARD set for the downstream errexit gate).
891        if ef != 0 && hard == 0 && builtin_is_assign_family(name) {
892            // c:4287 — execbuiltin skipped; lastval unchanged.
893            return crate::ported::builtin::LASTVAL.load(Ordering::Relaxed);
894        }
895    }
896    if let Some(status) = try_user_fn_override(name, &args) {
897        // c:Src/jobs.c:1748 waitonejob — canonical single-command
898        // pipestats update via the no-procs else-branch.
899        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
900        let mut synth = crate::ported::zsh_h::job::default();
901        crate::ported::jobs::waitonejob(&mut synth);
902        return status;
903    }
904    // c:Src/builtin.c:587 + Src/exec.c:3056 — a builtin disabled via
905    // `disable <name>` has its `DISABLED` flag set in `builtintab`;
906    // `builtintab->getnode` (the DISABLED-filtering accessor) returns
907    // NULL for it at lookup time, so execcmd_exec falls through to
908    // PATH lookup and runs the external. The Rust port stores the
909    // disabled set in `BUILTINS_DISABLED`; the previous dispatcher
910    // only checked the immutable `createbuiltintable` HashMap which
911    // never reflects disablement — so `disable echo; echo hi` kept
912    // running the bin_echo builtin. Bug #106 in docs/BUGS.md.
913    //
914    // dispatch_builtin (the high-level wrapper used by the BUILTIN_*
915    // opcode handlers and reg_passthru! callsites) is the correct
916    // gate: `dispatch_builtin_raw` is the low-level entry point
917    // used by `bin_builtin` itself which MUST bypass the disabled
918    // set (man zshbuiltins: `builtin name` runs the builtin
919    // regardless of disable state). Place the check here so the
920    // bypass path stays clean.
921    let disabled = crate::ported::builtin::BUILTINS_DISABLED
922        .lock()
923        .map(|s| s.contains(name))
924        .unwrap_or(false);
925    if disabled {
926        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
927        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
928        let mut synth = crate::ported::zsh_h::job::default();
929        crate::ported::jobs::waitonejob(&mut synth);
930        return status;
931    }
932    // c:Src/Modules/files.c:806-814 — `mkdir`, `rm`, `mv`, `ln`, `chmod`,
933    // `chown`, `chgrp`, `sync`, `rmdir` are bound by the `zsh/files`
934    // module's boot_/setup_ chain. Without explicit `zmodload zsh/files`,
935    // these bare names fall through to PATH (`/bin/rm`, `/usr/bin/chmod`,
936    // etc.) in zsh; `type rm` reports `rm is /bin/rm`. The `zf_*`
937    // aliases (`zf_rm`, `zf_chmod`, …) are bound by the same module
938    // and gated the same way. Bug #28 in docs/BUGS.md.
939    if module_gated_files_builtin(name) {
940        if !crate::ported::module::MODULESTAB
941            .lock()
942            .unwrap()
943            .is_loaded("zsh/files")
944        {
945            // PATH lookup uses the literal name. In --zsh parity mode
946            // `zf_rm` must 127 like zsh -fc (no /bin/zf_rm); default
947            // zshrs mode keeps the convenience zf_-strip so the alias
948            // still reaches the system binary.
949            let path_name = if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
950                name
951            } else {
952                name.strip_prefix("zf_").unwrap_or(name)
953            };
954            let status =
955                with_executor(|exec| exec.execute_external(path_name, &args, &[])).unwrap_or(127);
956            crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
957            let mut synth = crate::ported::zsh_h::job::default();
958            crate::ported::jobs::waitonejob(&mut synth);
959            return status;
960        }
961    }
962    let status = dispatch_builtin_raw(name, args);
963    // c:Src/jobs.c:1748 waitonejob — canonical single-command pipestats update.
964    crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
965    let mut synth = crate::ported::zsh_h::job::default();
966    crate::ported::jobs::waitonejob(&mut synth);
967    // c:Src/exec.c:4367-4386 — done: tail. A PSPECIAL builtin that
968    // raised errflag under POSIX_BUILTINS exits the non-interactive
969    // shell with status 1 ("hard error in POSIX" — e.g. bin_dot's
970    // zerrnam at Src/builtin.c:6133). Arm the deferred-exit pair so
971    // the next ERREXIT_CHECK unwinds; EXIT_VAL=1 matches C's
972    // hardcoded exit(1), NOT the builtin's own status (dot returns
973    // 127 but POSIX exits 1).
974    if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
975        && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
976        && builtin_is_pspecial(name)
977        && (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
978            & crate::ported::zsh_h::ERRFLAG_ERROR)
979            != 0
980    {
981        use std::sync::atomic::Ordering;
982        crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
983        crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
984    }
985    status
986}
987
988/// c:Src/zsh.h:1467 BINF_PSPECIAL — true when `name` is a POSIX
989/// special builtin per the canonical builtin table flags
990/// (Src/builtin.c:48-129: `.`, `:`, break, continue, declare, eval,
991/// exit, export, float, integer, local, readonly, return, set,
992/// shift, source, times, trap, typeset, unset).
993fn builtin_is_pspecial(name: &str) -> bool {
994    crate::ported::builtin::createbuiltintable()
995        .get(name)
996        .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_PSPECIAL) != 0)
997        .unwrap_or(false)
998}
999
1000/// c:Src/zsh.h:1486 BINF_ASSIGN — the assignment-builtin family
1001/// (typeset / declare / local / export / readonly / integer / float /
1002/// private). Their `name=value` args are handled as postassigns
1003/// (c:Src/exec.c:4162-4295), whose errflag-abort skips execbuiltin and
1004/// preserves the prior `lastval`. Read the flag straight from the
1005/// builtin table (same pattern as `builtin_is_pspecial`).
1006fn builtin_is_assign_family(name: &str) -> bool {
1007    crate::ported::builtin::createbuiltintable()
1008        .get(name)
1009        .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_ASSIGN) != 0)
1010        .unwrap_or(false)
1011}
1012
1013// The former `install_exec_hooks()` fn-pointer registry is gone. Code
1014// under `src/ported/` now reaches `ShellExecutor` operations
1015// (array/assoc storage, script eval, function dispatch, command
1016// substitution) through the `crate::ported::exec::*` accessor wrappers,
1017// which resolve the live executor via `try_with_executor`
1018// (`CURRENT_EXECUTOR`). The bridge lives in exec.rs — the sanctioned
1019// fusevm-access exception — per `feedback_no_exec_script_from_ported` /
1020// `feedback_no_shellexecutor_in_ported`.
1021
1022/// Register all zsh builtins with the VM.
1023pub(crate) fn register_builtins(vm: &mut fusevm::VM) {
1024    // src/ported/ reaches the live executor (param store, function
1025    // dispatch, nested script/cmdsubst exec) through the
1026    // `crate::ported::exec::*` accessor wrappers, which read
1027    // `CURRENT_EXECUTOR` via `try_with_executor`. No install step is
1028    // needed: the executor is in scope for the duration of any VM run
1029    // (set by `ExecutorContext::enter`), so the wrappers resolve it
1030    // directly. (Replaces the former `exec_hooks` OnceLock fn-ptr
1031    // registry, now deleted.)
1032    // Engage fusevm's tiered JIT (block + tracing) so hot, fully-eligible
1033    // numeric chunks run in native code and — with the `jit-disk-cache`
1034    // feature (on by default) — persist that native code to
1035    // `~/.cache/fusevm-jit`, letting repeated zsh invocations skip Cranelift
1036    // codegen. fusevm gates the JIT on per-chunk eligibility and warms up by
1037    // an invocation threshold, falling back to the interpreter for any chunk
1038    // it cannot compile (e.g. host-builtin/`Extended` command dispatch), so
1039    // enabling it here never changes observable behaviour — it only caches
1040    // the numeric hot path. Idempotent: re-enabling on each VM is a no-op.
1041    vm.enable_tracing_jit();
1042    // Macro for builtins that user functions are allowed to shadow.
1043    // zsh dispatch order is alias → function → builtin; without the
1044    // try_user_fn_override probe a `cat() { ... }; cat` would silently
1045    // run the C builtin and ignore the user function.
1046    macro_rules! reg_overridable {
1047        ($vm:expr, $id:expr, $name:literal, $method:ident) => {
1048            $vm.register_builtin($id, |vm, argc| {
1049                let args = pop_args(vm, argc);
1050                // c:Src/exec.c getproc + Src/jobs.c deletefilelist —
1051                // close `>(cmd)` write ends owned by this command
1052                // once it finishes (shadows bypass dispatch_builtin
1053                // and ZshrsHost::exec, so they need their own guard:
1054                // `tee >(wc -c) </dev/null` left wc blocked).
1055                let _psub_fds = PsubFdGuard;
1056                if let Some(s) = try_user_fn_override($name, &args) {
1057                    return Value::Status(s);
1058                }
1059                // c:Src/exec.c — redirect failure in the current
1060                // scope means the command must NOT run. coreutils
1061                // shadows (cat / head / tail / etc.) take a separate
1062                // dispatch path from dispatch_builtin, so they need
1063                // their own gate. Without this `cat <&3` after a
1064                // closed-fd diagnostic still ran the shadow and
1065                // overwrote $? from the forced 1.
1066                let redir_failed = with_executor(|exec| {
1067                    let f = exec.redirect_failed;
1068                    exec.redirect_failed = false;
1069                    f
1070                });
1071                if redir_failed {
1072                    return Value::Status(1);
1073                }
1074                // `[builtins].coreutils_shadows = off` in
1075                // ~/.zshrs/zshrs.toml (or `ZSHRS_NO_COREUTILS_SHADOWS=1`
1076                // env override) bypasses the in-process shadow and
1077                // fork-execs the real /bin/X. Safety valve for any
1078                // script that hits an edge-case divergence between
1079                // the zshrs shadow and system coreutils. Cached
1080                // after first call, so the hot path is one atomic
1081                // load per shadowed-builtin invocation.
1082                if !crate::daemon_presence::coreutils_shadows_enabled() {
1083                    return Value::Status(exec_system_command($name, &args));
1084                }
1085                let status = with_executor(|exec| exec.$method(&args));
1086                Value::Status(status)
1087            });
1088        };
1089    }
1090
1091    // Pure-passthru builtin: pops args, routes to canonical
1092    // `dispatch_builtin(name, args)` (which goes via execbuiltin →
1093    // BUILTINS[name] → bin_X). No pre/post bridge work. Used by
1094    // ~25 handlers that were 4-line copy-paste boilerplate.
1095    macro_rules! reg_passthru {
1096        ($vm:expr, $id:expr, $name:literal) => {
1097            $vm.register_builtin($id, |vm, argc| {
1098                Value::Status(dispatch_builtin($name, pop_args(vm, argc)))
1099            });
1100        };
1101    }
1102
1103    // Core builtins
1104    vm.register_builtin(BUILTIN_CD, |vm, argc| {
1105        let args = pop_args(vm, argc);
1106        if let Some(s) = try_user_fn_override("cd", &args) {
1107            return Value::Status(s);
1108        }
1109        let status = dispatch_builtin("cd", args);
1110        // c:Src/builtin.c:1258 — `callhookfunc("chpwd", NULL, 1, NULL)`
1111        // after cd succeeds. The canonical port at
1112        // src/ported/utils.rs:1532 handles both the `chpwd` shfunc
1113        // dispatch AND the `chpwd_functions` array walk.
1114        if status == 0 {
1115            crate::ported::utils::callhookfunc("chpwd", None, 1, std::ptr::null_mut());
1116        }
1117        Value::Status(status)
1118    });
1119
1120    vm.register_builtin(BUILTIN_PWD, |vm, argc| {
1121        let args = pop_args(vm, argc);
1122        if let Some(s) = try_user_fn_override("pwd", &args) {
1123            return Value::Status(s);
1124        }
1125        // Route through the canonical execbuiltin path so the `rLP`
1126        // optstr at BUILTINS["pwd"] is parsed into `ops`.
1127        let status = dispatch_builtin("pwd", args);
1128        Value::Status(status)
1129    });
1130
1131    vm.register_builtin(BUILTIN_ECHO, |vm, argc| {
1132        let args = pop_args(vm, argc);
1133        if let Some(s) = try_user_fn_override("echo", &args) {
1134            return Value::Status(s);
1135        }
1136        // Update `$_` to the last arg before running. C zsh sets
1137        // zunderscore in execcmd_exec for every simple command,
1138        // including builtins.
1139        crate::ported::params::set_zunderscore(&args);
1140        let status = dispatch_builtin("echo", args);
1141        Value::Status(status)
1142    });
1143
1144    vm.register_builtin(BUILTIN_PRINT, |vm, argc| {
1145        let args = pop_args(vm, argc);
1146        if let Some(s) = try_user_fn_override("print", &args) {
1147            return Value::Status(s);
1148        }
1149        crate::ported::params::set_zunderscore(&args);
1150        let status = dispatch_builtin("print", args);
1151        Value::Status(status)
1152    });
1153
1154    reg_passthru!(vm, BUILTIN_PRINTF, "printf");
1155    reg_passthru!(vm, BUILTIN_EXPORT, "export");
1156    reg_passthru!(vm, BUILTIN_UNSET, "unset");
1157    // `source` (Src/builtin.c c:116) wired to bin_dot via BUILTINS.
1158    reg_passthru!(vm, BUILTIN_SOURCE, "source");
1159    reg_passthru!(vm, BUILTIN_DOT, ".");
1160    reg_passthru!(vm, BUILTIN_LOGOUT, "logout");
1161
1162    vm.register_builtin(BUILTIN_EXIT, |vm, argc| {
1163        let args = pop_args(vm, argc);
1164        let status = dispatch_builtin("exit", args);
1165        Value::Status(status)
1166    });
1167
1168    vm.register_builtin(BUILTIN_RETURN, |vm, argc| {
1169        let args = pop_args(vm, argc);
1170        // zsh: bare `return` (no arg) returns with the status of
1171        // the most recently executed command — `false; return`
1172        // returns 1, not 0. Direct port of zsh's bin_break/RETURN.
1173        // The executor's `last_status` is stale here (synced at
1174        // statement boundaries, not after each VM op), so read
1175        // the live `vm.last_status` instead.
1176        let live_status = vm.last_status;
1177        let status = {
1178            // Sync canonical LASTVAL to the VM's view BEFORE
1179            // bin_break("return") reads it for the no-arg fallback.
1180            with_executor(|exec| exec.set_last_status(live_status));
1181            dispatch_builtin("return", args)
1182        };
1183        Value::Status(status)
1184    });
1185
1186    vm.register_builtin(BUILTIN_TRUE, |vm, argc| {
1187        let args = pop_args(vm, argc);
1188        if let Some(s) = try_user_fn_override("true", &args) {
1189            return Value::Status(s);
1190        }
1191        // c:Src/exec.c:1257 — zsh sets `zunderscore` AT THE END of
1192        // each command (the `if (!noerrs)` block runs `zsfree(prev_argv0); …;
1193        // zunderscore = …`). For no-arg `true`, $_ becomes the
1194        // command name itself. Set DIRECTLY (not via pending_underscore)
1195        // so the NEXT command's argv-expansion of `$_` reads "true",
1196        // not the stale prior value — pending_underscore is consumed
1197        // by pop_args which runs AFTER argv expansion, too late.
1198        // c:Src/exec.c:1257 — `zunderscore = …` at end-of-command.
1199        // With args, $_ = args.last(). Without args, $_ = command name.
1200        // Write DIRECTLY to the canonical zunderscore static (the
1201        // underscoregetfn at params.rs:7003 reads from there); the
1202        // paramtab "_" slot is shadowed by lookup_special_var so
1203        // set_scalar on it has no effect on `$_` reads.
1204        if args.is_empty() {
1205            crate::ported::params::set_zunderscore(&["true".to_string()]);
1206        } else {
1207            crate::ported::params::set_zunderscore(&args);
1208        }
1209        // Route through canonical execbuiltin so PS4 xtrace fires
1210        // via the c:442 printprompt4 path.
1211        Value::Status(dispatch_builtin("true", args))
1212    });
1213    vm.register_builtin(BUILTIN_FALSE, |vm, argc| {
1214        let args = pop_args(vm, argc);
1215        if let Some(s) = try_user_fn_override("false", &args) {
1216            return Value::Status(s);
1217        }
1218        // Direct set; see BUILTIN_TRUE above for rationale.
1219        if args.is_empty() {
1220            crate::ported::params::set_zunderscore(&["false".to_string()]);
1221        } else {
1222            crate::ported::params::set_zunderscore(&args);
1223        }
1224        // Route through canonical execbuiltin — see BUILTIN_TRUE
1225        // above for the same rationale (xtrace + fast-path removal).
1226        let status = dispatch_builtin("false", args);
1227        Value::Status(status)
1228    });
1229    vm.register_builtin(BUILTIN_COLON, |vm, argc| {
1230        let args = pop_args(vm, argc);
1231        // Direct set; see BUILTIN_TRUE above for rationale.
1232        if args.is_empty() {
1233            crate::ported::params::set_zunderscore(&[":".to_string()]);
1234        } else {
1235            crate::ported::params::set_zunderscore(&args);
1236        }
1237        let status = dispatch_builtin(":", args);
1238        Value::Status(status)
1239    });
1240
1241    vm.register_builtin(BUILTIN_TEST, |vm, argc| {
1242        let args = pop_args(vm, argc);
1243        // Distinguish `[ … ]` from `test …` by sniffing the trailing
1244        // `]` — `[` requires it (c:Src/builtin.c:7241), `test` rejects
1245        // it. The compile path emits BUILTIN_TEST for both, so the
1246        // dispatch name carries the `[` vs `test` semantic for
1247        // execbuiltin's funcid (BIN_BRACKET=21 vs BIN_TEST=20). Without
1248        // this, bin_test's `if func == BIN_BRACKET` arm (which pops
1249        // the trailing `]`) never fired for `[` calls, so the `]`
1250        // leaked into evalcond as a positional and silently changed
1251        // the result. Bug surfaced via test_test_dashdash_unknown_condition.
1252        let name = if args.last().map(|s| s.as_str()) == Some("]") {
1253            "["
1254        } else {
1255            "test"
1256        };
1257        let status = dispatch_builtin(name, args);
1258        Value::Status(status)
1259    });
1260
1261    // Variable declaration. `local` (Src/builtin.c bin_local) handles
1262    // the scope chain (`pm->old = oldpm` at Src/params.c:1137 inside
1263    // createparam, `pm->level = locallevel` at Src/builtin.c:2576).
1264    // `typeset` / `declare` are aliases — fusevm maps both to
1265    // BUILTIN_TYPESET; compile_zsh special-cases `declare` to keep
1266    // the `declare:` error prefix.
1267    reg_passthru!(vm, BUILTIN_LOCAL, "local");
1268    reg_passthru!(vm, BUILTIN_TYPESET, "typeset");
1269
1270    reg_passthru!(vm, BUILTIN_DECLARE, "declare");
1271    reg_passthru!(vm, BUILTIN_READONLY, "readonly");
1272    reg_passthru!(vm, BUILTIN_INTEGER, "integer");
1273    reg_passthru!(vm, BUILTIN_FLOAT, "float");
1274    reg_passthru!(vm, BUILTIN_READ, "read");
1275    // c:Bug #504 — fusevm reserves BUILTIN_MAPFILE for the bash
1276    // mapfile/readarray builtins. Neither exists in zsh; in --zsh
1277    // parity mode the dispatch must emit "command not found" + rc=127
1278    // matching zsh's external-command-lookup miss. The previous wiring
1279    // left BUILTIN_MAPFILE unregistered, so fusevm's VM treated the op
1280    // as a no-op rc=0 — `mapfile` (and `readarray`) silently succeeded
1281    // in --zsh mode. The host gate in `dispatch_builtin_raw` never
1282    // fired because the compile path emitted `Op::CallBuiltin(31, ..)`
1283    // directly. Register the slot so the gate runs (or a future
1284    // non-zsh mode can wire in a real impl).
1285    vm.register_builtin(fusevm::shell_builtins::BUILTIN_MAPFILE, |vm, argc| {
1286        let args = pop_args(vm, argc);
1287        // The fusevm name→id map collapses both `mapfile` and
1288        // `readarray` to the same opcode; pick the right diagnostic
1289        // by sniffing the user's actual invocation. The xtrace ARGS
1290        // push earlier records the cmd-prefix as the bottom of the
1291        // popped argv, but `args` here excludes the prefix — so we
1292        // can't recover the user-typed name from the stack. Default
1293        // to `mapfile` (the more-common spelling); both produce
1294        // identical diagnostics in any case.
1295        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1296            eprintln!("zsh:1: command not found: mapfile");
1297            let _ = args;
1298            return Value::Status(127);
1299        }
1300        // Non-zsh modes (bash drop-in) get a passthru to the canonical
1301        // dispatcher in case a future port adds a real mapfile builtin
1302        // — until then the rc=1 unknown-command default applies.
1303        Value::Status(dispatch_builtin("mapfile", args))
1304    });
1305    reg_passthru!(vm, BUILTIN_BREAK, "break");
1306    reg_passthru!(vm, BUILTIN_CONTINUE, "continue");
1307    reg_passthru!(vm, BUILTIN_SHIFT, "shift");
1308
1309    vm.register_builtin(BUILTIN_EVAL, |vm, argc| {
1310        // Direct port of `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))` body from Src/builtin.c:6151:
1311        //   `if (!*argv) return 0;`
1312        //   `prog = parse_string(zjoin(argv, ' ', 1), 1);`
1313        //   `execode(prog, 1, 0, "eval");`
1314        // The execode invocation lives here (not in the canonical
1315        // free-fn) because it must run through the bytecode VM's
1316        // current executor — the same VM that's mid-dispatch.
1317        let mut args = pop_args(vm, argc);
1318        // c:Src/builtin.c:407-411 — generic `--` end-of-options
1319        // strip applied by `execbuiltin` for builtins that have
1320        // NULL optstr AND no BINF_HANDLES_OPTS. `eval` qualifies
1321        // (Src/builtin.c:65 `BUILTIN("eval", BINF_PSPECIAL, ...,
1322        // NULL, NULL)`). The BUILTIN_EVAL fast-path bypasses
1323        // execbuiltin, so we mirror the strip inline. Bug #319.
1324        if args.first().is_some_and(|s| s == "--") {
1325            args.remove(0);
1326        }
1327        if args.is_empty() {
1328            return Value::Status(0); // c:6160
1329        }
1330        let src = args.join(" "); // c:6166
1331                                  // c:Src/builtin.c:6164-6165 — `if (!ineval) scriptname =
1332                                  // "(eval)";`. Diagnostics emitted while the eval body runs
1333                                  // (command-not-found, parse errors, etc.) use scriptname as
1334                                  // the source-context prefix. Without setting it here the
1335                                  // BUILTIN_EVAL fast-path leaked the outer "zsh" prefix
1336                                  // through, breaking the `(eval):N:` convention zsh uses
1337                                  // for in-eval errors. Bug #420.
1338        let oscriptname = crate::ported::utils::scriptname_get();
1339        crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
1340        let mut status = with_executor(|exec| {
1341            // c:6175 execode
1342            exec.execute_script(&src).unwrap_or(1)
1343        });
1344        // c:Src/builtin.c:6211-6212 — `if (errflag && !lastval)
1345        //   lastval = errflag;`
1346        // c:Src/builtin.c:6221 — `errflag &= ~ERRFLAG_ERROR;`
1347        // eval is a CONTAINMENT boundary: an error inside the eval
1348        // body (readonly reassign, bad assoc set, ${unset?msg}, …)
1349        // breaks the eval body's lists via errflag, then eval clears
1350        // the flag and returns lastval, and the CALLER's next list
1351        // runs. zsh 5.9: `eval 'assoc=(odd)'; echo "after $?"`
1352        // prints `after 1` in -c, script, and stdin contexts.
1353        {
1354            use std::sync::atomic::Ordering;
1355            let ef = crate::ported::utils::errflag.load(Ordering::Relaxed)
1356                & crate::ported::zsh_h::ERRFLAG_ERROR;
1357            if ef != 0 && status == 0 {
1358                status = ef; // c:6212 lastval = errflag
1359            }
1360            crate::ported::utils::errflag
1361                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
1362        }
1363        crate::ported::utils::set_scriptname(oscriptname);
1364        Value::Status(status)
1365    });
1366
1367    // `builtin foo args…`: precmd-modifier that forces builtin dispatch,
1368    // bypassing alias AND function lookup. Without this, `builtin cd /`
1369    // inside a user `cd () { … }` wrapper recurses (real-world ZPWR pattern).
1370    // Handler pops argc args from the stack, treats args[0] as the builtin
1371    // name, and dispatches the rest via `dispatch_builtin` → `execbuiltin`
1372    // → `bin_*` directly. No function/alias lookup happens.
1373    vm.register_builtin(BUILTIN_BUILTIN, |vm, argc| {
1374        let args = pop_args(vm, argc);
1375        let Some((name, rest)) = args.split_first() else {
1376            // `builtin` with no args → list builtins (zsh emits nothing,
1377            // exit 0). Match that behavior; the BIN_BUILTIN bin_* in C
1378            // does the same default-list-nothing.
1379            return Value::Status(0);
1380        };
1381        // c:Src/exec.c:3435-3436 — `builtin NAME` with NAME not in
1382        // builtintab emits `zwarn("no such builtin: %s", cmdarg)`
1383        // and returns 1. zshrs's dispatch_builtin_raw bare-returned 1
1384        // silently. Probe the table here so the diagnostic fires
1385        // before dispatch.
1386        let tab = crate::ported::builtin::createbuiltintable();
1387        if !tab.contains_key(name.as_str()) {
1388            eprintln!("zshrs:1: no such builtin: {}", name);
1389            return Value::Status(1);
1390        }
1391        // `builtin foo` MUST bypass function shadow — that's the whole
1392        // point of the prefix. Use the _raw helper, not the shadow-aware
1393        // one. Without this, `cd () { builtin cd "$@"; }` recurses.
1394        Value::Status(dispatch_builtin_raw(name, rest.to_vec()))
1395    });
1396
1397    // `command foo args…` — BINF_COMMAND prefix (Src/builtin.c:44). Zsh
1398    // semantic: bypass alias+function lookup, search builtin then $PATH.
1399    // Without this, `cd () { command cd "$@" }` would re-invoke the user
1400    // wrapper (same root cause as the `builtin` bug). Flags `-p`/`-v`/`-V`
1401    // route to bin_whence with BIN_COMMAND funcid; bare `command foo`
1402    // dispatches builtin if present, else external (no fork — direct
1403    // spawn via execute_external since zshrs is non-forking).
1404    // BUILTIN_COMMAND — `command [-p] [-v|-V] cmd args…` BIN_PREFIX
1405    // (Src/builtin.c:45). PURE PASSTHRU: prepend "command" and hand
1406    // to `exec::execcmd_compile_head` (the fusevm-bytecode-time head
1407    // resolver mirroring `Src/exec.c::execcmd_exec` precommand-modifier
1408    // walk at c:3104-3187). That helper already does the -p / -v / -V
1409    // option parsing, surfaces `has_command_vv` for the whence
1410    // redirect, and reports the dispatch shape (is_builtin vs external).
1411    vm.register_builtin(BUILTIN_COMMAND, |vm, argc| {
1412        let args = pop_args(vm, argc);
1413        let mut full = Vec::with_capacity(args.len() + 1);
1414        full.push("command".to_string());
1415        full.extend(args.clone());
1416        let dispatch =
1417            crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
1418        let post = &full[dispatch.precmd_skip..];
1419        // c:Src/builtin.c:4500 — `command -p` resets PATH for the
1420        // exec to the POSIX-defined default (`getconf PATH`), so
1421        // standard utilities resolve even when the caller has
1422        // emptied $PATH. zsh restores the original PATH after the
1423        // command returns. Mirror via a scoped env::set_var.
1424        //
1425        // command's OWN options end at the first non-flag arg —
1426        // everything after the command name belongs to IT. The
1427        // previous `.any()` scan over ALL args stole `-p` from
1428        // `command mkdir -p DIR` (zconvey.plugin.zsh:44), stripping
1429        // the flag before /bin/mkdir ran → "File exists" errors on
1430        // every re-source.
1431        let mut lead = 0usize;
1432        let mut dash_p = false;
1433        let mut kept_flags: Vec<String> = Vec::new();
1434        for a in post.iter() {
1435            let s = a.as_str();
1436            if s == "--" {
1437                lead += 1;
1438                break;
1439            }
1440            if s.starts_with('-')
1441                && s.len() >= 2
1442                && s[1..].chars().all(|c| c == 'p' || c == 'v' || c == 'V')
1443            {
1444                if s.contains('p') {
1445                    dash_p = true;
1446                }
1447                // -v / -V drive the whence-style lookup downstream —
1448                // keep them in post (only the PATH-reset `p` is
1449                // consumed here).
1450                let rest: String = s[1..].chars().filter(|c| *c != 'p').collect();
1451                if !rest.is_empty() {
1452                    kept_flags.push(format!("-{}", rest));
1453                }
1454                lead += 1;
1455                continue;
1456            }
1457            break;
1458        }
1459        let mut post: Vec<String> = {
1460            let mut v = kept_flags;
1461            v.extend(post[lead..].iter().cloned());
1462            v
1463        };
1464        // c:Src/exec.c:3176-3177 — `BINF_COMMAND` arm strips a single
1465        // leading `--` end-of-options marker.
1466        // `execcmd_compile_head` (src/ported/exec.rs:1042) performs
1467        // this removal on its LOCAL `preargs` Vec but doesn't surface
1468        // the modified args; the caller still sees `--` in `full` and
1469        // tried to dispatch it as the command name. Bug #251. Mirror
1470        // the C strip here so `command -- echo hi` and
1471        // `command -p -- echo hi` route correctly.
1472        if let Some(first) = post.first() {
1473            if first == "--" {
1474                post.remove(0);
1475            }
1476        }
1477        let post = post.as_slice();
1478        let _path_guard = if dash_p {
1479            let saved = env::var("PATH").ok();
1480            let default_path = std::process::Command::new("getconf")
1481                .arg("PATH")
1482                .output()
1483                .ok()
1484                .and_then(|o| String::from_utf8(o.stdout).ok())
1485                .map(|s| s.trim().to_string())
1486                .filter(|s| !s.is_empty())
1487                .unwrap_or_else(|| "/usr/bin:/bin:/usr/sbin:/sbin".to_string());
1488            env::set_var("PATH", &default_path);
1489            crate::ported::params::setsparam("PATH", &default_path);
1490            Some(saved)
1491        } else {
1492            None
1493        };
1494        struct PathGuard {
1495            saved: Option<String>,
1496            active: bool,
1497        }
1498        impl Drop for PathGuard {
1499            fn drop(&mut self) {
1500                if !self.active {
1501                    return;
1502                }
1503                match self.saved.take() {
1504                    Some(p) => {
1505                        env::set_var("PATH", &p);
1506                        crate::ported::params::setsparam("PATH", &p);
1507                    }
1508                    None => {
1509                        env::remove_var("PATH");
1510                        crate::ported::params::setsparam("PATH", "");
1511                    }
1512                }
1513            }
1514        }
1515        let _restore = PathGuard {
1516            saved: _path_guard.unwrap_or(None),
1517            active: dash_p,
1518        };
1519        if dispatch.has_command_vv {
1520            // `-v` / `-V` → bin_whence with BIN_COMMAND funcid.
1521            let mut ops = options {
1522                ind: [0u8; MAX_OPS],
1523                args: Vec::new(),
1524                argscount: 0,
1525                argsalloc: 0,
1526            };
1527            let mut name_pos = 0usize;
1528            let mut flag_byte = b'v';
1529            for (i, a) in post.iter().enumerate() {
1530                if a.starts_with('-') && a.len() >= 2 {
1531                    let body = &a.as_bytes()[1..];
1532                    if body.contains(&b'V') {
1533                        flag_byte = b'V';
1534                    }
1535                    name_pos = i + 1;
1536                } else {
1537                    name_pos = i;
1538                    break;
1539                }
1540            }
1541            ops.ind[flag_byte as usize] = 1;
1542            let whence_args: Vec<String> = post[name_pos..].to_vec();
1543            return Value::Status(crate::ported::builtin::bin_whence(
1544                "command",
1545                &whence_args,
1546                &ops,
1547                crate::ported::hashtable_h::BIN_COMMAND,
1548            ));
1549        }
1550        if dispatch.is_empty_command {
1551            return Value::Status(0);
1552        }
1553        let Some((name, rest)) = post.split_first() else {
1554            return Value::Status(0);
1555        };
1556        // c:Src/exec.c:3275-3278 — `execcmd_compile_head` cleared
1557        // hn for the BINF_COMMAND + !POSIXBUILTINS case, surfacing
1558        // is_builtin=false. Run as external. Under POSIXBUILTINS
1559        // dispatch.is_builtin would be true; honour it.
1560        let n = name.clone();
1561        let r = rest.to_vec();
1562        if dispatch.is_builtin
1563            && crate::ported::builtin::BUILTINS
1564                .iter()
1565                .any(|b| b.node.nam == n.as_str())
1566        {
1567            return Value::Status(dispatch_builtin_raw(&n, r));
1568        }
1569        Value::Status(with_executor(|exec| exec.execute_external(&n, &r, &[])).unwrap_or(127))
1570    });
1571
1572    // `exec cmd args…` — BINF_EXEC prefix (Src/builtin.c:45). Zsh
1573    // semantic: replace the current shell process with `cmd`. On Unix
1574    // this is `execvp(2)`; the call only returns on error. zshrs is
1575    // non-forking, so the shell process IS the calling process —
1576    // execvp here directly replaces it. Options `-a name` (override
1577    // argv[0]), `-c` (clean env), `-l` (login shell — prepend `-`)
1578    // ported minimally; advanced redirect-only `exec >file` is handled
1579    // upstream by compile_zsh and never reaches this handler.
1580    vm.register_builtin(BUILTIN_EXEC, |vm, argc| {
1581        let mut args = pop_args(vm, argc);
1582        let mut argv0_override: Option<String> = None;
1583        let mut clean_env = false;
1584        let mut login = false;
1585        let mut i = 0;
1586        // c:Src/builtin.c:1075-1080 — track if any flag was consumed.
1587        // `exec -c`, `exec -l`, `exec -a NAME` without a following
1588        // command emit "exec requires a command to execute" rc=1.
1589        // Bare `exec` (no args at all) is the silent-redirect-apply
1590        // form per POSIX.
1591        let mut saw_flag = false;
1592        while i < args.len() {
1593            let a = &args[i];
1594            if a == "--" {
1595                args.remove(i);
1596                break;
1597            }
1598            // c:Src/builtin.c:42 `BIN_PREFIX("-", BINF_DASH)`. A bare
1599            // `-` is its own BINF_PREFIX builtin (BINF_DASH flag —
1600            // "login shell, prepend `-` to argv[0]"). In the canonical
1601            // precmd-walk at Src/exec.c:3056-3091 a bare `-` after
1602            // `exec` is recognized AS a builtin and stripped from
1603            // preargs (precmd_skip++), accumulating BINF_DASH into
1604            // cflags. The fast-path here bypasses execcmd_compile_head,
1605            // so we mirror the strip locally: bare `-` → set login,
1606            // remove, continue. Without this `exec -` (with no command
1607            // following) tried to exec `-` as a literal command and
1608            // exited the shell. Bug #252.
1609            if a == "-" {
1610                saw_flag = true;
1611                login = true;
1612                args.remove(i);
1613                continue;
1614            }
1615            if !a.starts_with('-') || a.len() < 2 {
1616                break;
1617            }
1618            match a.as_str() {
1619                "-a" => {
1620                    saw_flag = true;
1621                    args.remove(i);
1622                    if i < args.len() {
1623                        argv0_override = Some(args.remove(i));
1624                    }
1625                }
1626                "-c" => {
1627                    saw_flag = true;
1628                    clean_env = true;
1629                    args.remove(i);
1630                }
1631                "-l" => {
1632                    saw_flag = true;
1633                    login = true;
1634                    args.remove(i);
1635                }
1636                _ => {
1637                    // c:Src/exec.c:3196-3208 — when an unrecognized
1638                    // `-X`-style arg has NO following arg, the lexer's
1639                    // IS_DASH walk hits the "no next node" branch at
1640                    // c:3199 before the unknown-flag-letter switch at
1641                    // c:3249, so the canonical message is "exec
1642                    // requires a command to execute" rc=1 (verified vs
1643                    // `/opt/homebrew/bin/zsh -fc 'exec --bad'`).
1644                    // Consume the lone flag so the post-loop check
1645                    // fires. When a following arg exists, leave the
1646                    // unknown-flag arg in place — that arg becomes
1647                    // the command name and execution proceeds.
1648                    if args.len() == 1 {
1649                        saw_flag = true;
1650                        args.remove(i);
1651                        continue;
1652                    }
1653                    break;
1654                }
1655            }
1656        }
1657        let Some(cmd) = args.first().cloned() else {
1658            if saw_flag {
1659                // c:Src/builtin.c:1078-1080 — flags consumed but no
1660                // command follows → "exec requires a command to
1661                // execute" rc=1.
1662                eprintln!("zshrs:1: exec requires a command to execute");
1663                return Value::Status(1);
1664            }
1665            // `exec` with no command + no redirects = no-op success.
1666            return Value::Status(0);
1667        };
1668        let rest: Vec<String> = args[1..].to_vec();
1669        let display_argv0 = match argv0_override {
1670            Some(a) => a,
1671            None => {
1672                if login {
1673                    format!("-{}", cmd)
1674                } else {
1675                    cmd.clone()
1676                }
1677            }
1678        };
1679        // c:Src/exec.c::execcmd — `exec funcname` runs the function
1680        // in-process as the shell's last act, then exits with the
1681        // function's status. zsh's dispatcher falls through from the
1682        // BINF_EXEC prefix into the normal Builtin/External/Function
1683        // resolution and only execvp's if the target ISN'T a
1684        // function. Bug #101 in docs/BUGS.md: zshrs's exec went
1685        // straight to execvp and errored `not found` for shell
1686        // functions.
1687        //
1688        // For both subshell and top-level contexts: dispatch through
1689        // the function/builtin lookup first; only fall through to
1690        // execvp/spawn if the name isn't shell-resolvable.
1691        let has_user_fn = with_executor(|exec| exec.functions_compiled.contains_key(&cmd));
1692        if has_user_fn {
1693            let status =
1694                with_executor(|exec| exec.dispatch_function_call(&cmd, &rest).unwrap_or(127));
1695            // Top-level `exec funcname` — exit the shell with the
1696            // function's status (mirrors C's "exec replaces shell as
1697            // last act"). Subshell `(exec funcname)` — return through
1698            // the EXIT_PENDING path so the subshell body aborts and
1699            // the parent resumes via subshell_end.
1700            let in_subshell_now = with_executor(|exec| !exec.subshell_snapshots.is_empty());
1701            if in_subshell_now {
1702                crate::ported::builtin::EXIT_VAL
1703                    .store(status, std::sync::atomic::Ordering::Relaxed);
1704                crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
1705                return Value::Status(status);
1706            }
1707            std::process::exit(status);
1708        }
1709        // c:Src/exec.c — builtin path: `exec builtin` runs the
1710        // builtin in-process and exits.
1711        let bn_in_tab = crate::ported::builtin::createbuiltintable().contains_key(&cmd);
1712        if bn_in_tab {
1713            let status = dispatch_builtin_raw(&cmd, rest.clone());
1714            let in_subshell_now = with_executor(|exec| !exec.subshell_snapshots.is_empty());
1715            if in_subshell_now {
1716                crate::ported::builtin::EXIT_VAL
1717                    .store(status, std::sync::atomic::Ordering::Relaxed);
1718                crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
1719                return Value::Status(status);
1720            }
1721            std::process::exit(status);
1722        }
1723        // c:Src/exec.c — `exec` inside a subshell (`(exec cmd)`)
1724        // replaces ONLY the subshell child process; the parent shell
1725        // continues. C zsh always forks for `(...)`, so the actual
1726        // execvp lands in the forked child. zshrs runs subshells via
1727        // a snapshot/restore pattern in the SAME process — calling
1728        // execvp here would replace the parent too. Bug #94 in
1729        // docs/BUGS.md.
1730        //
1731        // Detect subshell context via the non-empty
1732        // `subshell_snapshots` stack. When in a subshell: spawn the
1733        // command as a child, wait for it, then signal the subshell
1734        // body to abort (return Status(N) and the caller's
1735        // subshell_end will pop the snapshot and resume the parent).
1736        let in_subshell = with_executor(|exec| !exec.subshell_snapshots.is_empty());
1737        if in_subshell {
1738            let mut command = std::process::Command::new(&cmd);
1739            command.arg0(&display_argv0);
1740            command.args(&rest);
1741            if clean_env {
1742                command.env_clear();
1743            }
1744            let status = match command.spawn() {
1745                Ok(mut child) => match child.wait() {
1746                    Ok(s) => s.code().unwrap_or(127),
1747                    Err(_) => 127,
1748                },
1749                Err(e) => {
1750                    // c:Src/exec.c:797 — `zerr("%e: %s", lerrno, arg0)`
1751                    //                     when arg0 contains `/`.
1752                    // c:872-876 — when arg0 has no `/` (PATH search
1753                    //              path), C tracks the "good" errno
1754                    //              via `isgooderr`; if all PATH entries
1755                    //              were ENOENT-not-good, eno stays 0
1756                    //              and C emits `command not found: %s`
1757                    //              instead of strerror.
1758                    // %e expands to strerror(errno) with the first
1759                    // letter lowercased (unless errno == EIO; see
1760                    // Src/utils.c:362-368). `zerr` prepends the
1761                    // scriptname:lineno: prefix — matching zsh's
1762                    // canonical `zsh:N: <errmsg>: <cmd>` pattern.
1763                    // Previously emitted `zshrs: exec: {}: not found`
1764                    // (wrong prefix, hardcoded message, missing
1765                    // lineno). Bug #140 in docs/BUGS.md.
1766                    let errno = e.raw_os_error().unwrap_or(libc::ENOENT);
1767                    let has_slash = cmd.contains('/');
1768                    if !has_slash && errno == libc::ENOENT {
1769                        // c:876 — PATH search exhausted with no good
1770                        // errno → `command not found: arg0`.
1771                        crate::ported::utils::zerr(&format!("command not found: {}", cmd));
1772                    } else {
1773                        let mut errmsg = crate::ported::compat::strerror(errno);
1774                        if errno != libc::EIO {
1775                            if let Some(c) = errmsg.chars().next() {
1776                                errmsg = format!(
1777                                    "{}{}",
1778                                    c.to_ascii_lowercase(),
1779                                    &errmsg[c.len_utf8()..]
1780                                );
1781                            }
1782                        }
1783                        crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
1784                    }
1785                    // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
1786                    if errno == libc::EACCES || errno == libc::ENOEXEC {
1787                        126
1788                    } else {
1789                        127
1790                    }
1791                }
1792            };
1793            // Mark the subshell as exec-replaced so subsequent body
1794            // commands skip — mirrors the post-execvp "child process
1795            // is gone" reality in C. EXIT_PENDING + EXIT_VAL drive
1796            // the next ERREXIT_CHECK to unwind to the subshell-end
1797            // patch.
1798            crate::ported::builtin::EXIT_VAL.store(status, std::sync::atomic::Ordering::Relaxed);
1799            crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
1800            return Value::Status(status);
1801        }
1802        let mut command = std::process::Command::new(&cmd);
1803        command.arg0(&display_argv0);
1804        command.args(&rest);
1805        if clean_env {
1806            command.env_clear();
1807        }
1808        use std::os::unix::process::CommandExt;
1809        // `exec` returns the OS error iff exec(2) failed; on success
1810        // it never returns. Match zsh: print the error to stderr with
1811        // the `exec` prefix and exit 127 (cmd not found) or 126 (not
1812        // executable).
1813        let err = command.exec();
1814        // c:Src/exec.c:797 / c:872-876 — same format as in-subshell
1815        // branch. arg0-has-/ → `<strerror>: <cmd>`; arg0-no-/ +
1816        // ENOENT → `command not found: <cmd>`. Lowercase strerror
1817        // first letter unless EIO. Bug #140 in docs/BUGS.md.
1818        let errno = err.raw_os_error().unwrap_or(libc::ENOENT);
1819        let has_slash = cmd.contains('/');
1820        if !has_slash && errno == libc::ENOENT {
1821            crate::ported::utils::zerr(&format!("command not found: {}", cmd));
1822        } else {
1823            let mut errmsg = crate::ported::compat::strerror(errno);
1824            if errno != libc::EIO {
1825                if let Some(c) = errmsg.chars().next() {
1826                    errmsg = format!("{}{}", c.to_ascii_lowercase(), &errmsg[c.len_utf8()..]);
1827                }
1828            }
1829            crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
1830        }
1831        // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
1832        let code = if errno == libc::EACCES || errno == libc::ENOEXEC {
1833            126
1834        } else {
1835            127
1836        };
1837        std::process::exit(code);
1838    });
1839
1840    reg_passthru!(vm, BUILTIN_LET, "let");
1841
1842    // Job control
1843    reg_passthru!(vm, BUILTIN_JOBS, "jobs");
1844    reg_passthru!(vm, BUILTIN_FG, "fg");
1845    reg_passthru!(vm, BUILTIN_BG, "bg");
1846    reg_passthru!(vm, BUILTIN_KILL, "kill");
1847    reg_passthru!(vm, BUILTIN_DISOWN, "disown");
1848    reg_passthru!(vm, BUILTIN_WAIT, "wait");
1849    reg_passthru!(vm, BUILTIN_SUSPEND, "suspend");
1850
1851    // History — `fc` / `history` / `r` all route to `bin_fc` (zsh
1852    // registers them as aliases of the same builtin per Src/builtin.c).
1853    reg_passthru!(vm, BUILTIN_FC, "fc");
1854    reg_passthru!(vm, BUILTIN_HISTORY, "history");
1855    reg_passthru!(vm, BUILTIN_R, "r");
1856
1857    // Aliases — alias is `BINF_MAGICEQUALS` per Src/builtin.c:50.
1858    // c:Src/exec.c:3298-3304 — when a builtin has BINF_MAGICEQUALS,
1859    // execcmd_exec sets esprefork = PREFORK_TYPESET and calls
1860    // `prefork(args, esprefork, NULL)` on the argv. prefork (subst.c:
1861    // 100) drives `filesub` on each word (c:133), which (c:677-686)
1862    // looks for the assignment Equals and runs `filesubstr` on the
1863    // VALUE side. That's how `alias bad===` triggers equalsubstr's
1864    // "= not found" via the inner Equals after the first `=`.
1865    //
1866    // The fusevm dispatch path doesn't go through execcmd_exec, so
1867    // BUILTIN_ALIAS previously passed args straight to bin_alias with
1868    // no expansion — `alias x=~/foo` stored literal `~/foo` (no tilde
1869    // expand), `alias bad===` stored a broken entry without firing
1870    // the "= not found" diagnostic. The prefork(PREFORK_TYPESET) runs
1871    // per arg word via BUILTIN_MAGIC_EQUALS_PREFORK ops that
1872    // compile_simple emits BEFORE the redirect scope opens (matching
1873    // c:3304 prefork-before-addfd order), so the dispatch here is a
1874    // plain passthrough — re-running prefork would double-fire the
1875    // "= not found" diagnostic.
1876    reg_passthru!(vm, BUILTIN_ALIAS, "alias");
1877    // c:Src/exec.c:3298-3304 — per-word magic-equals prefork; see the
1878    // const doc at BUILTIN_MAGIC_EQUALS_PREFORK. prefork's filesub
1879    // trigger (subst.c:678 `strchr(*namptr+1, Equals)`) looks for the
1880    // EQUALS TOKEN, not literal `=`. The fusevm path delivers args
1881    // already-untokenized, so re-tokenize each element via
1882    // `shtokenize` (the same call C's lexer makes implicitly when
1883    // assembling the word) so prefork sees Equals tokens at `=`
1884    // boundaries and Tilde tokens at `~` starts. After prefork
1885    // expands, untokenize for storage.
1886    vm.register_builtin(BUILTIN_MAGIC_EQUALS_PREFORK, |vm, _argc| {
1887        let raw = vm.pop();
1888        let inputs: Vec<String> = match raw {
1889            Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
1890            other => vec![other.to_str()],
1891        };
1892        let mut as_linklist: crate::ported::linklist::LinkList<String> = Default::default();
1893        for s in &inputs {
1894            let mut tokd = s.clone();
1895            crate::ported::glob::shtokenize(&mut tokd);
1896            as_linklist.push_back(tokd);
1897        }
1898        let mut rf = 0i32;
1899        crate::ported::subst::prefork(
1900            &mut as_linklist,
1901            crate::ported::zsh_h::PREFORK_TYPESET,
1902            &mut rf,
1903        );
1904        let mut expanded: Vec<String> = Vec::with_capacity(inputs.len());
1905        while let Some(s) = as_linklist.pop_front() {
1906            expanded.push(crate::ported::lex::untokenize(&s).to_string());
1907        }
1908        if expanded.len() == 1 {
1909            Value::str(expanded.into_iter().next().unwrap())
1910        } else {
1911            Value::Array(expanded.into_iter().map(Value::str).collect())
1912        }
1913    });
1914
1915    // Options. `setopt` (BIN_SETOPT=0) / `unsetopt` (BIN_UNSETOPT=1)
1916    // share bin_setopt (options.c:580) — funcid bit discriminates
1917    // the polarity via BUILTINS table entries.
1918    reg_passthru!(vm, BUILTIN_SET, "set");
1919    reg_passthru!(vm, BUILTIN_SETOPT, "setopt");
1920    reg_passthru!(vm, BUILTIN_UNSETOPT, "unsetopt");
1921
1922    vm.register_builtin(BUILTIN_SHOPT, |vm, argc| {
1923        let args = pop_args(vm, argc);
1924        let status = crate::extensions::ext_builtins::shopt(&args);
1925        Value::Status(status)
1926    });
1927
1928    reg_passthru!(vm, BUILTIN_EMULATE, "emulate");
1929    reg_passthru!(vm, BUILTIN_GETOPTS, "getopts");
1930    reg_passthru!(vm, BUILTIN_AUTOLOAD, "autoload");
1931    reg_passthru!(vm, BUILTIN_FUNCTIONS, "functions");
1932    reg_passthru!(vm, BUILTIN_TRAP, "trap");
1933    reg_passthru!(vm, BUILTIN_DIRS, "dirs");
1934    // pushd / popd dispatch through canonical bin_cd via execbuiltin
1935    // — the BUILTINS table at src/ported/builtin.rs:9298 wires
1936    // `pushd` to bin_cd with funcid=BIN_PUSHD, and `popd` similarly
1937    // with BIN_POPD. Without these reg_passthru lines the fusevm
1938    // BUILTIN_PUSHD/POPD opcodes had no handler installed, so the
1939    // emitted CallBuiltin(110, …) silently returned a no-op and the
1940    // dirstack/$dirstack/pwd all stayed unchanged.
1941    reg_passthru!(vm, BUILTIN_PUSHD, "pushd");
1942    reg_passthru!(vm, BUILTIN_POPD, "popd");
1943    // type / whence / where / which all route through `bin_whence`
1944    // (canonical port at `src/ported/builtin.rs:3734` of
1945    // `Src/builtin.c:3975`). Each gets its own opcode so funcid +
1946    // defopts come from the BUILTINS table entry — execbuiltin
1947    // applies them correctly via the module-level dispatch_builtin.
1948    reg_passthru!(vm, BUILTIN_WHENCE, "whence");
1949    reg_passthru!(vm, BUILTIN_TYPE, "type");
1950    reg_passthru!(vm, BUILTIN_WHICH, "which");
1951    reg_passthru!(vm, BUILTIN_WHERE, "where");
1952    reg_passthru!(vm, BUILTIN_HASH, "hash");
1953    reg_passthru!(vm, BUILTIN_REHASH, "rehash");
1954
1955    // `unhash`/`unalias`/`unfunction` share `bin_unhash` (Src/builtin.c
1956    // c:4350) but each carries its own funcid (BIN_UNHASH /
1957    // BIN_UNALIAS / BIN_UNFUNCTION) in the BUILTINS table.
1958    reg_passthru!(vm, BUILTIN_UNHASH, "unhash");
1959    vm.register_builtin(BUILTIN_UNALIAS, |vm, argc| {
1960        let args = pop_args(vm, argc);
1961        Value::Status(dispatch_builtin("unalias", args))
1962    });
1963    vm.register_builtin(BUILTIN_UNFUNCTION, |vm, argc| {
1964        let args = pop_args(vm, argc);
1965        Value::Status(dispatch_builtin("unfunction", args))
1966    });
1967
1968    // Completion
1969    vm.register_builtin(BUILTIN_COMPGEN, |vm, argc| {
1970        let args = pop_args(vm, argc);
1971        // c:Bug #475/#555 — `compgen` is a bash-only builtin. In
1972        // `--zsh` mode emit "command not found" matching zsh's
1973        // external-command lookup miss.
1974        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1975            eprintln!("zsh:1: command not found: compgen");
1976            let _ = args;
1977            return Value::Status(127);
1978        }
1979        let status = with_executor(|exec| exec.builtin_compgen(&args));
1980        Value::Status(status)
1981    });
1982
1983    vm.register_builtin(BUILTIN_COMPLETE, |vm, argc| {
1984        let args = pop_args(vm, argc);
1985        // c:Bug #475 — `complete` is a bash-only builtin. Same gate
1986        // as BUILTIN_COMPGEN above.
1987        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1988            eprintln!("zsh:1: command not found: complete");
1989            let _ = args;
1990            return Value::Status(127);
1991        }
1992        let status = with_executor(|exec| exec.builtin_complete(&args));
1993        Value::Status(status)
1994    });
1995
1996    reg_passthru!(vm, BUILTIN_COMPADD, "compadd");
1997    reg_passthru!(vm, BUILTIN_COMPSET, "compset");
1998
1999    // See the const's doc comment for the contract. Stack (bottom→top):
2000    // base, e1, …, eN — argc = N + 1.
2001    vm.register_builtin(BUILTIN_TYPESET_PAREN_PACK, |vm, argc| {
2002        let mut vals: Vec<Value> = Vec::with_capacity(argc as usize);
2003        for _ in 0..argc {
2004            vals.push(vm.pop());
2005        }
2006        vals.reverse();
2007        let mut it = vals.into_iter();
2008        let mut out = it.next().map(|v| v.to_str()).unwrap_or_default();
2009        for v in it {
2010            match v {
2011                // Array → splice items as separate elements (splat);
2012                // empty array contributes nothing (empty elision).
2013                Value::Array(items) => {
2014                    for item in items {
2015                        out.push('\u{1f}');
2016                        out.push_str(&item.to_str());
2017                    }
2018                }
2019                other => {
2020                    out.push('\u{1f}');
2021                    out.push_str(&other.to_str());
2022                }
2023            }
2024        }
2025        Value::str(out)
2026    });
2027
2028    vm.register_builtin(BUILTIN_TYPESET_PAREN_CLOSE, |vm, _argc| {
2029        let base = vm.pop().to_str();
2030        Value::str(format!("{}\u{1f})", base))
2031    });
2032
2033    vm.register_builtin(BUILTIN_COMPDEF, |vm, argc| {
2034        let args = pop_args(vm, argc);
2035        // ACTUALLY A ZSH FUNCTION: compdef is defined by `compinit`, it is
2036        // never a builtin. Without the completion system set up it is
2037        // command-not-found (127) in every mode — `zsh -f; compdef` prints
2038        // "command not found: compdef". A user/compsys `compdef` FUNCTION
2039        // (autoload compinit → compinit defines compdef) wins and runs the
2040        // fast native impl; otherwise it's command-not-found. Previously the
2041        // extension builtin ran in native mode (bare `compdef` → "I need
2042        // arguments"), diverging from zsh.
2043        // compinit installs a `compdef` function stub (see
2044        // NATIVE_COMPDEF_MARKER) purely so `${+functions[compdef]}` is
2045        // true; route that exact body to the fast native impl instead of
2046        // dispatching the stub. A genuine user/compsys compdef function
2047        // (any other body) still wins via try_user_fn_override below.
2048        let is_native_stub = crate::ported::hashtable::shfunctab_lock()
2049            .read()
2050            .ok()
2051            .and_then(|t| t.get("compdef").and_then(|shf| shf.body.clone()))
2052            .map(|b| b.trim() == crate::extensions::ext_builtins::NATIVE_COMPDEF_MARKER)
2053            .unwrap_or(false);
2054        if is_native_stub {
2055            return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
2056        }
2057        if let Some(s) = try_user_fn_override("compdef", &args) {
2058            return Value::Status(s);
2059        }
2060        if with_executor(|exec| exec.function_exists("compdef")) {
2061            return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
2062        }
2063        eprintln!("zsh:1: command not found: compdef");
2064        Value::Status(127)
2065    });
2066
2067    vm.register_builtin(BUILTIN_COMPINIT, |vm, argc| {
2068        let args = pop_args(vm, argc);
2069        // ACTUALLY A ZSH FUNCTION: compinit is a contrib FUNCTION (autoloaded
2070        // from $fpath), never a builtin. Without `autoload -Uz compinit` it is
2071        // command-not-found
2072        // (127) in every mode — `zsh -f; compinit` prints
2073        // "command not found: compinit". zshrs previously ran its builtin
2074        // unconditionally, so bare `compinit` succeeded. Gate on a compinit
2075        // function entry existing (which `autoload -Uz compinit` creates);
2076        // once the user has autoloaded/defined it, run zshrs's implementation.
2077        if !with_executor(|exec| exec.function_exists("compinit")) {
2078            eprintln!("zsh:1: command not found: compinit");
2079            let _ = args;
2080            return Value::Status(127);
2081        }
2082        Value::Status(with_executor(|exec| exec.builtin_compinit(&args)))
2083    });
2084
2085    vm.register_builtin(BUILTIN_CDREPLAY, |vm, argc| {
2086        let args = pop_args(vm, argc);
2087        Value::Status(with_executor(|exec| exec.builtin_cdreplay(&args)))
2088    });
2089
2090    // Zsh-specific
2091    reg_passthru!(vm, BUILTIN_ZSTYLE, "zstyle");
2092    reg_passthru!(vm, BUILTIN_ZMODLOAD, "zmodload");
2093    reg_passthru!(vm, BUILTIN_BINDKEY, "bindkey");
2094    reg_passthru!(vm, BUILTIN_ZLE, "zle");
2095    reg_passthru!(vm, BUILTIN_VARED, "vared");
2096    reg_passthru!(vm, BUILTIN_ZCOMPILE, "zcompile");
2097    reg_passthru!(vm, BUILTIN_ZFORMAT, "zformat");
2098    reg_passthru!(vm, BUILTIN_ZPARSEOPTS, "zparseopts");
2099    reg_passthru!(vm, BUILTIN_ZREGEXPARSE, "zregexparse");
2100
2101    // Resource limits
2102    reg_passthru!(vm, BUILTIN_ULIMIT, "ulimit");
2103    reg_passthru!(vm, BUILTIN_LIMIT, "limit");
2104    reg_passthru!(vm, BUILTIN_UNLIMIT, "unlimit");
2105    reg_passthru!(vm, BUILTIN_UMASK, "umask");
2106
2107    // Misc
2108    reg_passthru!(vm, BUILTIN_TIMES, "times");
2109
2110    vm.register_builtin(BUILTIN_CALLER, |vm, argc| {
2111        let args = pop_args(vm, argc);
2112        // c:Bug #475 — `caller` is a bash-only builtin. In `--zsh`
2113        // mode emit the canonical "command not found" diagnostic
2114        // and rc=127 matching zsh's external-command-lookup miss.
2115        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2116            eprintln!("zsh:1: command not found: caller");
2117            let _ = args;
2118            return Value::Status(127);
2119        }
2120        Value::Status(with_executor(|exec| exec.builtin_caller(&args)))
2121    });
2122
2123    vm.register_builtin(BUILTIN_HELP, |vm, argc| {
2124        let args = pop_args(vm, argc);
2125        // c:Bug #475 — `help` is a bash-only builtin. Same gate as
2126        // BUILTIN_CALLER above.
2127        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2128            eprintln!("zsh:1: command not found: help");
2129            let _ = args;
2130            return Value::Status(127);
2131        }
2132        Value::Status(with_executor(|exec| exec.builtin_help(&args)))
2133    });
2134
2135    reg_passthru!(vm, BUILTIN_ENABLE, "enable");
2136    reg_passthru!(vm, BUILTIN_DISABLE, "disable");
2137    reg_passthru!(vm, BUILTIN_TTYCTL, "ttyctl");
2138    reg_passthru!(vm, BUILTIN_SYNC, "sync");
2139    reg_passthru!(vm, BUILTIN_MKDIR, "mkdir");
2140    reg_passthru!(vm, BUILTIN_STRFTIME, "strftime");
2141
2142    vm.register_builtin(BUILTIN_ZSLEEP, |vm, argc| {
2143        Value::Status(crate::extensions::ext_builtins::zsleep(&pop_args(vm, argc)))
2144    });
2145
2146    reg_passthru!(vm, BUILTIN_ZSYSTEM, "zsystem");
2147
2148    // PCRE
2149    reg_passthru!(vm, BUILTIN_PCRE_COMPILE, "pcre_compile");
2150    reg_passthru!(vm, BUILTIN_PCRE_MATCH, "pcre_match");
2151    reg_passthru!(vm, BUILTIN_PCRE_STUDY, "pcre_study");
2152
2153    // Database (GDBM)
2154    reg_passthru!(vm, BUILTIN_ZTIE, "ztie");
2155    reg_passthru!(vm, BUILTIN_ZUNTIE, "zuntie");
2156    reg_passthru!(vm, BUILTIN_ZGDBMPATH, "zgdbmpath");
2157
2158    // Prompt
2159    vm.register_builtin(BUILTIN_PROMPTINIT, |vm, argc| {
2160        let args = pop_args(vm, argc);
2161        // ACTUALLY A ZSH FUNCTION: promptinit is a contrib FUNCTION
2162        // (autoloaded from $fpath), never a builtin. Command-not-found until
2163        // `autoload -Uz promptinit`; once autoloaded, run the native impl.
2164        if !with_executor(|exec| exec.function_exists("promptinit")) {
2165            eprintln!("zsh:1: command not found: promptinit");
2166            let _ = args;
2167            return Value::Status(127);
2168        }
2169        Value::Status(crate::extensions::ext_builtins::promptinit(&args))
2170    });
2171
2172    vm.register_builtin(BUILTIN_PROMPT, |vm, argc| {
2173        let args = pop_args(vm, argc);
2174        Value::Status(crate::extensions::ext_builtins::prompt(&args))
2175    });
2176
2177    // Async / Parallel (zshrs extensions)
2178    vm.register_builtin(BUILTIN_ASYNC, |vm, argc| {
2179        let args = pop_args(vm, argc);
2180        let status = with_executor(|exec| exec.builtin_async(&args));
2181        Value::Status(status)
2182    });
2183
2184    vm.register_builtin(BUILTIN_AWAIT, |vm, argc| {
2185        let args = pop_args(vm, argc);
2186        let status = with_executor(|exec| exec.builtin_await(&args));
2187        Value::Status(status)
2188    });
2189
2190    vm.register_builtin(BUILTIN_PMAP, |vm, argc| {
2191        let args = pop_args(vm, argc);
2192        let status = with_executor(|exec| exec.builtin_pmap(&args));
2193        Value::Status(status)
2194    });
2195
2196    vm.register_builtin(BUILTIN_PGREP, |vm, argc| {
2197        let args = pop_args(vm, argc);
2198        let status = with_executor(|exec| exec.builtin_pgrep(&args));
2199        Value::Status(status)
2200    });
2201
2202    vm.register_builtin(BUILTIN_PEACH, |vm, argc| {
2203        let args = pop_args(vm, argc);
2204        let status = with_executor(|exec| exec.builtin_peach(&args));
2205        Value::Status(status)
2206    });
2207
2208    vm.register_builtin(BUILTIN_BARRIER, |vm, argc| {
2209        let args = pop_args(vm, argc);
2210        let status = with_executor(|exec| exec.builtin_barrier(&args));
2211        Value::Status(status)
2212    });
2213
2214    // Intercept (AOP)
2215    vm.register_builtin(BUILTIN_INTERCEPT, |vm, argc| {
2216        let args = pop_args(vm, argc);
2217        let status = with_executor(|exec| exec.builtin_intercept(&args));
2218        Value::Status(status)
2219    });
2220
2221    vm.register_builtin(BUILTIN_INTERCEPT_PROCEED, |vm, argc| {
2222        let args = pop_args(vm, argc);
2223        let status = with_executor(|exec| exec.builtin_intercept_proceed(&args));
2224        Value::Status(status)
2225    });
2226
2227    // Debug / Profile
2228    vm.register_builtin(BUILTIN_DOCTOR, |vm, argc| {
2229        let args = pop_args(vm, argc);
2230        let status = with_executor(|exec| exec.builtin_doctor(&args));
2231        Value::Status(status)
2232    });
2233
2234    vm.register_builtin(BUILTIN_DBVIEW, |vm, argc| {
2235        let args = pop_args(vm, argc);
2236        let status = with_executor(|exec| exec.builtin_dbview(&args));
2237        Value::Status(status)
2238    });
2239
2240    vm.register_builtin(BUILTIN_PROFILE, |vm, argc| {
2241        let args = pop_args(vm, argc);
2242        let status = with_executor(|exec| exec.builtin_profile(&args));
2243        Value::Status(status)
2244    });
2245
2246    reg_passthru!(vm, BUILTIN_ZPROF, "zprof");
2247
2248    // ═══════════════════════════════════════════════════════════════════════
2249    // Coreutils builtins (anti-fork, gated by !posix_mode)
2250    //
2251    // All of these are routinely wrapped by user functions in real
2252    // dotfiles (zpwr, oh-my-zsh, etc.) — `cat() { ... }`, `ls() { ... }`,
2253    // `find() { ... }`. Each handler MUST consult try_user_fn_override
2254    // first (via reg_overridable!) so the user definition wins, matching
2255    // zsh's alias → function → builtin dispatch order.
2256    // ═══════════════════════════════════════════════════════════════════════
2257
2258    reg_overridable!(vm, BUILTIN_CAT, "cat", builtin_cat);
2259    reg_overridable!(vm, BUILTIN_HEAD, "head", builtin_head);
2260    reg_overridable!(vm, BUILTIN_TAIL, "tail", builtin_tail);
2261    reg_overridable!(vm, BUILTIN_WC, "wc", builtin_wc);
2262    reg_overridable!(vm, BUILTIN_BASENAME, "basename", builtin_basename);
2263    reg_overridable!(vm, BUILTIN_DIRNAME, "dirname", builtin_dirname);
2264    reg_overridable!(vm, BUILTIN_TOUCH, "touch", builtin_touch);
2265    reg_overridable!(vm, BUILTIN_REALPATH, "realpath", builtin_realpath);
2266    reg_overridable!(vm, BUILTIN_SORT, "sort", builtin_sort);
2267    reg_overridable!(vm, BUILTIN_FIND, "find", builtin_find);
2268    reg_overridable!(vm, BUILTIN_UNIQ, "uniq", builtin_uniq);
2269    reg_overridable!(vm, BUILTIN_CUT, "cut", builtin_cut);
2270    reg_overridable!(vm, BUILTIN_TR, "tr", builtin_tr);
2271    reg_overridable!(vm, BUILTIN_SEQ, "seq", builtin_seq);
2272    reg_overridable!(vm, BUILTIN_REV, "rev", builtin_rev);
2273    reg_overridable!(vm, BUILTIN_TEE, "tee", builtin_tee);
2274    reg_overridable!(vm, BUILTIN_SLEEP, "sleep", builtin_sleep);
2275    reg_overridable!(vm, BUILTIN_WHOAMI, "whoami", builtin_whoami);
2276    reg_overridable!(vm, BUILTIN_ID, "id", builtin_id);
2277
2278    reg_overridable!(vm, BUILTIN_HOSTNAME, "hostname", builtin_hostname);
2279    reg_overridable!(vm, BUILTIN_UNAME, "uname", builtin_uname);
2280    reg_overridable!(vm, BUILTIN_DATE, "date", builtin_date);
2281    reg_overridable!(vm, BUILTIN_MKTEMP, "mktemp", builtin_mktemp);
2282    // `cp` — zshrs extension (NOT in upstream zsh; upstream's
2283    // zsh/files module ships `ln`/`mv`/`rm`/`chmod`/`chown` but no
2284    // `cp`). In-process implementation in
2285    // `ext_builtins::cp_impl` — recursive copy with -r/-R, -f, -i,
2286    // -n, -p (chown + utimensat), -v. ID 263 is the first slot
2287    // past fusevm's built-in range (260-262) and before BUILTIN_MAX
2288    // (280).
2289    /// `BUILTIN_CP` constant.
2290    pub const BUILTIN_CP: u16 = 263;
2291    reg_overridable!(vm, BUILTIN_CP, "cp", builtin_cp);
2292
2293    // Pipeline execution — bytecode-native fork-per-stage. Pops N sub-chunk
2294    // indices, forks N children with stdin/stdout wired through N-1 pipes,
2295    // each child runs its stage's compiled bytecode and exits. Parent waits
2296    // and returns the last stage's status.
2297    //
2298    // Caveats: post-fork in a multi-threaded program, only async-signal-safe
2299    // ops are POSIX-safe. We violate this (running the bytecode VM after fork
2300    // touches mutexes like REGEX_CACHE). In practice, most pipeline stages
2301    // don't touch shared mutex state — externals fork/exec away, builtins do
2302    // pure I/O. Risks are bounded; if a stage does touch a held mutex, the
2303    // child deadlocks.
2304    vm.register_builtin(BUILTIN_RUN_PIPELINE, |vm, argc| {
2305        let n = argc as usize;
2306        if n == 0 {
2307            return Value::Status(0);
2308        }
2309
2310        // c:Src/exec.c — every pipeline stage forks from the current
2311        // shell state, so each stage observes the pre-pipeline $? until
2312        // it runs its own command. Stage sub-VMs start fresh with
2313        // last_status=0, so seed them with the parent's lastval; without
2314        // this `false; echo $? | cat` prints 0 instead of zsh's 1.
2315        let parent_status = vm.last_status;
2316
2317        // Pop N sub-chunk indices (LIFO → reverse to stage order)
2318        let mut indices: Vec<u16> = Vec::with_capacity(n);
2319        for _ in 0..n {
2320            indices.push(vm.pop().to_int() as u16);
2321        }
2322        indices.reverse();
2323
2324        // Clone each stage's sub-chunk
2325        let stages: Vec<fusevm::Chunk> = indices
2326            .iter()
2327            .filter_map(|&i| vm.chunk.sub_chunks.get(i as usize).cloned())
2328            .collect();
2329        if stages.len() != n {
2330            return Value::Status(1);
2331        }
2332
2333        // Single stage — no pipe, just run inline
2334        if n == 1 {
2335            let stage = stages.into_iter().next().unwrap();
2336            crate::fusevm_disasm::maybe_print_stdout("pipeline:single", &stage);
2337            let mut stage_vm = fusevm::VM::new(stage);
2338            stage_vm.last_status = parent_status;
2339            register_builtins(&mut stage_vm);
2340            let _ = stage_vm.run();
2341            return Value::Status(stage_vm.last_status);
2342        }
2343
2344        // Build N-1 pipes
2345        let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(n - 1);
2346        for _ in 0..n - 1 {
2347            let mut fds = [0i32; 2];
2348            if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
2349                // Cleanup any pipes we already created
2350                for (r, w) in &pipes {
2351                    unsafe {
2352                        libc::close(*r);
2353                        libc::close(*w);
2354                    }
2355                }
2356                return Value::Status(1);
2357            }
2358            pipes.push((fds[0], fds[1]));
2359        }
2360
2361        // zsh runs the LAST stage of a pipeline in the CURRENT shell
2362        // (not a forked child) so a trailing `read x` keeps its
2363        // assignment in the parent. Other shells (bash) fork every
2364        // stage. Honor zsh by leaving stage N-1 inline. Forks the
2365        // first N-1 stages with fork(); runs the last in this process
2366        // with stdin dup2'd to the last pipe's read end and stdout
2367        // restored after.
2368        let last_idx = n - 1;
2369        let stages_vec: Vec<fusevm::Chunk> = stages.into_iter().collect();
2370
2371        let mut child_pids: Vec<libc::pid_t> = Vec::with_capacity(n - 1);
2372        for (i, chunk) in stages_vec.iter().take(last_idx).enumerate() {
2373            match unsafe { libc::fork() } {
2374                -1 => {
2375                    // fork failed — kill any children we already started
2376                    for pid in &child_pids {
2377                        unsafe { libc::kill(*pid, libc::SIGTERM) };
2378                    }
2379                    for (r, w) in &pipes {
2380                        unsafe {
2381                            libc::close(*r);
2382                            libc::close(*w);
2383                        }
2384                    }
2385                    return Value::Status(1);
2386                }
2387                0 => {
2388                    // Reset SIGPIPE to default so a broken-pipe write
2389                    // kills the child cleanly instead of triggering a
2390                    // Rust println! panic. The parent shell ignores
2391                    // SIGPIPE so it can handle EPIPE itself, but child
2392                    // pipeline stages should die quietly when their
2393                    // downstream stage closes early (e.g. `seq | head -3`).
2394                    unsafe {
2395                        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
2396                    }
2397                    // c:Src/exec.c — pipeline children are forked
2398                    // subshells; their EXIT trap context is reset so
2399                    // the parent's `trap '...' EXIT` doesn't fire when
2400                    // the child exits. Mirror by dropping EXIT from
2401                    // the inherited traps_table inside the child.
2402                    if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
2403                        t.remove("EXIT");
2404                    }
2405                    // c:Src/exec.c:2862 → 1219 — pipeline children run
2406                    // entersubsh with ESUB_PGRP, which clears the job
2407                    // table (clearjobtab, Src/jobs.c:1780). Without
2408                    // this, `sleep 5 & jobs -p | wc -l` reports 1 in
2409                    // the forked stage where zsh reports 0. The fork
2410                    // already copy-isolates the statics, so mutating
2411                    // them here can't leak to the parent.
2412                    with_executor(|exec| {
2413                        let monitor =
2414                            crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
2415                        crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
2416                    });
2417                    *crate::ported::jobs::THISJOB
2418                        .get_or_init(|| std::sync::Mutex::new(-1))
2419                        .lock()
2420                        .unwrap() = -1;
2421                    // Child: wire stdin from previous pipe's read end
2422                    if i > 0 {
2423                        unsafe {
2424                            libc::dup2(pipes[i - 1].0, libc::STDIN_FILENO);
2425                        }
2426                    }
2427                    // Wire stdout to next pipe's write end
2428                    unsafe {
2429                        libc::dup2(pipes[i].1, libc::STDOUT_FILENO);
2430                    }
2431                    // (Pipe-output MULTIOS marking — c:Src/exec.c:3724 —
2432                    // is emitted INTO the stage chunk by compile_pipe
2433                    // via BUILTIN_PIPE_OUTPUT_MARK, gated on the stage's
2434                    // top-level command actually carrying redirects, so
2435                    // a nested `{ echo a > f; } | cat` body redirect
2436                    // does not wrongly join the pipe.)
2437                    // Close all original pipe fds (keeping stdin/stdout dups)
2438                    for (r, w) in &pipes {
2439                        unsafe {
2440                            libc::close(*r);
2441                            libc::close(*w);
2442                        }
2443                    }
2444
2445                    // Run this stage's bytecode on a fresh VM
2446                    crate::fusevm_disasm::maybe_print_stdout(
2447                        &format!("pipeline:child:stage:{i}"),
2448                        chunk,
2449                    );
2450                    let mut stage_vm = fusevm::VM::new(chunk.clone());
2451                    stage_vm.last_status = parent_status;
2452                    register_builtins(&mut stage_vm);
2453                    let _ = stage_vm.run();
2454                    // Flush any buffered output before exiting
2455                    let _ = std::io::stdout().flush();
2456                    let _ = std::io::stderr().flush();
2457                    std::process::exit(stage_vm.last_status);
2458                }
2459                pid => {
2460                    child_pids.push(pid);
2461                }
2462            }
2463        }
2464
2465        // Parent runs the LAST stage inline. Save stdin, dup the last
2466        // pipe's read end onto fd 0, run the chunk, restore stdin.
2467        // Close every other pipe fd so the producer side gets EOF
2468        // when the last upstream stage exits.
2469        let saved_stdin = unsafe { libc::dup(libc::STDIN_FILENO) };
2470        if last_idx > 0 {
2471            let read_fd = pipes[last_idx - 1].0;
2472            unsafe {
2473                libc::dup2(read_fd, libc::STDIN_FILENO);
2474            }
2475        }
2476        // Close all pipe fds in the parent now that stdin is wired.
2477        // (Children already have their own copies. The dup2 above
2478        // already gave us a fresh fd 0 if needed.)
2479        for (r, w) in &pipes {
2480            unsafe {
2481                libc::close(*r);
2482                libc::close(*w);
2483            }
2484        }
2485
2486        // Run the last stage's bytecode on a sub-VM with the host
2487        // wired up. The host points back at the executor so reads
2488        // (`read x`) update the parent's variables directly.
2489        let last_stage_status = {
2490            let last_chunk = stages_vec.into_iter().last().unwrap();
2491            crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
2492            let mut stage_vm = fusevm::VM::new(last_chunk);
2493            stage_vm.last_status = parent_status;
2494            register_builtins(&mut stage_vm);
2495            stage_vm.set_shell_host(Box::new(ZshrsHost));
2496            let _ = stage_vm.run();
2497            let _ = std::io::stdout().flush();
2498            let _ = std::io::stderr().flush();
2499            stage_vm.last_status
2500        };
2501
2502        // Restore stdin
2503        if saved_stdin >= 0 {
2504            unsafe {
2505                libc::dup2(saved_stdin, libc::STDIN_FILENO);
2506                libc::close(saved_stdin);
2507            }
2508        }
2509
2510        // Wait for all forked stages, capture per-stage statuses for PIPESTATUS.
2511        let mut pipestatus: Vec<i32> = Vec::with_capacity(n);
2512        for pid in child_pids {
2513            let mut status: i32 = 0;
2514            unsafe {
2515                libc::waitpid(pid, &mut status, 0);
2516            }
2517            let s = if libc::WIFEXITED(status) {
2518                libc::WEXITSTATUS(status)
2519            } else if libc::WIFSIGNALED(status) {
2520                128 + libc::WTERMSIG(status)
2521            } else {
2522                1
2523            };
2524            pipestatus.push(s);
2525        }
2526        // Append the in-parent last-stage status so `pipestatus` ends
2527        // with N entries (one per stage).
2528        pipestatus.push(last_stage_status);
2529        // Pipeline exit status: by default, the LAST stage's status.
2530        // With `setopt pipefail` (or `set -o pipefail`), use the
2531        // first non-zero stage status (so failures earlier in the
2532        // pipeline propagate even if the last stage succeeded).
2533        let pipefail_on = with_executor(|exec| opt_state_get("pipefail").unwrap_or(false));
2534        let last_status = if pipefail_on {
2535            pipestatus
2536                .iter()
2537                .copied()
2538                .rfind(|&s| s != 0)
2539                .or_else(|| pipestatus.last().copied())
2540                .unwrap_or(0)
2541        } else {
2542            *pipestatus.last().unwrap_or(&0)
2543        };
2544
2545        // c:Src/params.c:265,438 — only `pipestatus` (lowercase) is the
2546        // zsh special parameter; bash's `PIPESTATUS` doesn't exist in
2547        // zsh's special-params table. Prior port also populated
2548        // `PIPESTATUS` "for portability" — but that's a real divergence
2549        // from zsh: a script doing `[[ -z $PIPESTATUS ]]` to detect
2550        // zsh-vs-bash would mis-classify. Bug #64 in docs/BUGS.md.
2551        with_executor(|exec| {
2552            let strs: Vec<String> = pipestatus.iter().map(|s| s.to_string()).collect();
2553            exec.set_array("pipestatus".to_string(), strs);
2554        });
2555
2556        Value::Status(last_status)
2557    });
2558
2559    // Array→String join. Pops one value; if it's an Array (e.g. from Op::Glob),
2560    // joins string-coerced elements with a single space. Pass-through for
2561    // non-arrays so the op is safe to chain after any String-or-Array producer.
2562    // Scalar coercion of an assembled word: pop a Value; if it's an
2563    // Array (produced by a splice segment like `"$@"` / `"${arr[@]}"`),
2564    // IFS[0]-join it to a single scalar; a scalar passes through. This
2565    // is the assignment-context coercion C zsh applies in multsub when
2566    // the expansion is the RHS of a SCALAR assignment (Src/subst.c
2567    // c:3032 sepjoin under ssub) — `v="$@"` joins the positionals with
2568    // ${IFS[1]} rather than leaving an array whose splat would lose all
2569    // but the first element. Joins via sepjoin so a custom / empty IFS
2570    // is honored (not a hardcoded space).
2571    vm.register_builtin(BUILTIN_ARRAY_JOIN, |vm, _argc| {
2572        let val = vm.pop();
2573        match val {
2574            Value::Array(items) => {
2575                let strs: Vec<String> = items.iter().map(|v| v.to_str()).collect();
2576                Value::str(crate::ported::utils::sepjoin(&strs, None))
2577            }
2578            other => other,
2579        }
2580    });
2581
2582    // `cmd &` background execution. Compile_list emits this for any item
2583    // followed by ListOp::Amp: the job text + the cmd's sub-chunk index are
2584    // pushed, then this builtin pops both, looks up the chunk, forks. The
2585    // child detaches via setsid (so SIGINT to the foreground job doesn't kill
2586    // it), runs the bytecode on a fresh VM with builtins re-registered, exits
2587    // with the last status. The parent registers the job in the canonical
2588    // JOBTAB (c:Src/exec.c::execpline Z_ASYNC arm) and returns Status(0).
2589    vm.register_builtin(BUILTIN_RUN_BG, |vm, _argc| {
2590        let sub_idx = vm.pop().to_int() as usize;
2591        let job_text = vm.pop().to_str();
2592        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
2593            Some(c) => c,
2594            None => return Value::Status(1),
2595        };
2596
2597        match unsafe { libc::fork() } {
2598            -1 => Value::Status(1),
2599            0 => {
2600                // Child: detach and run.
2601                unsafe { libc::setsid() };
2602                crate::fusevm_disasm::maybe_print_stdout("background_job", &chunk);
2603                let mut bg_vm = fusevm::VM::new(chunk);
2604                register_builtins(&mut bg_vm);
2605                let _ = bg_vm.run();
2606                let _ = std::io::stdout().flush();
2607                let _ = std::io::stderr().flush();
2608                std::process::exit(bg_vm.last_status);
2609            }
2610            pid => {
2611                // Parent: record the PID into `$!` (most recent
2612                // backgrounded job's pid). zsh exposes this for any
2613                // script that needs `wait $!`. Also register the
2614                // bare-pid job so a no-args `wait` can synchronize.
2615                // c:Src/jobs.c:73 — `lastpid = pid;` after a
2616                // background fork. zshrs's `$!` getter
2617                // (params.rs::lookup_special_var "!") reads from
2618                // the same atomic, so a single store here is the
2619                // canonical writer.
2620                crate::ported::modules::clone::lastpid
2621                    .store(pid, std::sync::atomic::Ordering::Relaxed);
2622                // c:Src/exec.c:1700 — `thisjob = newjob = initjob()`:
2623                // allocate the canonical jobtab slot. c:Src/exec.c:2950
2624                // zfork path → addproc(pid, text, 0, &bgtime, ...) hangs
2625                // the proc entry (with its display text) off the job.
2626                // c:Src/exec.c:1744-1746 — `clearoldjobtab();
2627                // jobtab[thisjob].stat |= STAT_NOSTTY;` then c:1758
2628                // `spawnjob()` promotes it to curjob (top-level shell
2629                // only), marks STAT_LOCKED and resets thisjob.
2630                {
2631                    use crate::ported::jobs;
2632                    use std::sync::Mutex;
2633                    let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
2634                    let idx = {
2635                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
2636                        let idx = jobs::initjob(&mut tab); // c:exec.c:1700
2637                        jobs::addproc(
2638                            &mut tab[idx],
2639                            pid,
2640                            &job_text,
2641                            false,
2642                            Some(std::time::Instant::now()),
2643                            -1,
2644                            -1,
2645                        ); // c:exec.c:2950 addproc
2646                        tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
2647                        idx
2648                    };
2649                    jobs::clearoldjobtab(); // c:exec.c:1744
2650                    if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
2651                        *tj = idx as i32;
2652                    }
2653                    jobs::spawnjob(); // c:exec.c:1758
2654                }
2655                with_executor(|exec| {
2656                    exec.jobs
2657                        .add_pid_job(pid, job_text.clone(), JobState::Running);
2658                });
2659                Value::Status(0)
2660            }
2661        }
2662    });
2663
2664    // ── Indexed-array storage ─────────────────────────────────────────────
2665    //
2666    // Stack: pushed values then name (LAST). `arr=(a b c)` → 4 args
2667    // (a, b, c, arr). `arr=($(cmd))` → 2 args (FlatArray, arr).
2668    //
2669    // PURE PASSTHRU: pop name + values, dispatch to canonical
2670    // `setaparam` / `sethparam` (C port of `Src/params.c:3595/3602`).
2671    // assignaparam already handles PM_UNIQUE dedupe, type-flag flip,
2672    // PM_NAMEREF rejection, ASSPM_AUGMENT prepend, and createparam
2673    // for fresh names.
2674    vm.register_builtin(BUILTIN_SET_ARRAY, |vm, argc| {
2675        // `${~spec}` carrier: an assignment statement is a word-
2676        // pipeline boundary too — restore the user's GLOB_SUBST
2677        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
2678        // ${options[globsubst]}` must read the user value).
2679        consume_tilde_globsubst_carrier();
2680        let n = argc as usize;
2681        let mut popped: Vec<Value> = Vec::with_capacity(n);
2682        for _ in 0..n {
2683            popped.push(vm.pop());
2684        }
2685        popped.reverse();
2686        if popped.is_empty() {
2687            return Value::Status(1);
2688        }
2689        let name = popped.pop().unwrap().to_str();
2690        let mut values: Vec<String> = Vec::new();
2691        for v in popped {
2692            flatten_array_value(v, &mut values);
2693        }
2694        let blocked = with_executor(|exec| {
2695            // Assoc init `typeset -A m; m=(k v k v ...)` — route to
2696            // canonical sethparam (Src/params.c:3602) which parses the
2697            // flat (k,v) pair list internally.
2698            if exec.assoc(&name).is_some() {
2699                // `[k]=v` / `[k]+=v` elements arrive from the compiler
2700                // as Marker / key / value triples (compile_zsh's port
2701                // of keyvalpairelement, c:Src/subst.c:49-79).
2702                let marker = crate::ported::zsh_h::Marker;
2703                let values = if values.iter().any(|e| e.starts_with(marker)) {
2704                    // c:Src/params.c:3544-3560 — under ASSPM_KEY_VALUE
2705                    // assocs strictly enforce `[key]=value`: every
2706                    // stride-of-3 element must be a Marker. Mixing
2707                    // plain pairs with kv triads is an error.
2708                    let mut i = 0usize;
2709                    while i < values.len() {
2710                        if !values[i].starts_with(marker) {
2711                            crate::ported::utils::zerr(
2712                                "bad [key]=value syntax for associative array",
2713                            );
2714                            crate::ported::utils::errflag.fetch_or(
2715                                crate::ported::zsh_h::ERRFLAG_ERROR,
2716                                std::sync::atomic::Ordering::Relaxed,
2717                            );
2718                            exec.set_last_status(1);
2719                            return true;
2720                        }
2721                        i += 3;
2722                    }
2723                    if values.len() % 3 != 0 {
2724                        // c:Src/params.c:4124-4131 arrhashsetfn — a
2725                        // truncated triad leaves an odd non-Marker
2726                        // count → "bad set of key/value pairs".
2727                        crate::ported::utils::zerr(
2728                            "bad set of key/value pairs for associative array",
2729                        );
2730                        crate::ported::utils::errflag.fetch_or(
2731                            crate::ported::zsh_h::ERRFLAG_ERROR,
2732                            std::sync::atomic::Ordering::Relaxed,
2733                        );
2734                        exec.set_last_status(1);
2735                        return true;
2736                    }
2737                    // c:Src/params.c:4136-4168 arrhashsetfn — whole
2738                    // assignment builds a FRESH table; a `Marker +`
2739                    // triad (`[k]+=v`) appends to the value inserted
2740                    // EARLIER IN THIS SAME LITERAL (assignstrvalue
2741                    // with eltflags=ASSPM_AUGMENT against the new ht),
2742                    // so `h=([k]=a [k]+=b)` yields "ab". Resolve the
2743                    // appends here, then hand flat pairs to sethparam.
2744                    let mut order: Vec<String> = Vec::new();
2745                    let mut map: std::collections::HashMap<String, String> =
2746                        std::collections::HashMap::new();
2747                    for ch in values.chunks(3) {
2748                        let elt_append = ch[0].chars().nth(1) == Some('+');
2749                        let k = ch[1].clone();
2750                        let v = ch[2].clone();
2751                        let nv = if elt_append {
2752                            format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
2753                        } else {
2754                            v
2755                        };
2756                        if !map.contains_key(&k) {
2757                            order.push(k.clone());
2758                        }
2759                        map.insert(k, nv);
2760                    }
2761                    order
2762                        .into_iter()
2763                        .flat_map(|k| {
2764                            let v = map.get(&k).cloned().unwrap_or_default();
2765                            [k, v]
2766                        })
2767                        .collect()
2768                } else {
2769                    values
2770                };
2771                // Odd-count rejection lives in the canonical chain:
2772                // sethparam → setarrvalue (c:3651/c:2920) →
2773                // arrhashsetfn's zerr "bad set of key/value pairs"
2774                // (Src/params.c:4128-4131). zerr sets ERRFLAG_ERROR,
2775                // which aborts the remaining list at the next command
2776                // boundary (BUILTIN_ERREXIT_CHECK trigger 4) —
2777                // matching `zsh -fc 'typeset -A m; m=(odd); print x'`
2778                // printing nothing after the error. Like C's sethparam
2779                // (c:3652-3653 returns v->pm regardless), the Rust
2780                // port returns Some on the odd-count path — the
2781                // failure travels via errflag, so check BOTH.
2782                let pre_err = crate::ported::utils::errflag
2783                    .load(std::sync::atomic::Ordering::Relaxed)
2784                    & crate::ported::zsh_h::ERRFLAG_ERROR;
2785                let res = crate::ported::params::sethparam(&name, values.clone());
2786                let now_err = crate::ported::utils::errflag
2787                    .load(std::sync::atomic::Ordering::Relaxed)
2788                    & crate::ported::zsh_h::ERRFLAG_ERROR;
2789                if res.is_none() || (pre_err == 0 && now_err != 0) {
2790                    // c:Src/exec.c:2632-2633 addvars — `if
2791                    // (!assignaparam(name, arr, myflags)) lastval = 1;`
2792                    // — failed assignment sets lastval so the errflag
2793                    // abort exits 1 (init.c loop() breaks, zsh_main
2794                    // returns lastval).
2795                    exec.set_last_status(1);
2796                    return true;
2797                }
2798                #[cfg(feature = "recorder")]
2799                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2800                    let ctx = exec.recorder_ctx();
2801                    let attrs = exec.recorder_attrs_for(&name);
2802                    let mut pairs: Vec<(String, String)> = Vec::with_capacity(values.len() / 2);
2803                    let mut iter = values.iter().cloned();
2804                    while let Some(k) = iter.next() {
2805                        if let Some(v) = iter.next() {
2806                            pairs.push((k, v));
2807                        }
2808                    }
2809                    crate::recorder::emit_assoc_assign(&name, pairs, attrs, false, ctx);
2810                }
2811                return false;
2812            }
2813            // Indexed-array: setaparam (Src/params.c:3766) wraps
2814            // assignaparam with ASSPM_WARN — handles PM_UNIQUE dedupe,
2815            // type-flag flip, PM_READONLY rejection.
2816            //
2817            // `[k]=v` elements arrive as Marker / key / value triples
2818            // (compile_zsh's keyvalpairelement port). Mirror
2819            // c:Src/exec.c:2552-2553 — `if (prefork_ret &
2820            // PREFORK_KEY_VALUE) myflags |= ASSPM_KEY_VALUE;` — so
2821            // assignaparam runs its kv-resolution block (sparse fill
2822            // for PM_ARRAY, c:3447-3541; strict-triad enforcement for
2823            // special PM_HASHED targets like `options`, c:3544-3560).
2824            let values = values;
2825            let has_kv = values
2826                .iter()
2827                .any(|e| e.starts_with(crate::ported::zsh_h::Marker));
2828            // The tied-array mirror to a PM_TIED scalar
2829            // (`typeset -T PATH path`) lives canonically in
2830            // setarrvalue's dispatch in C zsh; until that wires
2831            // through assignaparam, mirror here so PATH stays in sync
2832            // after `path=(/x)`.
2833            if let Some((scalar_name, sep)) = exec.tied_array_to_scalar.get(&name).cloned() {
2834                let joined = values.join(&sep);
2835                exec.set_scalar(scalar_name, joined);
2836            }
2837            // c:Src/exec.c:2632-2633 addvars — `if (!assignaparam(...))
2838            // lastval = 1;` — a failed assignment (bad subscript, bad
2839            // [key]=value syntax, readonly) exits 1 and the errflag
2840            // abort stops the remaining list. Track errflag pre/post
2841            // like the assoc branch above.
2842            let kv_flag = if has_kv {
2843                crate::ported::zsh_h::ASSPM_KEY_VALUE
2844            } else {
2845                0
2846            };
2847            let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2848                & crate::ported::zsh_h::ERRFLAG_ERROR;
2849            let res = crate::ported::params::assignaparam(
2850                &name,
2851                values.clone(),
2852                crate::ported::zsh_h::ASSPM_WARN | kv_flag,
2853            );
2854            let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2855                & crate::ported::zsh_h::ERRFLAG_ERROR;
2856            if res.is_none() && pre_err == 0 && now_err != 0 {
2857                exec.set_last_status(1);
2858                return true;
2859            }
2860            #[cfg(feature = "recorder")]
2861            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
2862                let ctx = exec.recorder_ctx();
2863                let attrs = exec.recorder_attrs_for(&name);
2864                emit_path_or_assign(&name, &values, attrs, false, &ctx);
2865            }
2866            false
2867        });
2868        let status = if blocked { 1 } else { 0 };
2869        // c:Src/jobs.c:1748-1757 waitonejob — in C an array-assignment
2870        // simple command goes through execpline → waitjobs; with no
2871        // procs the else-branch stores `pipestats[0] = lastval;
2872        // numpipestats = 1`. Bare SCALAR assignments never create a
2873        // job (no waitjobs), so this clobber is array/assoc-assignment
2874        // specific: `false|true; x=(1 2); echo $pipestatus` → `0` in
2875        // zsh while `x=1` preserves `1 0`. Bug #373.
2876        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
2877        let mut synth = crate::ported::zsh_h::job::default();
2878        crate::ported::jobs::waitonejob(&mut synth);
2879        Value::Status(status)
2880    });
2881    // `arr+=(d e f)` — array append. Same calling conventions as SET_ARRAY.
2882    //
2883    // PURE PASSTHRU shape: pop name + values, dispatch through the
2884    // canonical assoc / array setter. assignaparam's ASSPM_AUGMENT
2885    // flag handles the C-source-equivalent "preserve prior value"
2886    // semantics; for now we read the current array, extend with
2887    // new values, write through set_array (which routes to
2888    // setaparam → assignaparam where PM_UNIQUE dedupe lands).
2889    vm.register_builtin(BUILTIN_APPEND_ARRAY, |vm, argc| {
2890        let n = argc as usize;
2891        let mut popped: Vec<Value> = Vec::with_capacity(n);
2892        for _ in 0..n {
2893            popped.push(vm.pop());
2894        }
2895        popped.reverse();
2896        if popped.is_empty() {
2897            return Value::Status(1);
2898        }
2899        let name = popped.pop().unwrap().to_str();
2900        let mut values: Vec<String> = Vec::new();
2901        for v in popped {
2902            flatten_array_value(v, &mut values);
2903        }
2904        let blocked = with_executor(|exec| -> bool {
2905            // Assoc append `m+=(k1 v1 ...)`: merge the (k,v) pairs into
2906            // the existing map and write back via canonical sethparam
2907            // (Src/params.c:3602). The canonical C path would go
2908            // assignaparam(ASSPM_AUGMENT) → arrhashsetfn(ASSPM_AUGMENT)
2909            // at Src/params.c:3850, but the zshrs port of
2910            // arrhashsetfn doesn't yet implement value-storage
2911            // (pending Param.u_hash backend wireup) — until that
2912            // lands, do the augment + write here so the storage
2913            // actually mutates.
2914            if exec.assoc(&name).is_some() {
2915                // `[k]=v` / `[k]+=v` elements arrive as Marker / key /
2916                // value triples (compile_zsh's port of
2917                // keyvalpairelement, c:Src/subst.c:49-79).
2918                let marker = crate::ported::zsh_h::Marker;
2919                let mut map = exec.assoc(&name).unwrap_or_default();
2920                if values.iter().any(|e| e.starts_with(marker)) {
2921                    // c:Src/params.c:3544-3560 — strict triad rule.
2922                    let mut i = 0usize;
2923                    while i < values.len() {
2924                        if !values[i].starts_with(marker) {
2925                            crate::ported::utils::zerr(
2926                                "bad [key]=value syntax for associative array",
2927                            );
2928                            crate::ported::utils::errflag.fetch_or(
2929                                crate::ported::zsh_h::ERRFLAG_ERROR,
2930                                std::sync::atomic::Ordering::Relaxed,
2931                            );
2932                            exec.set_last_status(1);
2933                            return true;
2934                        }
2935                        i += 3;
2936                    }
2937                    if values.len() % 3 != 0 {
2938                        // c:Src/params.c:4124-4131 — odd pair count.
2939                        crate::ported::utils::zerr(
2940                            "bad set of key/value pairs for associative array",
2941                        );
2942                        crate::ported::utils::errflag.fetch_or(
2943                            crate::ported::zsh_h::ERRFLAG_ERROR,
2944                            std::sync::atomic::Ordering::Relaxed,
2945                        );
2946                        exec.set_last_status(1);
2947                        return true;
2948                    }
2949                    // c:Src/params.c:4133-4168 arrhashsetfn with
2950                    // ASSPM_AUGMENT — ht = the EXISTING table, so
2951                    // `[k]+=v` appends to the current value
2952                    // (assignstrvalue eltflags=ASSPM_AUGMENT,
2953                    // c:4144-4150) and `[k]=v` overwrites.
2954                    for ch in values.chunks(3) {
2955                        let elt_append = ch[0].chars().nth(1) == Some('+');
2956                        let k = ch[1].clone();
2957                        let v = ch[2].clone();
2958                        let nv = if elt_append {
2959                            format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
2960                        } else {
2961                            v
2962                        };
2963                        map.insert(k, nv);
2964                    }
2965                } else {
2966                    let mut it = values.iter().cloned();
2967                    while let Some(k) = it.next() {
2968                        if let Some(v) = it.next() {
2969                            map.insert(k, v);
2970                        }
2971                    }
2972                }
2973                exec.set_assoc(name, map);
2974                return false;
2975            }
2976            // Indexed-array append `arr+=(d e f)` — route directly
2977            // through canonical assignaparam with ASSPM_AUGMENT
2978            // (`Src/params.c:3570-3585` append-on-array branch).
2979            // assignaparam reads the prior array internally and
2980            // appends the new values, so the bridge no longer needs
2981            // to pre-concat manually. Marker triples from `[k]=v`
2982            // elements add ASSPM_KEY_VALUE (c:Src/exec.c:2552-2553)
2983            // so the kv sparse-fill block (c:Src/params.c:3447-3541)
2984            // resolves them against the existing elements.
2985            let kv_flag = if values
2986                .iter()
2987                .any(|e| e.starts_with(crate::ported::zsh_h::Marker))
2988            {
2989                crate::ported::zsh_h::ASSPM_KEY_VALUE
2990            } else {
2991                0
2992            };
2993            let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
2994                & crate::ported::zsh_h::ERRFLAG_ERROR;
2995            let res = crate::ported::params::assignaparam(
2996                &name,
2997                values.clone(),
2998                crate::ported::zsh_h::ASSPM_AUGMENT | kv_flag,
2999            );
3000            let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3001                & crate::ported::zsh_h::ERRFLAG_ERROR;
3002            if res.is_none() && pre_err == 0 && now_err != 0 {
3003                // c:Src/exec.c:2632-2633 — failed assignment → lastval 1.
3004                exec.set_last_status(1);
3005                return true;
3006            }
3007            #[cfg(feature = "recorder")]
3008            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3009                let ctx = exec.recorder_ctx();
3010                let attrs = exec.recorder_attrs_for(&name);
3011                emit_path_or_assign(&name, &values, attrs, true, &ctx);
3012            }
3013            // Tied-scalar mirror — TODO faithful: should live in
3014            // setarrvalue's gsu dispatch once boot_ paramtab wiring
3015            // lands (Task #16). Re-read the canonical post-augment
3016            // array so the joined scalar matches.
3017            let tied_scalar = exec.tied_array_to_scalar.get(&name).cloned();
3018            if let Some((scalar_name, sep)) = tied_scalar {
3019                let merged = exec.array(&name).unwrap_or_default();
3020                let joined = merged.join(&sep);
3021                exec.set_scalar(scalar_name.clone(), joined.clone());
3022                let _ = crate::ported::params::zputenv(&format!("{}={}", &scalar_name, &joined));
3023                // c:Src/params.c:5354
3024            }
3025            false
3026        });
3027        // c:Src/jobs.c:1748-1757 waitonejob — `arr+=(...)` is an
3028        // array-assignment simple command and clobbers pipestats to
3029        // `[lastval]` exactly like `arr=(...)` above. Bug #373.
3030        let status = if blocked { 1 } else { 0 };
3031        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
3032        let mut synth = crate::ported::zsh_h::job::default();
3033        crate::ported::jobs::waitonejob(&mut synth);
3034        Value::Status(status)
3035    });
3036    // `name[@]=(...)` / `name[*]=(...)` — whole-array SET with the assoc
3037    // guard (c:Src/params.c:3324-3327). Stack: [v0..vn, name].
3038    vm.register_builtin(BUILTIN_SET_ARRAY_AT, |vm, argc| {
3039        let (name, values) = pop_array_args_with_name(vm, argc);
3040        let status = with_executor(|exec| {
3041            if exec.assoc(&name).is_some() {
3042                // c:Src/params.c:3324-3327 — `[@]` (any slice) on a
3043                // PM_HASHED target is an error.
3044                crate::ported::utils::zerr(&format!(
3045                    "{}: attempt to set slice of associative array",
3046                    name
3047                ));
3048                crate::ported::utils::errflag.fetch_or(
3049                    crate::ported::zsh_h::ERRFLAG_ERROR,
3050                    std::sync::atomic::Ordering::Relaxed,
3051                );
3052                exec.set_last_status(1);
3053                return 1;
3054            }
3055            exec.set_array(name, values); // whole replace (c:3528 setarrvalue)
3056            0
3057        });
3058        Value::Status(status)
3059    });
3060    // `name[@]+=(...)` / `name[*]+=(...)` — whole-array APPEND (push) with
3061    // the same assoc guard.
3062    vm.register_builtin(BUILTIN_APPEND_ARRAY_AT, |vm, argc| {
3063        let (name, values) = pop_array_args_with_name(vm, argc);
3064        let status = with_executor(|exec| {
3065            if exec.assoc(&name).is_some() {
3066                crate::ported::utils::zerr(&format!(
3067                    "{}: attempt to set slice of associative array",
3068                    name
3069                ));
3070                crate::ported::utils::errflag.fetch_or(
3071                    crate::ported::zsh_h::ERRFLAG_ERROR,
3072                    std::sync::atomic::Ordering::Relaxed,
3073                );
3074                exec.set_last_status(1);
3075                return 1;
3076            }
3077            let mut cur = exec.array(&name).unwrap_or_default();
3078            cur.extend(values);
3079            exec.set_array(name, cur); // c:3511-3528 AUGMENT on array → push
3080            0
3081        });
3082        Value::Status(status)
3083    });
3084    vm.register_builtin(BUILTIN_RUN_SELECT, |vm, argc| {
3085        if argc < 2 {
3086            return Value::Status(1);
3087        }
3088        let n = argc as usize;
3089        let mut popped: Vec<Value> = Vec::with_capacity(n);
3090        for _ in 0..n {
3091            popped.push(vm.pop());
3092        }
3093        // popped: [sub_idx, name, word_N, ..., word_1] (popping from top)
3094        let sub_idx_val = popped.remove(0);
3095        let name_val = popped.remove(0);
3096        // c:Src/loop.c — `select` flattens Array values (from `$@`,
3097        // `${arr[@]}`, etc.) into the menu. Without per-element
3098        // splice, `select x do ... done` (bare, iterating $@)
3099        // collapsed all positionals into one joined entry.
3100        let mut words: Vec<String> = Vec::new();
3101        for v in popped.into_iter().rev() {
3102            match v {
3103                Value::Array(items) => {
3104                    for item in items {
3105                        words.push(item.to_str());
3106                    }
3107                }
3108                other => words.push(other.to_str()),
3109            }
3110        }
3111
3112        let sub_idx = sub_idx_val.to_int() as usize;
3113        let name = name_val.to_str();
3114
3115        // c:Src/loop.c:248-252 — `if (!args || empty(args)) {
3116        // state->pc = end; ... return 0; }`. An empty option list
3117        // skips the body entirely; without this gate the prompt loop
3118        // runs indefinitely (or twice on the EOF stdin case before
3119        // exiting). Bug #401.
3120        if words.is_empty() {
3121            return Value::Status(0);
3122        }
3123
3124        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
3125            Some(c) => c,
3126            None => return Value::Status(1),
3127        };
3128
3129        let prompt =
3130            with_executor(|exec| exec.scalar("PROMPT3").unwrap_or_else(|| "?# ".to_string()));
3131
3132        let stdin = std::io::stdin();
3133        let mut reader = stdin.lock();
3134        let mut last_status: i32 = 0;
3135
3136        loop {
3137            // Direct port of zsh's selectlist from
3138            // src/zsh/Src/loop.c:347-409. Layout is column-major
3139            // ("down columns, then across") — NOT row-major. With
3140            // 6 items in 3 cols zsh produces:
3141            //   1  3  5
3142            //   2  4  6
3143            // The previous Rust impl walked row-major which
3144            // produced 1 2 3 / 4 5 6 (visually similar but wrong
3145            // for prompts that mention ordering and breaks scripts
3146            // that rely on column count == ceil(N/rows)).
3147            //
3148            // C variable mapping:
3149            //   ct      -> word count (n)
3150            //   longest -> max item width + 1, then plus digits-of-ct
3151            //   fct     -> column count
3152            //   fw      -> per-column width
3153            //   colsz   -> row count = ceil(ct / fct)
3154            //   t1      -> row index, walks 0..colsz
3155            //   ap      -> item pointer; advances by colsz to step
3156            //              DOWN a column.
3157            let term_width: usize = env::var("COLUMNS")
3158                .ok()
3159                .and_then(|v| v.parse().ok())
3160                .unwrap_or(80);
3161            let ct = words.len();
3162            // loop.c:354-363 — find longest item width.
3163            let mut longest = 1usize;
3164            for w in &words {
3165                let aplen = w.chars().count();
3166                if aplen > longest {
3167                    longest = aplen;
3168                }
3169            }
3170            // loop.c:365-367 — `longest++` then add digits of `ct`.
3171            longest += 1;
3172            let mut t0 = ct;
3173            while t0 > 0 {
3174                t0 /= 10;
3175                longest += 1;
3176            }
3177            // loop.c:369-373 — fct = (cols - 1) / (longest + 3); if
3178            // 0, fct = 1; else fw = (cols - 1) / fct.
3179            let raw_fct = (term_width.saturating_sub(1)) / (longest + 3);
3180            let (fct, fw) = if raw_fct == 0 {
3181                (1, longest + 3)
3182            } else {
3183                (raw_fct, (term_width.saturating_sub(1)) / raw_fct)
3184            };
3185            // loop.c:374 — colsz = (ct + fct - 1) / fct.
3186            let colsz = ct.div_ceil(fct);
3187            // loop.c:375-395 — for each row t1, walk down columns.
3188            for t1 in 0..colsz {
3189                let mut ap_idx = t1;
3190                while ap_idx < ct {
3191                    let w = &words[ap_idx];
3192                    let n = ap_idx + 1;
3193                    let _ = write!(std::io::stderr(), "{}) {}", n, w);
3194                    let mut t2 = w.chars().count() + 2;
3195                    let mut t3 = n;
3196                    while t3 > 0 {
3197                        t2 += 1;
3198                        t3 /= 10;
3199                    }
3200                    // Pad to fw (loop.c:389-390).
3201                    while t2 < fw {
3202                        let _ = write!(std::io::stderr(), " ");
3203                        t2 += 1;
3204                    }
3205                    ap_idx += colsz;
3206                }
3207                let _ = writeln!(std::io::stderr());
3208            }
3209            let _ = write!(std::io::stderr(), "{}", prompt);
3210            let _ = std::io::stderr().flush();
3211
3212            let mut line = String::new();
3213            match reader.read_line(&mut line) {
3214                Ok(0) => {
3215                    // EOF — emit the final newline that zsh prints
3216                    // after the prompt-then-EOF sequence (c:Src/loop.c
3217                    // selectlist falls through to fputc('\n', stderr)
3218                    // at the end of the read failure path). Without
3219                    // this the next process's output runs directly
3220                    // after `-->>>> ` on the same line.
3221                    let _ = writeln!(std::io::stderr());
3222                    let _ = std::io::stderr().flush();
3223                    break;
3224                }
3225                Ok(_) => {}
3226                Err(_) => break,
3227            }
3228            let trimmed = line.trim_end_matches(['\n', '\r'][..].as_ref()).to_string();
3229
3230            with_executor(|exec| {
3231                exec.set_scalar("REPLY".to_string(), trimmed.clone());
3232            });
3233
3234            if trimmed.is_empty() {
3235                // Empty input → redraw menu without running body.
3236                continue;
3237            }
3238
3239            let chosen = match trimmed.parse::<usize>() {
3240                Ok(n) if n >= 1 && n <= words.len() => words[n - 1].clone(),
3241                _ => String::new(),
3242            };
3243
3244            with_executor(|exec| {
3245                exec.set_scalar(name.clone(), chosen);
3246            });
3247
3248            // Reset canonical BREAKS/CONTFLAG before running the body
3249            // so a stale value from a sibling construct doesn't leak in.
3250            crate::ported::builtin::BREAKS.store(0, SeqCst);
3251            crate::ported::builtin::CONTFLAG.store(0, SeqCst);
3252
3253            // c:Src/loop.c — `select` increments LOOPS for the body so
3254            // `break` / `continue` inside the body see loops > 0 and
3255            // don't emit `not in while, until, select, or repeat loop`.
3256            // Mirrors execwhile/execrepeat's `LOOPS.fetch_add` pattern.
3257            // The decrement happens after the body call so a body that
3258            // explicitly returns / errors still leaves the counter
3259            // balanced for the next iteration.
3260            crate::ported::builtin::LOOPS.fetch_add(1, SeqCst);
3261
3262            crate::fusevm_disasm::maybe_print_stdout("select:body", &chunk);
3263            let mut body_vm = fusevm::VM::new(chunk.clone());
3264            register_builtins(&mut body_vm);
3265            let _ = body_vm.run();
3266            last_status = body_vm.last_status;
3267
3268            crate::ported::builtin::LOOPS.fetch_sub(1, SeqCst);
3269
3270            // Drain the canonical BREAKS/CONTFLAG counters. Mirrors
3271            // loop.c:529-534's `if (breaks) { breaks--; if (breaks ||
3272            // !contflag) break; contflag = 0; }` drain pattern.
3273            // The legacy `BREAK_SELECT=1` env-var sentinel is still
3274            // honored for backward compat.
3275            let break_legacy = with_executor(|exec| {
3276                let v = exec.scalar("BREAK_SELECT");
3277                exec.unset_scalar("BREAK_SELECT");
3278                v.map(|s| s != "0" && !s.is_empty()).unwrap_or(false)
3279            });
3280            use std::sync::atomic::Ordering::SeqCst;
3281            let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
3282            if breaks > 0 {
3283                let cont = crate::ported::builtin::CONTFLAG.load(SeqCst);
3284                crate::ported::builtin::BREAKS.fetch_sub(1, SeqCst);
3285                if breaks - 1 > 0 || cont == 0 {
3286                    break;
3287                }
3288                crate::ported::builtin::CONTFLAG.store(0, SeqCst);
3289                continue;
3290            }
3291            if break_legacy {
3292                break;
3293            }
3294        }
3295
3296        Value::Status(last_status)
3297    });
3298
3299    // Magic special-parameter assoc lookup. Synthesizes values from
3300    // shell state for zsh's shell-introspection assocs:
3301    //   commands, aliases, galiases, saliases, dis_aliases, dis_galiases,
3302    //   dis_saliases, functions, dis_functions, builtins, dis_builtins,
3303    //   reswords, options, parameters, jobtexts, jobdirs, jobstates,
3304    //   nameddirs, userdirs, modules.
3305    // Returns None if `name` isn't a recognized magic name.
3306
3307    // `${arr[idx]}` — pop name, then idx_str. zsh is 1-based for positive
3308    // indices; we honor that. `@`/`*` return the whole array as Value::Array
3309    // so Op::Exec splice produces N argv slots. For `${foo[key]}` where foo
3310    // is an assoc, the idx is a string key — we check assoc_arrays first
3311    // when the idx isn't `@`/`*` and the name has an assoc binding.
3312    // BUILTIN_ARRAY_INDEX — `${name[idx]}` paramsubst dispatch.
3313    // PURE PASSTHRU: pops the idx + name, hands the canonical
3314    // `${name[idx]}` form to `subst::paramsubst` (C port of
3315    // `Src/subst.c::paramsubst`). All subscript-flag dispatch
3316    // ((I)pat / (R)pat / (i)/(r)/(K)/(k), range slices `[N,M]`,
3317    // negative indices, magic-assoc shape lookup, DQ-join collapse)
3318    // lives inside paramsubst → fetchvalue → getarg in params.rs.
3319    //
3320    // Outer-flag dispatch (`(@)` / `(@k)` / `(v)NAME[(I)pat]` / etc.)
3321    // routes through BUILTIN_BRIDGE_BRACE_ARRAY at the compile path
3322    // (canonical paramsubst flag parser owns dispatch at Src/subst.c:2147+),
3323    // so BUILTIN_ARRAY_INDEX receives clean name+key with no sentinel
3324    // prefixes.
3325    vm.register_builtin(BUILTIN_ARRAY_INDEX, |vm, _argc| {
3326        let idx = vm.pop().to_str();
3327        let name = vm.pop().to_str();
3328        array_index_lookup(&name, &idx)
3329    });
3330    // BUILTIN_ARRAY_INDEX_UNBRACED — bare `$name[idx]` (no braces).
3331    // Same subscript dispatch as BUILTIN_ARRAY_INDEX when KSHARRAYS
3332    // is unset, but under KSHARRAYS the UNBRACED form does NOT
3333    // subscript at all:
3334    //   c:Src/subst.c:2800-2802 — fetchvalue's bracket-parse arg is
3335    //     `(unset(KSHARRAYS) || inbrace) ? 1 : -1`; -1 inhibits
3336    //     subscript parsing for the bare form under KSHARRAYS.
3337    //   c:Src/subst.c:2867 — the bracket-consuming loop only runs
3338    //     `while (v || ((inbrace || (unset(KSHARRAYS) && vunset)) &&
3339    //     isbrack(*s)))` — bare + KSHARRAYS leaves `[...]` as literal
3340    //     trailing text.
3341    // The bare `$name` expands (first element for identifier-named
3342    // arrays per c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`),
3343    // the literal `[idx]` (+ any literal suffix) joins the last word,
3344    // and the word undergoes filename generation: `[...]` is a glob
3345    // char class, so unquoted it hits the c:Src/glob.c:1873-1886
3346    // nomatch/nullglob dispatch (reused via exec.expand_glob).
3347    // Operands: [name, idx, suffix, quoted] — `quoted` set when the
3348    // word carries DQ markers (no filename generation in DQ; zsh 5.9:
3349    // `setopt ksharrays; a=(x y z); print "$a[0]"` → `x[0]`).
3350    // Verbatim zsh 5.9 ground truth for the unquoted form:
3351    //   `setopt ksharrays; a=(x y z); print -- $a[0]` →
3352    //   stderr `zsh:1: no matches found: x[0]`, rc=1, empty stdout.
3353    vm.register_builtin(BUILTIN_ARRAY_INDEX_UNBRACED, |vm, _argc| {
3354        let quoted = vm.pop().to_str() == "1";
3355        let suffix = vm.pop().to_str();
3356        let idx = vm.pop().to_str();
3357        let name = vm.pop().to_str();
3358        if !crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
3359            let v = array_index_lookup(&name, &idx);
3360            if suffix.is_empty() {
3361                return v;
3362            }
3363            // Mirrors the previous compile-shape `ARRAY_INDEX +
3364            // Op::Concat` exactly: Concat stringifies via as_str_cow
3365            // (fusevm value.rs:132-146, arrays join with " ").
3366            return Value::str(format!("{}{}", v.to_str(), suffix));
3367        }
3368        // KSHARRAYS bare form: no subscript. Bare-`$name` words +
3369        // literal `[idx]suffix` glued onto the last word.
3370        let mut words = ksharrays_bare_words(&name);
3371        let last = format!("{}[{}]{}", words.pop().unwrap_or_default(), idx, suffix);
3372        if quoted {
3373            // DQ context — no filename generation, bracket text stays
3374            // literal.
3375            words.push(last);
3376            return Value::str(words.join(" "));
3377        }
3378        // c:Src/glob.c:1873-1886 — expand_glob handles nullglob /
3379        // NOMATCH (zerr "no matches found" + errflag + the
3380        // current_command_glob_failed cell consumed at the command
3381        // dispatch boundary) / literal passthrough for glob-free text.
3382        let matches = with_executor(|exec| exec.expand_glob(&last));
3383        let mut out: Vec<Value> = words.into_iter().map(Value::str).collect();
3384        out.extend(matches.into_iter().map(Value::str));
3385        if out.len() == 1 {
3386            return out.pop().unwrap();
3387        }
3388        Value::Array(out)
3389    });
3390    // BUILTIN_ASSOC_HAS_KEY — `${(k)assoc[name]}` key-existence query.
3391    // Pops [assoc_name, key]; returns key (Str) if present in the
3392    // assoc, empty Str otherwise. Mirrors zsh's `${(k)h[name]}`
3393    // documented semantics in zshparam(1) "Parameter Expansion Flags".
3394    // Distinct from BUILTIN_ARRAY_INDEX (which returns the VALUE) and
3395    // from `${+h[name]}` (which returns "0"/"1"). Bug #145.
3396    vm.register_builtin(BUILTIN_ASSOC_HAS_KEY, |vm, _argc| {
3397        let key = vm.pop().to_str();
3398        let name = vm.pop().to_str();
3399        // c:Src/params.c getindex — the subscript text is substituted
3400        // (singsub) before the lookup; `${(k)H[$k]}` must resolve $k.
3401        // The compiler hands this opcode the RAW subscript text, so a
3402        // dynamic key arrived literally ("$k") and matched nothing.
3403        // singsub is identity for plain keys.
3404        let key = if key.contains('$') || key.contains('`') || key.contains('\u{8c}') {
3405            crate::ported::subst::singsub(&key)
3406        } else {
3407            key
3408        };
3409        // c:Src/params.c:3131 gethkparam covers ordinary PM_HASHED
3410        // paramtab entries only. Special/magic hashes (`parameters`,
3411        // `options`, … — the zsh/parameter module's partab-backed
3412        // params, Src/Modules/parameter.c) aren't in that storage, so
3413        // a None here doesn't mean "no such assoc". Route those
3414        // through paramsubst, whose assoc materialization handles the
3415        // magic hashes and whose getarg port (c:Src/params.c:1591 +
3416        // Src/subst.c:2922) returns the KEY for `(k)` on a plain
3417        // subscript. zsh 5.9: `${(k)parameters[PATH]}` → "PATH".
3418        match crate::ported::params::gethkparam(&name) {
3419            Some(keys) => {
3420                if keys.iter().any(|k| k == &key) {
3421                    Value::str(key)
3422                } else {
3423                    Value::str("")
3424                }
3425            }
3426            None => paramsubst_to_value(&format!("${{(k){}[{}]}}", name, key)),
3427        }
3428    });
3429    vm.register_builtin(BUILTIN_BRIDGE_BRACE_ARRAY, |vm, _argc| {
3430        // Inner body of `${(...)...}` (already stripped of `${`/`}` by
3431        // the caller). The compiler optionally prefixes Qstring
3432        // (\u{8c}) to signal "expanded in DQ context" — strip it
3433        // here and bump in_dq_context for the paramsubst call so the
3434        // SUB_ZIP and other qt-aware paths fire.
3435        let body = vm.pop().to_str();
3436        let (dq, inner) = if let Some(rest) = body.strip_prefix('\u{8c}') {
3437            (true, rest.to_string())
3438        } else {
3439            (false, body)
3440        };
3441        if dq {
3442            with_executor(|exec| exec.in_dq_context += 1);
3443        }
3444        let v = paramsubst_to_value(&format!("${{{}}}", inner));
3445        if dq {
3446            with_executor(|exec| exec.in_dq_context -= 1);
3447        }
3448        v
3449    });
3450
3451    // BUILTIN_PARAM_FLAG — `${(flags)name}` paramsubst dispatch.
3452    // PURE PASSTHRU: pops sentinel-tagged flags + name, hands the
3453    // canonical `${(flags)name}` form to `subst::paramsubst` (C port
3454    // of `Src/subst.c::paramsubst`). The bridge does no flag
3455    // walking, no DQ-context branching, no array/scalar shape
3456    // selection — all of that lives inside paramsubst. Compile-time
3457    // context (DQ / scalar-assign-RHS) flows through executor cells
3458    // (in_dq_context, in_scalar_assign) bumped by BUILTIN_EXPAND_TEXT.
3459    vm.register_builtin(BUILTIN_PARAM_FLAG, |vm, _argc| {
3460        let flags = vm.pop().to_str();
3461        let name = vm.pop().to_str();
3462        let body = format!("${{({}){}}}", flags, name);
3463        paramsubst_to_value(&body)
3464    });
3465
3466    // `foo[key]=val` — single-key set on an assoc array. Stack: [name, key, value].
3467    // PURE PASSTHRU: assignsparam with `name[key]` form (C port of
3468    // `Src/params.c::assignsparam` subscript path at c:3210-3231)
3469    // already does the indexed-array vs assoc decision, PM_HASHED
3470    // auto-vivification, numeric-subscript bounds handling, and
3471    // PM_READONLY rejection.
3472    vm.register_builtin(BUILTIN_SET_ASSOC, |vm, _argc| {
3473        // `${~spec}` carrier: an assignment statement is a word-
3474        // pipeline boundary too — restore the user's GLOB_SUBST
3475        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
3476        // ${options[globsubst]}` must read the user value).
3477        consume_tilde_globsubst_carrier();
3478        // argc 4 = compile flagged the subscript as DYNAMIC (`H[$k]`):
3479        // an EXPANDED-empty key is then a legal assoc key (C's
3480        // assignsparam isident gate sees the raw `$k` text and the
3481        // empty key stores at getindex time — zinit's
3482        // ZINIT_SICE[$1…$2] relies on it). argc 3 = source-literal
3483        // key; `H[]` stays the "not an identifier" error.
3484        let key_is_dynamic = if _argc == 4 {
3485            vm.pop().to_int() != 0
3486        } else {
3487            false
3488        };
3489        let value = vm.pop().to_str();
3490        let key = vm.pop().to_str();
3491        let name = vm.pop().to_str();
3492        if key_is_dynamic && key.is_empty() {
3493            with_executor(|exec| {
3494                let _ = exec;
3495            });
3496            // Mirror assignsparam's PM_HASHED tail directly (the
3497            // textual `name[]` reconstruction can't pass isident).
3498            if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
3499                let entry = store.entry(name.clone()).or_default();
3500                let newval = if let Some(old) = entry.get("") {
3501                    // `+=` arrives pre-concatenated by the compile
3502                    // read-modify-write; plain `=` overwrites.
3503                    let _ = old;
3504                    value.clone()
3505                } else {
3506                    value.clone()
3507                };
3508                entry.insert(String::new(), newval);
3509            }
3510            return Value::Status(0);
3511        }
3512        with_executor(|exec| {
3513            #[cfg(feature = "recorder")]
3514            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3515                let ctx = exec.recorder_ctx();
3516                let attrs = exec.recorder_attrs_for(&name);
3517                crate::recorder::emit_assoc_assign(
3518                    &name,
3519                    vec![(key.clone(), value.clone())],
3520                    attrs,
3521                    true,
3522                    ctx,
3523                );
3524            }
3525            let _ = exec;
3526        });
3527        // Build `name[key]=value` shape for assignsparam's subscript
3528        // dispatch. Arith-evaluate numeric subscripts on an existing
3529        // indexed array (`a[i+1]=v` form) before handing off — the
3530        // canonical port currently only handles literal int / string
3531        // keys, so pre-resolve here.
3532        let resolved_key = with_executor(|exec| {
3533            let is_indexed = exec.array(&name).is_some();
3534            let is_assoc = exec.assoc(&name).is_some();
3535            let is_scalar = !is_indexed && !is_assoc && exec.scalar(&name).is_some();
3536            // c:Src/params.c::getindex — `(i)pat` / `(I)pat` / `(R)pat`
3537            // / `(r)pat` subscript flags on an indexed array LHS resolve
3538            // to a numeric index (first / last match of pat). On a
3539            // SCALAR LHS the same flags resolve to a CHAR position
3540            // (1-based first/last match of pat in the scalar string)
3541            // for the c:2748+ char-splice assignment. zshrs's
3542            // read-form `${a[(i)pat]}` already implements both shapes;
3543            // the LHS assignment path silently stored the literal
3544            // "(i)pat" as an assoc key (for scalar: auto-vivified to
3545            // PM_HASHED via the assignsparam unknown-subscript
3546            // fallback). Bug #293 (array) / scalar sibling.
3547            //
3548            // Detect the `(flags)pat` shape and resolve to a numeric
3549            // index before assignsparam.
3550            if is_indexed || is_scalar {
3551                if let Some(rest) = key.strip_prefix('(') {
3552                    if let Some(close) = rest.find(')') {
3553                        let flags = &rest[..close];
3554                        let pat = &rest[close + 1..];
3555                        if !flags.is_empty()
3556                            && flags
3557                                .chars()
3558                                .all(|c| matches!(c, 'I' | 'R' | 'i' | 'r' | 'n' | 'e'))
3559                        {
3560                            // Resolve via the array's contents.
3561                            if let Some(arr) = exec.array(&name) {
3562                                let return_index = true; // LHS write — index needed
3563                                let down = flags.contains('I') || flags.contains('R');
3564                                let exact = flags.contains('e');
3565                                let iter: Box<dyn Iterator<Item = (usize, &String)>> = if down {
3566                                    Box::new(arr.iter().enumerate().rev())
3567                                } else {
3568                                    Box::new(arr.iter().enumerate())
3569                                };
3570                                let mut found: Option<usize> = None;
3571                                for (idx, elem) in iter {
3572                                    let matched = if exact {
3573                                        elem == pat
3574                                    } else {
3575                                        crate::ported::pattern::patcompile(
3576                                            &{
3577                                                let mut __pat_tok = (pat).to_string();
3578                                                crate::ported::glob::tokenize(&mut __pat_tok);
3579                                                __pat_tok
3580                                            },
3581                                            crate::ported::zsh_h::PAT_HEAPDUP as i32,
3582                                            None,
3583                                        )
3584                                        .map_or(false, |p| crate::ported::pattern::pattry(&p, elem))
3585                                    };
3586                                    if matched {
3587                                        found = Some(idx);
3588                                        break;
3589                                    }
3590                                }
3591                                let _ = return_index;
3592                                // (i)/(r) return 1-based index of match,
3593                                // arr.len()+1 (or 1 for I/R) on miss
3594                                // per zsh docs. We mirror the read-form
3595                                // semantics from subst.rs.
3596                                let idx_1based = match found {
3597                                    Some(i) => (i + 1) as i64,
3598                                    None => (arr.len() + 1) as i64,
3599                                };
3600                                return idx_1based.to_string();
3601                            }
3602                            // Scalar LHS — resolve to a CHAR position
3603                            // (1-based first/last match of pat in the
3604                            // string). c:Src/params.c:1411-1418 — the
3605                            // scalar path returns the char index from
3606                            // sliding-window pattern match against
3607                            // pm.u_str. Same algorithm as the read-form
3608                            // at subst.rs:5283-5306. Bug (scalar
3609                            // sibling of #293): `a=hello; a[(I)l]=X`
3610                            // previously auto-vivified `a` into
3611                            // PM_HASHED with key "(I)l" instead of
3612                            // splicing X at the last 'l' position
3613                            // (yielding "helXo").
3614                            if is_scalar {
3615                                let s = exec.scalar(&name).unwrap_or_default();
3616                                let s_chars: Vec<char> = s.chars().collect();
3617                                let n = s_chars.len();
3618                                let want_last = flags.contains('I') || flags.contains('R');
3619                                let exact = flags.contains('e');
3620                                let mut found: Option<usize> = None;
3621                                'outer: for start in 0..=n {
3622                                    let lengths: Box<dyn Iterator<Item = usize>> = if want_last {
3623                                        Box::new((1..=(n - start)).rev())
3624                                    } else {
3625                                        Box::new(1..=(n - start))
3626                                    };
3627                                    for len in lengths {
3628                                        let cand: String =
3629                                            s_chars[start..start + len].iter().collect();
3630                                        let matched = if exact {
3631                                            cand == pat
3632                                        } else {
3633                                            crate::ported::pattern::patcompile(
3634                                                &{
3635                                                    let mut __pat_tok = (pat).to_string();
3636                                                    crate::ported::glob::tokenize(&mut __pat_tok);
3637                                                    __pat_tok
3638                                                },
3639                                                crate::ported::zsh_h::PAT_HEAPDUP as i32,
3640                                                None,
3641                                            )
3642                                            .map_or(false, |p| {
3643                                                crate::ported::pattern::pattry(&p, &cand)
3644                                            })
3645                                        };
3646                                        if matched {
3647                                            found = Some(start);
3648                                            if !want_last {
3649                                                break 'outer;
3650                                            }
3651                                            break;
3652                                        }
3653                                    }
3654                                }
3655                                // (I)/(R): scan again to find LAST.
3656                                if want_last {
3657                                    let mut last_found: Option<usize> = found;
3658                                    for start in (0..=n).rev() {
3659                                        for len in 1..=(n - start) {
3660                                            let cand: String =
3661                                                s_chars[start..start + len].iter().collect();
3662                                            let matched = if exact {
3663                                                cand == pat
3664                                            } else {
3665                                                crate::ported::pattern::patcompile(
3666                                                    &{
3667                                                        let mut __pat_tok = (pat).to_string();
3668                                                        crate::ported::glob::tokenize(
3669                                                            &mut __pat_tok,
3670                                                        );
3671                                                        __pat_tok
3672                                                    },
3673                                                    crate::ported::zsh_h::PAT_HEAPDUP as i32,
3674                                                    None,
3675                                                )
3676                                                .map_or(false, |p| {
3677                                                    crate::ported::pattern::pattry(&p, &cand)
3678                                                })
3679                                            };
3680                                            if matched {
3681                                                last_found = Some(start);
3682                                                break;
3683                                            }
3684                                        }
3685                                        if last_found.is_some() && last_found.unwrap() >= start {
3686                                            break;
3687                                        }
3688                                    }
3689                                    found = last_found;
3690                                }
3691                                let idx_1based = match found {
3692                                    Some(i) => (i + 1) as i64,
3693                                    // (i) miss → len+1 (one past end).
3694                                    None => (n + 1) as i64,
3695                                };
3696                                return idx_1based.to_string();
3697                            }
3698                        }
3699                    }
3700                }
3701            }
3702            if is_indexed && key.trim().parse::<i64>().is_err() {
3703                crate::ported::math::mathevali(&crate::ported::subst::singsub(&key))
3704                    .map(|n| n.to_string())
3705                    .unwrap_or(key.clone())
3706            } else {
3707                key.clone()
3708            }
3709        });
3710        let subscripted = format!("{}[{}]", name, resolved_key);
3711        crate::ported::params::assignsparam(&subscripted, &value, crate::ported::zsh_h::ASSPM_WARN);
3712        Value::Status(0)
3713    });
3714
3715    // Brace expansion. Routes through executor.xpandbraces (already
3716    // implemented for the pre-fusevm executor). Returns Value::Array.
3717    // BUILTIN_ARRAY_DROP_EMPTY — filter out empty Value::Str entries
3718    // from a Value::Array on the stack. Used by `for x in $@` /
3719    // `for x in $*` unquoted forms which drop empty positionals
3720    // (POSIX-like) but do NOT IFS-split each element internally
3721    // (zsh-specific — scalar word splitting is off by default).
3722    // Distinct from BUILTIN_WORD_SPLIT which routes through
3723    // multsub PREFORK_SPLIT (full IFS-split). Bug #166.
3724    vm.register_builtin(BUILTIN_ARRAY_DROP_EMPTY, |vm, _argc| {
3725        let v = vm.pop();
3726        match v {
3727            Value::Array(items) => {
3728                let filtered: Vec<Value> = items
3729                    .into_iter()
3730                    .filter(|x| !x.to_str().is_empty())
3731                    .collect();
3732                Value::Array(filtered)
3733            }
3734            Value::Str(s) if s.is_empty() => Value::Array(Vec::new()),
3735            other => other,
3736        }
3737    });
3738
3739    // BUILTIN_QUOTEDZPUTS — re-wrap top-of-stack scalar via the
3740    // canonical quotedzputs (Src/utils.c:6464). Non-printable bytes
3741    // come back as `$'…'` C-string form so the cond xtrace prefix
3742    // line preserves the source-quoting form for `[[ -n $'\C-[OP' ]]`
3743    // instead of leaking raw ESC + "OP" bytes through the terminal.
3744    vm.register_builtin(BUILTIN_QUOTEDZPUTS, |vm, _argc| {
3745        let s = vm.pop().to_str();
3746        Value::str(crate::ported::utils::quotedzputs(&s))
3747    });
3748
3749    // BUILTIN_QUOTE_TOKENIZED_OUTPUT — char-aware mirror of
3750    // c:Src/exec.c:2114 `quote_tokenized_output`. The canonical
3751    // port at exec::quote_tokenized_output operates on bytes
3752    // (zsh's metafied encoding); zshrs strings are UTF-8 so
3753    // `\u{87}` Star is `[0xC2, 0x87]`, and a byte walk writes
3754    // 0xC2 raw (invalid UTF-8 lead → U+FFFD on lossy decode).
3755    // Walk by char and dispatch the same switch the byte port
3756    // uses, but with the token chars matching the UTF-8 form.
3757    vm.register_builtin(BUILTIN_QUOTE_TOKENIZED_OUTPUT, |vm, _argc| {
3758        let s = vm.pop().to_str();
3759        let mut out = String::with_capacity(s.len());
3760        let chars: Vec<char> = s.chars().collect();
3761        let mut i = 0;
3762        while i < chars.len() {
3763            let c = chars[i];
3764            // c:2120 — Meta-quoted byte: emit `*++s ^ 32`.
3765            // In UTF-8 strings Meta is `\u{83}`; the next char is
3766            // the metafied payload.
3767            if c == '\u{83}' {
3768                if let Some(&n) = chars.get(i + 1) {
3769                    if (n as u32) < 0x80 {
3770                        out.push(((n as u8) ^ 32) as char);
3771                    } else {
3772                        out.push(n);
3773                    }
3774                    i += 2;
3775                    continue;
3776                }
3777                i += 1;
3778                continue;
3779            }
3780            // c:2124 — Nularg: skip.
3781            if c == '\u{a1}' {
3782                i += 1;
3783                continue;
3784            }
3785            // c:2128-2143 — ASCII specials get backslash-prefixed
3786            // then fall through to emit the literal char.
3787            match c {
3788                '\\' | '<' | '>' | '(' | '|' | ')' | '^' | '#' | '~' | '[' | ']' | '*' | '?'
3789                | '$' | ' ' => {
3790                    out.push('\\');
3791                    out.push(c);
3792                    i += 1;
3793                    continue;
3794                }
3795                '\t' => {
3796                    out.push_str("$'\\t'");
3797                    i += 1;
3798                    continue;
3799                }
3800                '\n' => {
3801                    out.push_str("$'\\n'");
3802                    i += 1;
3803                    continue;
3804                }
3805                '\r' => {
3806                    out.push_str("$'\\r'");
3807                    i += 1;
3808                    continue;
3809                }
3810                '=' => {
3811                    if i == 0 {
3812                        out.push('\\');
3813                    }
3814                    out.push(c);
3815                    i += 1;
3816                    continue;
3817                }
3818                _ => {}
3819            }
3820            // c:2163 — `if (itok(*s)) putc(ztokens[*s - Pound]);`
3821            // Map zsh token chars (`\u{84}`..`\u{a1}` range, the
3822            // ones the lexer emits for `#$^*()…`) back to their
3823            // source ASCII via the `ztokens` table.
3824            let cp = c as u32;
3825            if (0x84..=0xa1).contains(&cp) {
3826                let idx = (cp - 0x84) as usize;
3827                let ztokens = crate::ported::lex::ztokens.as_bytes();
3828                if idx < ztokens.len() {
3829                    out.push(ztokens[idx] as char);
3830                    i += 1;
3831                    continue;
3832                }
3833            }
3834            out.push(c);
3835            i += 1;
3836        }
3837        Value::str(out)
3838    });
3839
3840    // BUILTIN_WORD_SPLIT — `${=var}` IFS-split runtime.
3841    // PURE PASSTHRU: route through canonical `subst::multsub` with
3842    // PREFORK_SPLIT flag (C port of `Src/subst.c::multsub` at c:544
3843    // — the IFS-split walker with whitespace-vs-non-whitespace
3844    // gating, quote-aware parsing, and empty-field handling).
3845    vm.register_builtin(BUILTIN_WORD_SPLIT, |vm, _argc| {
3846        let s = vm.pop().to_str();
3847        let (_joined, parts, _isarr, _flags) =
3848            crate::ported::subst::multsub(&s, crate::ported::zsh_h::PREFORK_SPLIT);
3849        // Empty single-string special case → empty Array (drop empty arg).
3850        if parts.len() == 1 && parts[0].is_empty() {
3851            return Value::Array(Vec::new());
3852        }
3853        nodes_to_value(parts)
3854    });
3855
3856    vm.register_builtin(BUILTIN_BRACE_EXPAND, |vm, _argc| {
3857        // c:Src/glob.c::xpandbraces — brace expansion runs per word.
3858        // When the upstream produced an array (e.g. `${a:e}` splat),
3859        // expand braces on each element separately so the splat
3860        // survives. `pop().to_str()` would join with space and lose
3861        // the array shape. Parity bug #28 cousin: the BRACE_EXPAND
3862        // emit always fires for any word containing `{` (including
3863        // `${...}` param-expansion braces), so its collapse hit even
3864        // pure-paramsubst args.
3865        let raw = vm.pop();
3866        // c:Src/options.c — `no_brace_expand` (negated braceexpand)
3867        // disables brace expansion entirely. When set, `{a,b}` stays
3868        // literal. Mirror by short-circuiting xpandbraces; pass the
3869        // input through unchanged.
3870        let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
3871        let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
3872        let inputs: Vec<String> = match raw {
3873            Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
3874            other => vec![other.to_str()],
3875        };
3876        if !brace_expand {
3877            return nodes_to_value(inputs);
3878        }
3879        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
3880        for s in inputs {
3881            for w in crate::ported::glob::xpandbraces(&s, brace_ccl) {
3882                out.push(w);
3883            }
3884        }
3885        nodes_to_value(out)
3886    });
3887
3888    // `*(qual)` glob qualifier filter. Stack: [pattern, qualifier].
3889    // Pattern is glob-expanded normally, then each result is filtered by the
3890    // qualifier predicate. Common qualifiers:
3891    //   .  — regular files only
3892    //   /  — directories only
3893    //   @  — symlinks
3894    //   x  — executable
3895    //   r/w/x — readable/writable/executable
3896    //   N  — nullglob (no error if no match)
3897    //   L+N / L-N — size > N / size < N (in bytes)
3898    //   mh-N / mh+N — modified within N hours / older than N hours
3899    //   md-N / md+N — modified within N days / older than N days
3900    //   on/On — sort by name asc/desc (default)
3901    //   oL/OL — sort by length
3902    //   om/Om — sort by mtime
3903    // Pop a scalar pattern, run expand_glob, push Value::Array. Used
3904    // by the segment-concat compile path for `$D/*`-style words.
3905    vm.register_builtin(BUILTIN_GLOB_EXPAND, |vm, _argc| {
3906        // c:Src/glob.c:1872 — honour `setopt noglob` / `noglob CMD`
3907        // precommand. When the option is on, the word stays literal
3908        // (zsh skips the glob expansion entirely). Without this, the
3909        // segment-fast-path BUILTIN_GLOB_EXPAND fired even after
3910        // `noglob` set the option, so `noglob echo *.xyz` saw the
3911        // NOMATCH error instead of the literal pass-through.
3912        let raw = vm.pop();
3913        let noglob =
3914            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
3915        glob_expand_word_value(raw, noglob)
3916    });
3917    // Redirect-target variant of BUILTIN_GLOB_EXPAND. c:Src/glob.c:
3918    // 2161-2167 xpandredir — `prefork(&fake, isset(MULTIOS) ? 0 :
3919    // PREFORK_SINGLE, NULL)` then "Globbing is only done for
3920    // multios.": a redirect target word is only globbed when the
3921    // MULTIOS option is set. With it unset, `echo hi > *.txt`
3922    // creates the literal file `*.txt`, and `wc -c < *.txt` errors
3923    // "no such file or directory: *.txt". Bug #36 follow-up in
3924    // docs/BUGS.md.
3925    vm.register_builtin(BUILTIN_REDIR_GLOB_EXPAND, |vm, _argc| {
3926        let raw = vm.pop();
3927        let noglob =
3928            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
3929        let multios = opt_state_get("multios").unwrap_or(true);
3930        glob_expand_word_value(raw, noglob || !multios)
3931    });
3932    // Clear the default-word glob-pending carrier before the word's
3933    // expansion runs, so a flag set by a prior word never leaks in.
3934    vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB_RESET, |_vm, _argc| {
3935        crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| c.set(false));
3936        Value::Status(0)
3937    });
3938    // After the word is assembled, run filename generation ONLY if the
3939    // default/alternate paramsubst arm flagged a source-glob default
3940    // (DEFAULT_WORD_GLOB_PENDING). Otherwise pass the word through
3941    // literally — a parameter VALUE must not glob. c:Src/subst.c globlist.
3942    vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB, |vm, _argc| {
3943        let raw = vm.pop();
3944        let pending = crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| {
3945            let v = c.get();
3946            c.set(false); // read + clear
3947            v
3948        });
3949        if !pending {
3950            return raw;
3951        }
3952        let noglob =
3953            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
3954        glob_expand_word_value(raw, noglob)
3955    });
3956
3957    // `break`/`continue` from a sub-VM body. The compile path emits
3958    // these when the keyword appears at chunk top-level (no enclosing
3959    // for/while in the current chunk's patch lists). Outer-loop
3960    // builtins (BUILTIN_RUN_SELECT and any future loop-via-builtin
3961    // construct) drain canonical BREAKS/CONTFLAG after each iteration.
3962    //
3963    // Writes match `bin_break`'s c:5836+ pattern:
3964    //   continue: contflag = 1; breaks++   (Src/builtin.c::bin_break)
3965    //   break:    breaks++
3966    vm.register_builtin(BUILTIN_SET_BREAK, |_vm, _argc| {
3967        use std::sync::atomic::Ordering::SeqCst;
3968        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
3969        Value::Status(0)
3970    });
3971    vm.register_builtin(BUILTIN_SET_CONTINUE, |_vm, _argc| {
3972        use std::sync::atomic::Ordering::SeqCst;
3973        crate::ported::builtin::CONTFLAG.store(1, SeqCst);
3974        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
3975        Value::Status(0)
3976    });
3977
3978    // `${arr[*]}` — join array elements with the first IFS char into
3979    // a single string. Matches zsh: in DQ context this preserves the
3980    // join; in array context too the result is one Value::Str.
3981    // Set or clear a shell option directly. Used by `noglob CMD ...`
3982    // precommand wrapping — the compiler emits SET_RAW_OPT to flip the
3983    // option ON before compiling the inner words and OFF after, so glob
3984    // expansion of the inner args sees the temporary state.
3985    vm.register_builtin(BUILTIN_SET_RAW_OPT, |vm, _argc| {
3986        let on = vm.pop().to_int() != 0;
3987        let opt = vm.pop().to_str();
3988        // Pure passthru: canonical port lives in
3989        // src/ported/options.rs::opt_state_set_via_alias and
3990        // handles negation-alias resolution per c:Src/options.c.
3991        crate::ported::options::opt_state_set_via_alias(&opt, on);
3992        Value::Status(0)
3993    });
3994
3995    // c:Src/options.c GLOB_SUBST — runtime glob expansion of
3996    // substituted words. Pop a Value (Str or Array); when
3997    // GLOB_SUBST is ON, run expand_glob on each string element;
3998    // when OFF, pass through unchanged. Bug #119 in docs/BUGS.md.
3999    vm.register_builtin(BUILTIN_GLOB_SUBST_EXPAND, |vm, _argc| {
4000        let raw = vm.pop();
4001        let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
4002        if !glob_subst {
4003            return raw;
4004        }
4005        // Collect input strings (Str → vec![s]; Array → multiple).
4006        let inputs: Vec<String> = match raw {
4007            Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
4008            other => vec![other.to_str()],
4009        };
4010        // Run expand_glob on each. Empty matches collapse to a
4011        // single literal pass-through to mirror nullglob-off default.
4012        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
4013        for pattern in inputs {
4014            // c:Src/subst.c — GLOB_SUBST subjects the value to the FULL
4015            // filename-generation pipeline: `filesub` (tilde/`=` expansion)
4016            // BEFORE globbing (prefork runs filesub then globlist). zshrs
4017            // globbed but skipped filesub, so `${~x}` / `setopt globsubst`
4018            // left `~/foo` un-expanded. filesubstr matches the Tilde TOKEN,
4019            // so shtokenize the value first (`~`→Tilde, glob metas active),
4020            // run filesub, then untokenize the surviving glob metas back to
4021            // raw for expand_glob (which re-tokenizes internally).
4022            let pattern = if pattern.contains('~') || pattern.contains('=') {
4023                let mut tok = pattern.clone();
4024                crate::ported::glob::shtokenize(&mut tok);
4025                let fs = crate::ported::subst::filesub(&tok, 0);
4026                crate::ported::lex::untokenize(&fs)
4027            } else {
4028                pattern
4029            };
4030            let matches = with_executor(|exec| exec.expand_glob(&pattern));
4031            if matches.is_empty() {
4032                // No match: keep the literal (like nullglob off).
4033                out.push(pattern);
4034            } else {
4035                for m in matches {
4036                    out.push(m);
4037                }
4038            }
4039        }
4040        if out.len() == 1 {
4041            Value::str(out.into_iter().next().unwrap())
4042        } else {
4043            Value::Array(out.into_iter().map(Value::str).collect())
4044        }
4045    });
4046
4047    // c:Src/math.c:337 — `getmathparam` for ArithCompiler pre-load.
4048    // Pop a variable name, return its math-coerced value. Mirrors
4049    // the routing in math::getmathparam: try i64, then f64, then
4050    // recursive arith-eval, else 0. Bug #118 in docs/BUGS.md.
4051    vm.register_builtin(BUILTIN_GET_MATH_VAR, |vm, _argc| {
4052        let name = vm.pop().to_str();
4053        let raw = crate::ported::params::getsparam(&name).unwrap_or_default();
4054        // Empty / unset → 0.
4055        if raw.is_empty() {
4056            return Value::Int(0);
4057        }
4058        // Direct int / float parse.
4059        if let Ok(n) = raw.parse::<i64>() {
4060            return Value::Int(n);
4061        }
4062        if let Ok(f) = raw.parse::<f64>() {
4063            return Value::Float(f);
4064        }
4065        // Recursive arith eval (matches getmathparam fallback at
4066        // Src/math.c:337). If that fails too, return 0 — C's
4067        // mathevall returns 0 with errflag set on parse failure.
4068        match crate::ported::math::mathevali(&raw) {
4069            Ok(n) => Value::Int(n),
4070            Err(_) => Value::Int(0),
4071        }
4072    });
4073
4074    // c:Src/options.c GLOB_SUBST + Src/cond.c:552 cond_match.
4075    // Pop pattern string; when GLOB_SUBST is OFF, escape every glob
4076    // metachar with `\` so the downstream StrMatch + patcompile
4077    // treat them as literals (matching C's tokenization-based
4078    // gate). When GLOB_SUBST is ON, pass through unchanged.
4079    // See BUILTIN_GLOB_SUBST_GUARD docs above for full rationale.
4080    vm.register_builtin(BUILTIN_GLOB_SUBST_GUARD, |vm, _argc| {
4081        let p = vm.pop().to_str();
4082        let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
4083        if glob_subst {
4084            return Value::str(p);
4085        }
4086        let mut out = String::with_capacity(p.len() * 2);
4087        for c in p.chars() {
4088            match c {
4089                '*' | '?' | '[' | ']' | '(' | ')' | '|' | '<' | '>' | '#' | '^' | '~' | '\\' => {
4090                    out.push('\\');
4091                    out.push(c);
4092                }
4093                _ => out.push(c),
4094            }
4095        }
4096        Value::str(out)
4097    });
4098
4099    vm.register_builtin(BUILTIN_ARRAY_JOIN_STAR, |vm, _argc| {
4100        let name = vm.pop().to_str();
4101        let (joined, ifs_full, in_dq) = with_executor(|exec| {
4102            // c:Src/params.c — `"$*"` joins by IFS[0]. zsh
4103            // distinguishes IFS=unset (→ default `" "`) from
4104            // IFS="" (→ EMPTY separator → fields concatenate).
4105            // chars().next() collapsed both into the default, so
4106            // IFS="" was treated as IFS=" ".
4107            let ifs_full = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
4108            let sep = ifs_full
4109                .chars()
4110                .next()
4111                .map(|c| c.to_string())
4112                .unwrap_or_default();
4113            let in_dq = exec.in_dq_context > 0;
4114            let joined = if name == "@" || name == "*" || name == "argv" {
4115                exec.pparams().join(&sep)
4116            } else if let Some(assoc_map) = exec.assoc(&name) {
4117                // c:Src/params.c — assoc-splat values for
4118                // `"${h[@]}"` / `"${h[*]}"`. Bug #109 in
4119                // docs/BUGS.md.
4120                assoc_map.values().cloned().collect::<Vec<_>>().join(&sep)
4121            } else if let Some(arr) = exec.array(&name) {
4122                arr.join(&sep)
4123            } else {
4124                exec.get_variable(&name)
4125            };
4126            (joined, ifs_full, in_dq)
4127        });
4128        // c:Src/subst.c — UNQUOTED `${name[*]}` (or `$*`) goes
4129        // through the canonical "join via IFS[0], then word-split
4130        // via IFS" pipeline. The fast-path bypassed paramsubst
4131        // entirely so it never word-split, producing one joined
4132        // string instead of N argv entries. Bug #428.
4133        //
4134        // In QUOTED (`"${name[*]}"`) context, the result IS a
4135        // single scalar — return it as Str without splitting.
4136        if in_dq {
4137            return Value::str(joined);
4138        }
4139        if joined.is_empty() {
4140            return Value::Array(Vec::new());
4141        }
4142        // IFS word-split — every IFS char is a separator. Empty
4143        // resulting fields are dropped (the canonical
4144        // "remove empty unquoted words" pass from
4145        // Src/subst.c::prefork c:184-187).
4146        let parts: Vec<String> = joined
4147            .split(|c: char| ifs_full.contains(c))
4148            .filter(|s| !s.is_empty())
4149            .map(String::from)
4150            .collect();
4151        if parts.is_empty() {
4152            Value::Array(Vec::new())
4153        } else if parts.len() == 1 {
4154            Value::str(parts.into_iter().next().unwrap())
4155        } else {
4156            Value::Array(parts.into_iter().map(Value::str).collect())
4157        }
4158    });
4159
4160    vm.register_builtin(BUILTIN_ARRAY_ALL, |vm, _argc| {
4161        let name = vm.pop().to_str();
4162        with_executor(|exec| {
4163            // Special positional names — splice the positional list.
4164            if name == "@" || name == "*" || name == "argv" {
4165                return Value::Array(exec.pparams().iter().map(Value::str).collect());
4166            }
4167            // c:Src/Modules/parameter.c — funcstack/funcfiletrace/
4168            // funcsourcetrace/functrace are PM_ARRAY|PM_READONLY
4169            // specials backed by the canonical FUNCSTACK Vec.
4170            // `${funcstack[@]}` inside a function call should splat
4171            // the innermost-first names; without this branch the
4172            // runtime fell to the scalar fallback (get_variable
4173            // returns empty for these specials) and `[@]` came out
4174            // empty. Bug #276 in docs/BUGS.md. Mirrors the parallel
4175            // arrays_get handler at src/ported/subst.rs ~10685.
4176            // c:Src/Modules/datetime.c:256 — `epochtime` PM_ARRAY|
4177            // PM_READONLY backed by getcurrenttime(). Same parallel
4178            // arrangement as the FUNCSTACK-backed specials below.
4179            if name == "epochtime" {
4180                let arr = crate::ported::modules::datetime::getcurrenttime();
4181                return Value::Array(arr.into_iter().map(Value::str).collect());
4182            }
4183            if matches!(
4184                name.as_str(),
4185                "funcstack" | "funcfiletrace" | "funcsourcetrace" | "functrace"
4186            ) {
4187                // Route the three trace arrays through the canonical
4188                // ported getfns (Src/Modules/parameter.c:648/:679/:711)
4189                // — the previous inline copy emitted wrong shapes
4190                // (bare filename for funcfiletrace, `name:lineno` for
4191                // functrace instead of `caller:lineno`); same dedup as
4192                // the parallel arrays_get handler in subst.rs.
4193                let vals: Vec<String> = match name.as_str() {
4194                    "funcstack" => crate::ported::modules::parameter::FUNCSTACK
4195                        .lock()
4196                        .map(|f| f.iter().rev().map(|fs| fs.name.clone()).collect())
4197                        .unwrap_or_default(),
4198                    "funcfiletrace" => crate::ported::modules::parameter::funcfiletracegetfn(
4199                        std::ptr::null_mut(),
4200                    ),
4201                    "funcsourcetrace" => crate::ported::modules::parameter::funcsourcetracegetfn(
4202                        std::ptr::null_mut(),
4203                    ),
4204                    _ => crate::ported::modules::parameter::functracegetfn(std::ptr::null_mut()),
4205                };
4206                return Value::Array(vals.into_iter().map(Value::str).collect());
4207            }
4208            // c:Src/params.c — `${assoc[@]}` enumerates VALUES (per
4209            // params.c:1696-1750 hashparam splat). Check assoc
4210            // storage BEFORE the scalar fallback so an associative
4211            // array named X resolves `${X[@]}` to the values, not
4212            // empty. Bug #109 in docs/BUGS.md: `${h[@]}` on an
4213            // assoc routed through BUILTIN_ARRAY_ALL, which only
4214            // consulted `exec.array(name)` (the indexed-array map)
4215            // — that lookup missed for assocs, fell through to
4216            // `get_variable("h")` (also empty for an assoc-only
4217            // name), and returned `Array(vec![])`. zsh's expected
4218            // behavior is to enumerate values.
4219            if let Some(assoc_map) = exec.assoc(&name) {
4220                return Value::Array(
4221                    assoc_map.values().cloned().map(Value::str).collect(),
4222                );
4223            }
4224            match exec.array(&name) {
4225                Some(v) => Value::Array(v.iter().map(Value::str).collect()),
4226                None => {
4227                    // Fall back to scalar lookup. zsh (unlike bash)
4228                    // does NOT IFS-split a scalar variable in a for
4229                    // list — `for w in $scalar` iterates ONCE with the
4230                    // scalar value. Word-splitting requires either
4231                    // sh_word_split option or explicit `${(s.,.)scalar}`.
4232                    let val = exec.get_variable(&name);
4233                    if val.is_empty() && !exec.has_scalar(&name) && env::var(&name).is_err() {
4234                        // c:Src/subst.c:3480-3485 — `${arr[@]}` on a genuinely
4235                        // UNSET parameter under NO_UNSET is a "parameter not set"
4236                        // error (vunset > 0 && unset(UNSET)), exactly like the
4237                        // scalar `$arr`, the `${arr[*]}` splat, and `${arr[1]}`
4238                        // — all of which already fire it via GET_VAR. The `[@]`
4239                        // splat path returned an empty array silently, so
4240                        // `setopt NO_UNSET; print "${arr[@]}"` exited 0 where zsh
4241                        // exits 1. A DECLARED-but-empty array (`arr=()`) resolves
4242                        // to `Some(vec![])` above and never reaches here, so it
4243                        // still splats to nothing without erroring — matching zsh.
4244                        if opt_state_get("nounset").unwrap_or(false) {
4245                            crate::ported::utils::zerr(&format!("{}: parameter not set", name));
4246                            crate::ported::utils::errflag.fetch_or(
4247                                crate::ported::zsh_h::ERRFLAG_ERROR,
4248                                std::sync::atomic::Ordering::Relaxed,
4249                            );
4250                            exec.set_last_status(1);
4251                        }
4252                        Value::Array(vec![])
4253                    } else if opt_state_get("shwordsplit").unwrap_or(false) {
4254                        // bash-compat: under setopt sh_word_split, do
4255                        // split scalars on IFS chars.
4256                        let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
4257                        let parts: Vec<Value> = val
4258                            .split(|c: char| ifs.contains(c))
4259                            .filter(|s| !s.is_empty())
4260                            .map(Value::str)
4261                            .collect();
4262                        Value::Array(parts)
4263                    } else {
4264                        Value::Array(vec![Value::str(val)])
4265                    }
4266                }
4267            }
4268        })
4269    });
4270
4271    // BUILTIN_ARRAY_FLATTEN(N): pops N values, flattens one level of Array
4272    // nesting, pushes the resulting Array AND its length as a separate Int.
4273    // The two-value return shape lets the caller (for-loop compile path)
4274    // SetSlot the length before SetSlot'ing the array, without re-deriving
4275    // the length from the array via a second builtin call.
4276    // `coproc [name] { body }` — bidirectional pipe to backgrounded body.
4277    // Stack discipline (top first): [name (str, "" for default), sub_idx (int)].
4278    // On success: parent's `executor.arrays[name]` becomes [write_fd, read_fd]
4279    // and Status(0) is returned. The caller writes to the child's stdin via
4280    // write_fd, reads its stdout via read_fd, and closes both when done.
4281    //
4282    // Bash's coproc convention is `${NAME[0]}` = read_fd, `${NAME[1]}` =
4283    // write_fd. We follow that: arrays[name] = [read_fd_str, write_fd_str].
4284    vm.register_builtin(BUILTIN_RUN_COPROC, |vm, _argc| {
4285        let sub_idx = vm.pop().to_int() as usize;
4286        let job_text = vm.pop().to_str();
4287        let raw_name = vm.pop().to_str();
4288        let name = if raw_name.is_empty() {
4289            "COPROC".to_string()
4290        } else {
4291            raw_name
4292        };
4293        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
4294            Some(c) => c,
4295            None => return Value::Status(1),
4296        };
4297
4298        // c:Src/exec.c:1710-1712 — starting a new coproc closes the
4299        // previous one's fds FIRST:
4300        //     if (coprocin >= 0) { zclose(coprocin); zclose(coprocout); }
4301        // The old coproc child then sees EOF on its stdin and exits on
4302        // its own schedule (its job-table entry stays until it's
4303        // reaped) — zsh does NOT deletejob it here. This is also what
4304        // makes the `exec 4<&p; coproc exit; read -u4` EOF idiom work:
4305        // the replacement coproc closes the shell's write end to the
4306        // old one.
4307        {
4308            use std::sync::atomic::Ordering;
4309            let old_in = crate::ported::modules::clone::coprocin.load(Ordering::Relaxed);
4310            if old_in >= 0 {
4311                let old_out = crate::ported::modules::clone::coprocout.load(Ordering::Relaxed);
4312                unsafe {
4313                    libc::close(old_in);
4314                    if old_out >= 0 {
4315                        libc::close(old_out);
4316                    }
4317                }
4318                crate::ported::modules::clone::coprocin.store(-1, Ordering::Relaxed);
4319                crate::ported::modules::clone::coprocout.store(-1, Ordering::Relaxed);
4320            }
4321        }
4322
4323        // (parent_read ← child_stdout)
4324        let mut p2c = [0i32; 2]; // parent writes, child reads
4325        let mut c2p = [0i32; 2]; // child writes, parent reads
4326        if unsafe { libc::pipe(p2c.as_mut_ptr()) } < 0 {
4327            return Value::Status(1);
4328        }
4329        if unsafe { libc::pipe(c2p.as_mut_ptr()) } < 0 {
4330            unsafe {
4331                libc::close(p2c[0]);
4332                libc::close(p2c[1]);
4333            }
4334            return Value::Status(1);
4335        }
4336        // c:Src/exec.c:5160 mpipe — both pipes' fds are moved above
4337        // the user-visible range (movefd → F_DUPFD ≥ 10) so the
4338        // coproc fds never collide with explicit user fds like
4339        // `exec 3>&p`.
4340        for fd in p2c.iter_mut().chain(c2p.iter_mut()) {
4341            *fd = crate::ported::utils::movefd(*fd);
4342        }
4343
4344        match unsafe { libc::fork() } {
4345            -1 => {
4346                unsafe {
4347                    libc::close(p2c[0]);
4348                    libc::close(p2c[1]);
4349                    libc::close(c2p[0]);
4350                    libc::close(c2p[1]);
4351                }
4352                Value::Status(1)
4353            }
4354            0 => {
4355                // Child: stdin from p2c[0], stdout to c2p[1]. Close all
4356                // unused fds. setsid so SIGINT to fg doesn't hit us.
4357                unsafe {
4358                    libc::dup2(p2c[0], libc::STDIN_FILENO);
4359                    libc::dup2(c2p[1], libc::STDOUT_FILENO);
4360                    libc::close(p2c[0]);
4361                    libc::close(p2c[1]);
4362                    libc::close(c2p[0]);
4363                    libc::close(c2p[1]);
4364                    libc::setsid();
4365                }
4366                crate::fusevm_disasm::maybe_print_stdout("coproc:child", &chunk);
4367                let mut co_vm = fusevm::VM::new(chunk);
4368                register_builtins(&mut co_vm);
4369                let _ = co_vm.run();
4370                let _ = std::io::stdout().flush();
4371                let _ = std::io::stderr().flush();
4372                std::process::exit(co_vm.last_status);
4373            }
4374            pid => {
4375                // Parent: close child ends, store [read_fd, write_fd] in NAME.
4376                unsafe {
4377                    libc::close(p2c[0]);
4378                    libc::close(c2p[1]);
4379                }
4380                let read_fd = c2p[0];
4381                let write_fd = p2c[1];
4382                with_executor(|exec| {
4383                    exec.unset_scalar(&name);
4384                    exec.set_array(name, vec![read_fd.to_string(), write_fd.to_string()]);
4385                });
4386                // c:Src/exec.c — `coprocin`/`coprocout` are the
4387                // canonical globals that bin_read's `-p` arm
4388                // (Src/builtin.c:6510) and bin_print's `-p` arm
4389                // (Src/builtin.c:4827) read to find the
4390                // coprocess fds. The Rust port has the atomic
4391                // declarations at src/ported/modules/clone.rs:262
4392                // but the coproc-launch path never updated them,
4393                // so `read -p` / `print -p` always errored with
4394                // "-p: no coprocess" even when a coproc was
4395                // running. Bug #388 in docs/BUGS.md. Update them
4396                // here so the canonical builtins find the live
4397                // pipe.
4398                crate::ported::modules::clone::coprocin
4399                    .store(read_fd, std::sync::atomic::Ordering::Relaxed);
4400                crate::ported::modules::clone::coprocout
4401                    .store(write_fd, std::sync::atomic::Ordering::Relaxed);
4402                // c:Src/exec.c:1725 — `fdtable[coprocin] =
4403                // fdtable[coprocout] = FDT_UNUSED;`: the two kept ends
4404                // are user-reachable (via `>&p` / `<&p`), so they drop
4405                // the FDT_INTERNAL mark movefd gave them.
4406                crate::ported::utils::fdtable_set(read_fd, crate::ported::zsh_h::FDT_UNUSED);
4407                crate::ported::utils::fdtable_set(write_fd, crate::ported::zsh_h::FDT_UNUSED);
4408                // c:Src/exec.c:2837 — `lastpid = (zlong) pid;`. zsh
4409                // sets the `$!` global to the coproc child's PID so
4410                // subsequent `$!` reads return it. The Rust port at
4411                // exec.rs:6773 mirrors this for regular background
4412                // jobs but the coproc launch path was missing the
4413                // assignment, leaving `$!` at 0 after `coproc cmd`.
4414                crate::ported::modules::clone::lastpid
4415                    .store(pid, std::sync::atomic::Ordering::Relaxed);
4416                // c:Src/exec.c:1700-1758 — the coproc rides the SAME
4417                // Z_ASYNC job-table path as `cmd &`: `thisjob = newjob
4418                // = initjob()` (c:1700), addproc hangs the pid+text
4419                // proc entry off the job, `jobtab[thisjob].stat |=
4420                // STAT_NOSTTY` (c:1746), `clearoldjobtab()` (c:1744)
4421                // and `spawnjob()` (c:1758) promote it to curjob. This
4422                // is what makes `jobs` list the coproc as
4423                // `[1]  + running    cat` and `kill %1` resolve it.
4424                // Mirrors the BUILTIN_RUN_BG parent arm exactly.
4425                {
4426                    use crate::ported::jobs;
4427                    use std::sync::Mutex;
4428                    let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
4429                    let idx = {
4430                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
4431                        let idx = jobs::initjob(&mut tab); // c:exec.c:1700
4432                        jobs::addproc(
4433                            &mut tab[idx],
4434                            pid,
4435                            &job_text,
4436                            false,
4437                            Some(std::time::Instant::now()),
4438                            -1,
4439                            -1,
4440                        );
4441                        tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
4442                        idx
4443                    };
4444                    jobs::clearoldjobtab(); // c:exec.c:1744
4445                    if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
4446                        *tj = idx as i32;
4447                    }
4448                    jobs::spawnjob(); // c:exec.c:1758
4449                }
4450                with_executor(|exec| {
4451                    exec.jobs
4452                        .add_pid_job(pid, job_text.clone(), JobState::Running);
4453                });
4454                Value::Status(0)
4455            }
4456        }
4457    });
4458
4459    vm.register_builtin(BUILTIN_ARRAY_FLATTEN, |vm, argc| {
4460        let n = argc as usize;
4461        let start = vm.stack.len().saturating_sub(n);
4462        let raw: Vec<Value> = vm.stack.drain(start..).collect();
4463        let mut flat: Vec<Value> = Vec::with_capacity(raw.len());
4464        for v in raw {
4465            match v {
4466                Value::Array(items) => flat.extend(items),
4467                other => flat.push(other),
4468            }
4469        }
4470        let len = flat.len() as i64;
4471        // Push the array first; the Int(len) becomes the builtin's return
4472        // value (which CallBuiltin already pushes). Caller consumes in
4473        // reverse: SetSlot(len_slot) pops Int, SetSlot(arr_slot) pops Array.
4474        vm.push(Value::Array(flat));
4475        Value::Int(len)
4476    });
4477
4478    // Shell variable get/set — routes through executor.variables so nested
4479    // VMs (function calls) and tree-walker callers see the same storage.
4480    // GET_VAR / GET_VAR_DQ share one body via `get_var_impl`; the only
4481    // difference is `force_dq`, which the compiler sets for QUOTED simple
4482    // reads (`"$name"`) so an array's empty elements are preserved (the
4483    // `in_dq_context` runtime flag is 0 for these compiler-direct reads).
4484    fn get_var_impl(vm: &mut fusevm::VM, argc: u8, force_dq: bool) -> Value {
4485        let args = pop_args(vm, argc);
4486        let name = args.into_iter().next().unwrap_or_default();
4487        let live_status = vm.last_status;
4488        // Suppress sync when a deferred subshell exit just landed:
4489        // LASTVAL holds the correct deferred status, vm.last_status
4490        // is stale (post-subshell vm doesn't propagate status). See
4491        // SUBSHELL_EXIT_STATUS_PENDING TLS declaration for rationale.
4492        let suppress_sync = SUBSHELL_EXIT_STATUS_PENDING.with(|c| {
4493            let prev = c.get();
4494            c.set(false);
4495            prev
4496        });
4497        // `$@` and `$*` need splice semantics — return Value::Array of
4498        // positional params so for-loop's BUILTIN_ARRAY_FLATTEN spreads them
4499        // and pop_args splits them into argv slots. zsh's `"$@"` bslashquote-each-
4500        // word semantics matches: each pos-param becomes its own arg.
4501        // Same for arrays accessed by name (e.g. `$arr` in some contexts).
4502        let sync_status = |exec: &mut ShellExecutor| {
4503            if !suppress_sync {
4504                exec.set_last_status(live_status);
4505            }
4506        };
4507        if name == "@" || name == "*" {
4508            // Quoting decides empty-word retention (c:Src/subst.c:
4509            // 184-187): the COMPILE site knows it and emits
4510            // BUILTIN_ARRAY_DROP_EMPTY after this read for the
4511            // unquoted form only — in_dq_context is NOT a valid
4512            // discriminator here (the quoted "$@" fast path emits
4513            // GET_VAR directly without an EXPAND_TEXT wrapper).
4514            return with_executor(|exec| {
4515                sync_status(exec);
4516                Value::Array(exec.pparams().iter().map(Value::str).collect())
4517            });
4518        }
4519        // RC_EXPAND_PARAM: when the option is set and `name` refers to
4520        // an array, return Value::Array so the enclosing word's
4521        // BUILTIN_CONCAT_DISTRIBUTE distributes element-wise. Without
4522        // the option, arrays still join to a space-separated scalar
4523        // (zsh's default unquoted-array-as-scalar semantics).
4524        let rc_expand = with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
4525        // c:Src/subst.c — under KSHARRAYS a bare `$name` (no [@]/[*] subscript;
4526        // this GET_VAR path only handles the bare form) is element 1 ONLY — a
4527        // scalar. RC_EXPAND_PARAM then has a single value to distribute, so
4528        // `$acc` → "p1", NOT the whole array. Skip the whole-array rc_expand
4529        // shortcut when KSHARRAYS is set and fall through to the normal path,
4530        // which applies the element-1 collapse. Without this gate,
4531        // `setopt KSH_ARRAYS rc_expand_param; print -r -- $acc` splatted every
4532        // element while zsh prints just "p1".
4533        let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
4534        if rc_expand && !ksh_arrays {
4535            let arr_val = with_executor(|exec| {
4536                sync_status(exec);
4537                exec.array(&name)
4538            });
4539            if let Some(arr) = arr_val {
4540                return Value::Array(arr.into_iter().map(Value::str).collect());
4541            }
4542        }
4543        // Magic-assoc fallback FIRST — `${aliases}` / `${functions}`
4544        // / `${commands}` / etc. should return the value list per
4545        // zsh's bare-assoc semantics. Without this, those names fell
4546        // through to `get_variable` which is empty (they live in
4547        // separate executor tables, not `assoc_arrays`). Return as
4548        // a Value::Array so `arr=(${aliases})` distributes into
4549        // multiple elements, matching zsh's array-context word
4550        // splitting for assoc-bare references.
4551        let magic_vals = with_executor(|exec| {
4552            sync_status(exec);
4553            // Canonical PARTAB dispatch (Src/Modules/parameter.c:2235-
4554            // 2298 + SPECIALPMDEFs in mapfile/terminfo/termcap/system/
4555            // zleparameter): PARTAB_ARRAY entries → whole-array getfn;
4556            // PARTAB entries → scan keys + per-key getpm/scanpm fn
4557            // pointers.
4558            let _ = exec;
4559            if let Some(values) = partab_array_get(&name) {
4560                Some(values)
4561            } else if let Some(keys) = partab_scan_keys(&name) {
4562                Some(
4563                    keys.iter()
4564                        .map(|k| partab_get(&name, k).unwrap_or_default())
4565                        .collect::<Vec<_>>(),
4566                )
4567            } else {
4568                None
4569            }
4570        });
4571        if let Some(vals) = magic_vals {
4572            // Distinguish "name IS a magic-assoc with no entries"
4573            // (return Array(empty)) from "name is unknown — fall
4574            // through to get_variable".
4575            // c:Src/params.c:2293-2296 — KSHARRAYS bare reference
4576            // collapses to the FIRST element in scan order
4577            // (`v->end = 1, v->isarr = 0`). For `options` the scan
4578            // order is optiontab bucket order (OPTIONTAB), so zsh 5.9
4579            // prints `off` (posixargzero) for
4580            // `setopt ksharrays; print $options`.
4581            if opt_state_get("ksharrays").unwrap_or(false) {
4582                return Value::str(vals.into_iter().next().unwrap_or_default());
4583            }
4584            return Value::Array(vals.into_iter().map(Value::str).collect());
4585        }
4586        // Indexed-array path: return Value::Array so pop_args splats
4587        // each element into its own argv slot. Direct port of zsh's
4588        // unquoted `$arr` semantics — each element becomes a separate
4589        // word in command-arg position.
4590        //
4591        // DQ context exception: inside `"...$arr..."`, zsh joins with
4592        // the first char of $IFS (default space) so the DQ word stays
4593        // a single argv slot. Detect via in_dq_context (bumped by
4594        // BUILTIN_EXPAND_TEXT mode 1) and return the joined scalar.
4595        // Direct port of Src/subst.c:1759-1813 nojoin/sepjoin: in DQ
4596        // (qt=1) without explicit `(@)`, sepjoin runs and the result
4597        // is one word.
4598        let arr_assoc_data = with_executor(|exec| {
4599            sync_status(exec);
4600            let in_dq = force_dq || exec.in_dq_context > 0;
4601            // KSH_ARRAYS: bare `$arr` returns ONLY arr[0] (zero-
4602            // based first-element-only semantics). Direct port of
4603            // Src/params.c getstrvalue's KSH_ARRAYS gate which
4604            // returns aval[0] instead of the whole array.
4605            let ksh_arrays = opt_state_get("ksharrays").unwrap_or(false);
4606            if let Some(arr) = exec.array(&name) {
4607                if ksh_arrays {
4608                    return Some((vec![arr.first().cloned().unwrap_or_default()], in_dq));
4609                }
4610                return Some((arr.clone(), in_dq));
4611            }
4612            if exec.assoc(&name).is_some() {
4613                // c:Src/hashtable.c scanhashtable — a bare `$assoc` joins its
4614                // VALUES in zsh hash-BUCKET order (the same order `(k)`/`(v)`
4615                // enumerate), NOT sorted or insertion order. `assoc_get`
4616                // rebuilds zsh's bucket layout; use it so `$as` matches
4617                // `${(v)as}` (`as=(zebra 9 apple 1)` → `9 1`, not the
4618                // alphabetical `1 9`). Under KSHARRAYS the bare form collapses
4619                // to the bucket-FIRST value (`9`), matching zsh.
4620                let values: Vec<String> = crate::ported::subst::assoc_get(&name)
4621                    .map(|m| m.values().cloned().collect())
4622                    .unwrap_or_default();
4623                if ksh_arrays {
4624                    return Some((vec![values.into_iter().next().unwrap_or_default()], in_dq));
4625                }
4626                return Some((values, in_dq));
4627            }
4628            None
4629        });
4630        if let Some((items, in_dq)) = arr_assoc_data {
4631            // c:Src/subst.c:184-187 — prefork's `else if (!keep)
4632            // uremnode(list, node)`: UNQUOTED expansion drops empty
4633            // list nodes before they reach argv, so `a=(y '' x);
4634            // print -- $a` passes TWO args in zsh (`y x`), while the
4635            // quoted "${a[@]}" splat keeps the empty slot. The
4636            // paramsubst splat path already does this (Bug #578
4637            // retain); this GET_VAR fast path bypassed it and leaked
4638            // empty argv slots (visible double-space, wrong arg
4639            // counts in `for`/`print -l`).
4640            let items: Vec<String> = if in_dq {
4641                items
4642            } else {
4643                items.into_iter().filter(|s| !s.is_empty()).collect()
4644            };
4645            if in_dq {
4646                // c:Src/utils.c:3936-3945 sepjoin default-sep rule:
4647                // set-but-empty IFS joins with "" (`IFS=""; echo
4648                // "$arr"` concatenates); only unset / space-leading
4649                // IFS yields " ". The previous get_variable read
4650                // couldn't distinguish unset from set-empty.
4651                return Value::str(crate::ported::utils::sepjoin(&items, None));
4652            }
4653            return Value::Array(items.into_iter().map(Value::str).collect());
4654        }
4655        let (val, in_dq, is_known) = with_executor(|exec| {
4656            sync_status(exec);
4657            let v = exec.get_variable(&name);
4658            // For nounset detection: a name is "known" when it has a
4659            // paramtab/array/assoc/env entry. Special chars ($?, $#,
4660            // $@, $*, $-, $$, $!, $_, $0) always count as known
4661            // regardless of value. Pure-digit positional params
4662            // count as known iff index <= $# (set -- has populated
4663            // that slot). c:Src/subst.c:1689 — NOUNSET fires on
4664            // unset positional param too: `set --; echo "$1"` with
4665            // nounset must diagnose.
4666            let is_special_single = name.len() == 1
4667                && matches!(
4668                    name.chars().next().unwrap(),
4669                    '?' | '#' | '@' | '*' | '-' | '$' | '!' | '_' | '0'
4670                );
4671            let is_pure_digit = !name.is_empty() && name.chars().all(|c| c.is_ascii_digit());
4672            let positional_known = if is_pure_digit {
4673                let idx: usize = name.parse().unwrap_or(0);
4674                if idx == 0 {
4675                    true // $0 always set
4676                } else {
4677                    idx <= exec.pparams().len()
4678                }
4679            } else {
4680                false
4681            };
4682            let known = !v.is_empty()
4683                || name.is_empty()
4684                || is_special_single
4685                || positional_known
4686                || crate::ported::params::paramtab()
4687                    .read()
4688                    .ok()
4689                    .map(|t| t.contains_key(&name))
4690                    .unwrap_or(false)
4691                || env::var(&name).is_ok();
4692            (v, force_dq || exec.in_dq_context > 0, known)
4693        });
4694        // c:Src/subst.c:1689 — NO_UNSET / nounset: reading an unset
4695        // parameter fires "parameter not set" diagnostic and aborts
4696        // the substitution. Direct port of the noerrs gate at c:1689
4697        // (zerr + errflag). Matches `set -u` POSIX semantics.
4698        if !is_known && opt_state_get("nounset").unwrap_or(false) {
4699            crate::ported::utils::zerr(&format!("{}: parameter not set", name));
4700            crate::ported::utils::errflag.fetch_or(
4701                crate::ported::zsh_h::ERRFLAG_ERROR,
4702                std::sync::atomic::Ordering::Relaxed,
4703            );
4704            with_executor(|exec| exec.set_last_status(1));
4705            return Value::str("");
4706        }
4707        // Empty unquoted scalar → drop the arg (zsh "remove empty
4708        // unquoted words" rule). Returning empty Value::Array makes
4709        // pop_args contribute zero items. DQ context keeps the empty
4710        // string so "$a" stays a single empty arg. Direct port of
4711        // subst.c's elide-empty pass.
4712        if val.is_empty() && !in_dq {
4713            return Value::Array(Vec::new());
4714        }
4715        // c:Src/subst.c:1759 SH_WORD_SPLIT — when shwordsplit is set and
4716        // we're in unquoted command-arg position (not DQ), split scalar
4717        // value on IFS into multiple words. Matches BUILTIN_ARRAY_ALL's
4718        // shwordsplit arm (fusevm_bridge.rs:2200). Without this, bare
4719        // `$s` in `print $s` stayed a single arg even with the option
4720        // set, breaking POSIX-style scalar word-splitting.
4721        if !in_dq && opt_state_get("shwordsplit").unwrap_or(false) {
4722            let ifs =
4723                with_executor(|exec| exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string()));
4724            let parts: Vec<Value> = val
4725                .split(|c: char| ifs.contains(c))
4726                .filter(|s| !s.is_empty())
4727                .map(|s| Value::str(s.to_string()))
4728                .collect();
4729            if parts.is_empty() {
4730                return Value::Array(Vec::new());
4731            } else if parts.len() == 1 {
4732                return parts.into_iter().next().unwrap();
4733            } else {
4734                return Value::Array(parts);
4735            }
4736        }
4737        Value::str(val)
4738    }
4739    vm.register_builtin(BUILTIN_GET_VAR, |vm, argc| get_var_impl(vm, argc, false));
4740    vm.register_builtin(BUILTIN_GET_VAR_DQ, |vm, argc| get_var_impl(vm, argc, true));
4741
4742    // `name+=val` (no parens) — runtime dispatch:
4743    //   - if `name` is in `arrays` → push `val` as new element
4744    //   - if `name` is in `assoc_arrays` → refuse (zsh errors here)
4745    //   - else → scalar concat (existing behavior)
4746    // Stack: [name, value].
4747    vm.register_builtin(BUILTIN_APPEND_SCALAR_OR_PUSH, |vm, argc| {
4748        let args = pop_args(vm, argc);
4749        let mut iter = args.into_iter();
4750        let name = iter.next().unwrap_or_default();
4751        let value = iter.next().unwrap_or_default();
4752        with_executor(|exec| {
4753            // Array form: `arr+=elem` pushes a single element.
4754            // Routes through canonical assignaparam(name, [value],
4755            // ASSPM_AUGMENT) — Src/params.c:3357 c:3402-3412 augment
4756            // path prepends prior scalar / appends to existing array.
4757            if exec.array(&name).is_some() {
4758                // c:Src/params.c — under KSHARRAYS a bare array name
4759                // addresses element 0 (ksh), so `a+=X` (scalar augment)
4760                // CONCATENATES onto the first element ("firstlast second"),
4761                // it does NOT push a new element. C routes scalar `+=`
4762                // through assignsparam (which targets the elem-0 value);
4763                // zshrs's APPEND_SCALAR_OR_PUSH would otherwise push.
4764                if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
4765                    let mut arr = exec.array(&name).unwrap_or_default();
4766                    if arr.is_empty() {
4767                        arr.push(value.clone());
4768                    } else {
4769                        arr[0] = format!("{}{}", arr[0], value);
4770                    }
4771                    exec.set_array(name.clone(), arr);
4772                    #[cfg(feature = "recorder")]
4773                    if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
4774                        let ctx = exec.recorder_ctx();
4775                        let attrs = exec.recorder_attrs_for(&name);
4776                        emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
4777                    }
4778                    return;
4779                }
4780                let _ = crate::ported::params::assignaparam(
4781                    &name,
4782                    vec![value.clone()],
4783                    crate::ported::zsh_h::ASSPM_AUGMENT,
4784                );
4785                #[cfg(feature = "recorder")]
4786                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
4787                    let ctx = exec.recorder_ctx();
4788                    let attrs = exec.recorder_attrs_for(&name);
4789                    emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
4790                }
4791                return;
4792            }
4793            if exec.assoc(&name).is_some() {
4794                eprintln!("zshrs: {}: cannot use += on assoc without (key val)", name);
4795                return;
4796            }
4797            // Scalar / integer / float form: route through canonical
4798            // assignsparam(name, value, ASSPM_AUGMENT) which
4799            // dispatches PM_TYPE — PM_SCALAR concats, PM_INTEGER
4800            // arith-adds (c:2775-2778), PM_FLOAT float-adds.
4801            let _ = crate::ported::params::assignsparam(
4802                &name,
4803                &value,
4804                crate::ported::zsh_h::ASSPM_AUGMENT,
4805            );
4806            #[cfg(feature = "recorder")]
4807            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
4808                let ctx = exec.recorder_ctx();
4809                let attrs = exec.recorder_attrs_for(&name);
4810                // Re-read the canonical value via get_variable for the
4811                // recorder bundle (assignsparam may have transformed it
4812                // through integer/float arithmetic).
4813                let final_val = exec.get_variable(&name);
4814                let lower = name.to_ascii_lowercase();
4815                if matches!(
4816                    lower.as_str(),
4817                    "path" | "fpath" | "manpath" | "module_path" | "cdpath"
4818                ) {
4819                    emit_path_or_assign(&name, std::slice::from_ref(&final_val), attrs, true, &ctx);
4820                } else {
4821                    crate::recorder::emit_assign_typed(&name, &final_val, attrs, ctx);
4822                }
4823            }
4824        });
4825        Value::Status(0)
4826    });
4827
4828    // BUILTIN_SET_VAR — `name=value` runtime scalar assignment.
4829    // PURE PASSTHRU: hand to canonical `setsparam` (C port of
4830    // `Src/params.c::setsparam`). That walks assignsparam →
4831    // assignstrvalue which already does:
4832    //   - readonly rejection (zerr + errflag at c:2701)
4833    //   - PM_INTEGER math evaluation (mathevali at c:3590)
4834    //   - PM_EFLOAT / PM_FFLOAT float coercion (c:3608)
4835    //   - PM_LOWER / PM_UPPER case fold (via setstrvalue)
4836    //   - GSU special-param dispatch (homesetfn / ifssetfn / etc.)
4837    //   - allexport env mirror via the PM_EXPORTED setfn
4838    //
4839    // Bridge-only concerns kept here:
4840    //   - inline_env_stack (zsh `X=foo cmd` scoped env)
4841    //   - recorder emission (PFA-SMR)
4842    //   - vm.last_status propagation for `a=$(cmd)` exit-code chaining
4843    // Sets the GLOB_ASSIGN-eligibility flag consumed by the NEXT BUILTIN_SET_VAR.
4844    // Emitted only when a scalar-assign RHS had an unquoted glob token. Takes no
4845    // args; its pushed return is discarded by a following Op::Pop.
4846    vm.register_builtin(BUILTIN_MARK_GLOB_ELIGIBLE, |_vm, _argc| {
4847        SET_VAR_GLOB_ELIGIBLE.with(|c| c.set(true));
4848        fusevm::Value::Int(0)
4849    });
4850    vm.register_builtin(BUILTIN_SET_VAR, |vm, argc| {
4851        // `${~spec}` carrier: an assignment statement is a word-
4852        // pipeline boundary too — restore the user's GLOB_SUBST
4853        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
4854        // ${options[globsubst]}` must read the user value).
4855        consume_tilde_globsubst_carrier();
4856        // Snapshot the raw Values BEFORE pop_args's to_str
4857        // flattening — needed to distinguish Int (arith assignment,
4858        // integer-typed param) from Str (scalar assignment).
4859        let mut raw_values: Vec<fusevm::Value> = Vec::with_capacity(argc as usize);
4860        for _ in 0..argc {
4861            raw_values.push(vm.pop());
4862        }
4863        raw_values.reverse();
4864        let name = raw_values.first().map(|v| v.to_str()).unwrap_or_default();
4865        let value_raw = raw_values.get(1).cloned();
4866        let value = value_raw.as_ref().map(|v| v.to_str()).unwrap_or_default();
4867        // c:Src/params.c — when the bytecode hands us an Int value
4868        // (only the arith assignment paths emit this — `(( X = N ))`
4869        // is the canonical site), route through setiparam so the
4870        // param ends up PM_INTEGER + inherits the math layer's
4871        // `lastbase` for display formatting (`(( X = 16#ff ));
4872        // echo \$X` → `16#FF`). Scalar `X=val` and `$((expr))`
4873        // assignments still take the setsparam path below.
4874        let int_assign = matches!(value_raw, Some(fusevm::Value::Int(_)));
4875        let float_assign = matches!(value_raw, Some(fusevm::Value::Float(_)));
4876        let mut assign_failed = false;
4877        with_executor(|exec| {
4878            // c:Src/params.c assignsparam — PM_READONLY rejection
4879            // BEFORE any env mutation. The inline-env-prefix path
4880            // (`X=2 env`) called env::set_var unconditionally before
4881            // the readonly check fired in setsparam, so the OS env
4882            // got X=2 even though the assignment errored. env then
4883            // inherited the polluted env from fork, leaking the
4884            // attempted override past the readonly guard. Mirror
4885            // C's order: readonly check → zerr → bail; only mutate
4886            // env when the assignment is admissible. Bug #551
4887            // (security-relevant).
4888            if exec.is_readonly_param(&name) {
4889                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
4890                return;
4891            }
4892            // Inline-assignment frame tracking (`X=foo cmd` reverts on
4893            // command return).
4894            if !exec.inline_env_stack.is_empty() {
4895                let prev_var = crate::ported::params::getsparam(&name);
4896                let prev_env = env::var(&name).ok();
4897                exec.inline_env_stack
4898                    .last_mut()
4899                    .unwrap()
4900                    .push((name.clone(), prev_var, prev_env));
4901                let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
4902                // c:Src/params.c:5354
4903            }
4904            // Canonical setsparam handles readonly, integer math, case
4905            // fold, GSU dispatch. For Int values (arith assigns) route
4906            // through setiparam so the param is PM_INTEGER + inherits
4907            // the math layer's lastbase for display formatting. For
4908            // Float (arith assigns producing MN_FLOAT) route through
4909            // setnparam so the param is PM_FFLOAT — `(( b = a * 2 ))`
4910            // with scalar `a="3.14"` should create b as typeset -F,
4911            // not a scalar holding "6.28".
4912            if int_assign {
4913                if let Some(fusevm::Value::Int(i)) = value_raw {
4914                    crate::ported::params::setiparam(&name, i);
4915                } else {
4916                    assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
4917                }
4918            } else if float_assign {
4919                if let Some(fusevm::Value::Float(f)) = value_raw {
4920                    // ArithCompiler returns Value::Float whenever any
4921                    // operand came through Str (BUILTIN_GET_VAR yields
4922                    // Value::Str even for integer-shaped scalars). To
4923                    // avoid forcing every `(( b = a + 3 ))` to PM_FFLOAT
4924                    // when `a="5"` (integer-shaped), detect integer-
4925                    // valued floats and route through setiparam instead.
4926                    // True floats (non-integral) reach setnparam →
4927                    // PM_FFLOAT so `typeset -p b` shows `typeset -F …`.
4928                    if f.fract() == 0.0 && f.is_finite() && f.abs() <= i64::MAX as f64 {
4929                        crate::ported::params::setiparam(&name, f as i64);
4930                    } else {
4931                        let mnval = crate::ported::math::mnumber {
4932                            l: 0,
4933                            d: f,
4934                            type_: crate::ported::math::MN_FLOAT,
4935                        };
4936                        crate::ported::params::setnparam(&name, mnval);
4937                    }
4938                } else {
4939                    assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
4940                }
4941            } else {
4942                // c:Src/exec.c:2554-2567 — GLOB_ASSIGN. When the
4943                // `globassign` option is on and the scalar RHS is a glob
4944                // pattern, glob it and recreate the parameter as a scalar
4945                // (≤1 match) or array (>1) — csh-style assignment. The
4946                // bridge hands `value` UNTOKENIZED, so re-tokenize
4947                // (shtokenize) before haswilds/globlist; zsh's wordcode
4948                // value arrives pre-tokenized via `htok`. The
4949                // `isset(GLOBASSIGN)` gate is first and cheap (option off
4950                // by default), so the common path is unchanged.
4951                let mut globbed = false;
4952                // Only glob the RHS when the compiler flagged an UNQUOTED glob
4953                // token in the literal wordcode (SET_VAR_GLOB_ELIGIBLE). zsh's
4954                // GLOB_ASSIGN (Src/exec.c:2554) globs literal patterns only —
4955                // `x="/tmp/*"`, `x='/tmp/*'`, `x=$param`, `x=$(cmd)` all assign
4956                // verbatim. The value arrives here untokenized (DQ-wrapped by
4957                // the compiler), so this compile-time flag is the only surviving
4958                // signal of whether the pattern was quote-protected.
4959                let glob_eligible = SET_VAR_GLOB_ELIGIBLE.with(|c| c.replace(false));
4960                if glob_eligible && crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBASSIGN) {
4961                    let mut tv = value.clone();
4962                    crate::ported::glob::shtokenize(&mut tv);
4963                    if crate::ported::pattern::haswilds(&tv) {
4964                        // Committed to the glob path: never fall back to
4965                        // assigning the literal pattern (zsh errors on
4966                        // no-match instead).
4967                        globbed = true;
4968                        // globlist tokenizes its input internally (for
4969                        // haswilds + glob_path) and prints the ORIGINAL
4970                        // string verbatim in its "no matches found" error,
4971                        // so feed it the UNtokenized value — passing the
4972                        // tokenized form would leak the Star/Quest token
4973                        // bytes into the error message.
4974                        let mut ll: crate::ported::linklist::LinkList<String> = Default::default();
4975                        ll.push_back(value.clone());
4976                        crate::ported::subst::globlist(&mut ll, 0); // c:2556
4977                        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
4978                            == 0
4979                        {
4980                            let matches: Vec<String> = ll
4981                                .nodes
4982                                .iter()
4983                                .map(|s| crate::ported::lex::untokenize(s).to_string())
4984                                .collect();
4985                            crate::ported::params::unsetparam(&name); // c:2562
4986                            if matches.len() <= 1 {
4987                                let v = matches.into_iter().next().unwrap_or_default();
4988                                assign_failed =
4989                                    crate::ported::params::setsparam(&name, &v).is_none();
4990                            } else {
4991                                crate::ported::params::setaparam(&name, matches);
4992                            }
4993                        }
4994                        // errflag set → globlist already reported
4995                        // "no matches found"; leave the param unassigned
4996                        // to match zsh's abort.
4997                    }
4998                }
4999                // c:Src/exec.c addvars — a NULL return from
5000                // assignsparam (e.g. nameref resolving out of scope,
5001                // createparam refusal at c:1108-1118) fails the
5002                // assignment with status 1.
5003                if !globbed {
5004                    assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
5005                }
5006            }
5007            // PM_EXPORTED / allexport env mirror — read AFTER setsparam
5008            // so the flag bit reflects any GSU setfn side-effects.
5009            let allexport = opt_state_get("allexport").unwrap_or(false);
5010            let already_exported =
5011                (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
5012            if allexport || already_exported {
5013                let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
5014                // c:Src/params.c:5354
5015            }
5016            #[cfg(feature = "recorder")]
5017            if crate::recorder::is_enabled()
5018                && exec.local_scope_depth == 0
5019                && !matches!(
5020                    name.as_str(),
5021                    "PPID" | "LINENO" | "ZSH_ARGZERO" | "argv0" | "ARGC" | "?" | "_" | "RANDOM"
5022                )
5023            {
5024                let ctx = exec.recorder_ctx();
5025                let attrs = exec.recorder_attrs_for(&name);
5026                crate::recorder::emit_assign_typed(&name, &value, attrs, ctx);
5027            }
5028        });
5029        Value::Status(vm.last_status)
5030    });
5031
5032    // c:Src/exec.c execfor → Src/params.c:6362 setloopvar — the
5033    // for-loop variable bind. Distinct from BUILTIN_SET_VAR because a
5034    // PM_NAMEREF loop variable REBINDS (new refname) instead of
5035    // assigning through the resolved chain.
5036    vm.register_builtin(BUILTIN_SET_LOOP_VAR, |vm, argc| {
5037        let args = pop_args(vm, argc);
5038        let name = args.first().cloned().unwrap_or_default();
5039        let value = args.get(1).cloned().unwrap_or_default();
5040        if crate::vm_helper::is_nameref(&name) {
5041            let ef_before =
5042                crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
5043            crate::ported::params::setloopvar(&name, &value); // c:6362
5044            let ef_after = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
5045            if (ef_after & crate::ported::utils::ERRFLAG_ERROR) != 0 && ef_after != ef_before {
5046                // zerr fired (read-only reference / invalid self
5047                // reference) — abort the loop, status 1 (C errflag).
5048                vm.last_status = 1;
5049                return Value::Bool(false);
5050            }
5051            return Value::Bool(true);
5052        }
5053        // Plain loop var — canonical scalar path (same shape as
5054        // BUILTIN_SET_VAR's setsparam arm).
5055        with_executor(|exec| {
5056            if exec.is_readonly_param(&name) {
5057                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
5058                return;
5059            }
5060            crate::ported::params::setsparam(&name, &value);
5061            let allexport = opt_state_get("allexport").unwrap_or(false);
5062            let already_exported =
5063                (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
5064            if allexport || already_exported {
5065                let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
5066                // c:Src/params.c:5354
5067            }
5068        });
5069        Value::Bool(true)
5070    });
5071
5072    // Pre-compiled function registration — used by compile_zsh.rs's
5073    // FuncDef path. Stack: [name, base64-bincode-of-Chunk]. We decode
5074    // the base64, deserialize the Chunk, and store directly in
5075    // executor.functions_compiled. Bypasses the ShellCommand JSON layer.
5076    // BUILTIN_VAR_EXISTS — `[[ -v name ]]` set-test.
5077    // PURE PASSTHRU: build `${+name}` and route through canonical
5078    // `subst::paramsubst` which returns "1" for set / "0" for unset
5079    // (C port of `Src/subst.c::paramsubst` plus-prefix arm).
5080    // paramsubst handles all the shapes the 48-line hand-roll did:
5081    //   - bare scalar / array / assoc
5082    //   - subscripted `a[N]` / `h[key]`
5083    //   - positional params (any digit-only name)
5084    //   - env-var fallback (`HOME` set via getsparam → lookup_special_var)
5085    vm.register_builtin(BUILTIN_VAR_EXISTS, |vm, _argc| {
5086        let name = vm.pop().to_str();
5087        // c:Src/cond.c:361 `case 'v': return !issetvar(left)`. `-v` is
5088        // NOT `${+name}` — issetvar (params.c:751) additionally rejects
5089        // trailing chars after the parsed name/subscript (`arr[3]extra`,
5090        // nested `arr[2][1]`) and validates array-slice bounds (an
5091        // out-of-range `(i)`-not-found index is "unset"). `${+}` is
5092        // lenient and reported those as set.
5093        Value::Bool(crate::ported::params::issetvar(&name) != 0)
5094    });
5095
5096    // `time { compound; ... }` — runs the sub-chunk and prints elapsed
5097    // wall-clock time. zsh's full `time` also tracks user/system CPU via
5098    // getrusage on the *child*; we approximate via wall-time only since
5099    // the sub-chunk runs in-process (no fork). Output format matches
5100    // `time simple-cmd` (already implemented elsewhere via exectime).
5101    vm.register_builtin(BUILTIN_TIME_SUBLIST, |vm, argc| {
5102        let sub_idx = vm.pop().to_int() as usize;
5103        // c:Src/jobs.c:1028-1029 — `pn->text` arg to printtime. argc==2
5104        // means the compiler also pushed a desc string (bug #66 fix);
5105        // older callers with argc==1 push only sub_idx and we synthesize
5106        // an empty desc for backward compat with cached bytecode that
5107        // predates the desc-threading patch.
5108        let desc = if argc >= 2 {
5109            vm.pop().to_str().to_string()
5110        } else {
5111            String::new()
5112        };
5113        let chunk_opt = vm.chunk.sub_chunks.get(sub_idx).cloned();
5114        let Some(chunk) = chunk_opt else {
5115            return Value::Status(0);
5116        };
5117        // c:Src/jobs.c:1968 — `getrusage(RUSAGE_CHILDREN, &ti)` before
5118        // and after the timed sublist gives accurate per-stage user/sys
5119        // CPU. Wall-time-only approximation (0.7×/0.1× fudge factors)
5120        // produced bogus user/sys columns and ignored TIMEFMT. Bug #66
5121        // in docs/BUGS.md.
5122        let ru_before: libc::rusage = unsafe {
5123            let mut r: libc::rusage = std::mem::zeroed();
5124            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
5125            r
5126        };
5127        // c:Src/jobs.c — zsh's `time` reports only for JOBS (forked
5128        // work). Builtins/brace-groups/functions run in the shell
5129        // process with no job, so `zsh -fc 'time true'` emits NOTHING.
5130        // Snapshot the fork-event counter; report only if the timed
5131        // body forked (external command or subshell).
5132        let forks_before = crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed);
5133        let start = Instant::now();
5134        crate::fusevm_disasm::maybe_print_stdout("time_sublist", &chunk);
5135        let mut sub_vm = fusevm::VM::new(chunk);
5136        register_builtins(&mut sub_vm);
5137        let _ = sub_vm.run();
5138        let status = sub_vm.last_status;
5139        let elapsed = start.elapsed();
5140        let ru_after: libc::rusage = unsafe {
5141            let mut r: libc::rusage = std::mem::zeroed();
5142            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
5143            r
5144        };
5145        // Delta children rusage = timed work's CPU.
5146        let mut delta = ru_after;
5147        let sub = |a: libc::timeval, b: libc::timeval| -> libc::timeval {
5148            let mut sec = a.tv_sec - b.tv_sec;
5149            let mut usec = a.tv_usec as i64 - b.tv_usec as i64;
5150            if usec < 0 {
5151                sec -= 1;
5152                usec += 1_000_000;
5153            }
5154            libc::timeval {
5155                tv_sec: sec,
5156                tv_usec: usec as libc::suseconds_t,
5157            }
5158        };
5159        delta.ru_utime = sub(ru_after.ru_utime, ru_before.ru_utime);
5160        delta.ru_stime = sub(ru_after.ru_stime, ru_before.ru_stime);
5161        let ti = crate::ported::zsh_h::timeinfo::from_rusage(&delta);
5162        // c:Src/jobs.c:808-809 — `s = getsparam("TIMEFMT"); s ||
5163        // DEFAULT_TIMEFMT`. Honor user-set TIMEFMT, fall back to the
5164        // canonical default.
5165        let fmt = crate::ported::params::getsparam("TIMEFMT")
5166            .unwrap_or_else(|| crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string());
5167        // c:Src/jobs.c:768 `desc` arg — for the `time { sublist }` /
5168        // `time simple-cmd` keyword path, zsh passes the sublist's
5169        // source text (used by %J via printtime). The compiler now
5170        // threads the rendered source text through as the desc operand
5171        // (compile_zsh.rs Time arm, argc==2 form). Bug #66.
5172        // c:Src/jobs.c — no job, no report (see forks_before above).
5173        let forked = crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed)
5174            != forks_before;
5175        if forked {
5176            let line = crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
5177            eprintln!("{}", line);
5178        }
5179        Value::Status(status)
5180    });
5181
5182    // `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocator.
5183    // Stack: [path, varid, op_byte]. Opens path with the appropriate mode
5184    // and stores the resulting fd number in $varid as a string. We use
5185    // a high starting fd (10+) by allocating then dup'ing — matches zsh's
5186    // "fresh fd >= 10" promise so subsequent commands don't collide on
5187    // stdin/out/err.
5188    vm.register_builtin(BUILTIN_OPEN_NAMED_FD, |vm, _argc| {
5189        use std::sync::atomic::Ordering;
5190        let op_byte = vm.pop().to_int() as u8;
5191        let varid = vm.pop().to_str();
5192        let path = vm.pop().to_str();
5193        // Param introspection used by both the open and close forms.
5194        let param_flags = crate::ported::params::paramtab()
5195            .read()
5196            .ok()
5197            .and_then(|t| t.get(&varid).map(|p| p.node.flags));
5198        let param_readonly = param_flags
5199            .map(|f| (f & crate::ported::zsh_h::PM_READONLY as i32) != 0)
5200            .unwrap_or(false);
5201        // `{varid}>&-` / `{varid}<&-` — REDIR_CLOSE with varid.
5202        // Direct port of Src/exec.c:3805-3850.
5203        if matches!(
5204            op_byte,
5205            b if b == fusevm::op::redirect_op::DUP_WRITE
5206                || b == fusevm::op::redirect_op::DUP_READ
5207        ) {
5208            let n = path.trim_start_matches('&');
5209            if n == "-" {
5210                let val = with_executor(|exec| exec.scalar(&varid)).unwrap_or_default();
5211                let fd1 = val.parse::<i32>();
5212                // c:3811-3816 — bad=1: parameter doesn't contain an fd.
5213                let Ok(fd1) = fd1 else {
5214                    crate::ported::utils::zwarn(&format!(
5215                        "parameter {} does not contain a file descriptor",
5216                        varid
5217                    ));
5218                    with_executor(|exec| exec.redirect_failed = true);
5219                    return Value::Status(1);
5220                };
5221                // c:3813-3814 — bad=2: readonly parameter.
5222                if param_readonly {
5223                    crate::ported::utils::zwarn(&format!(
5224                        "can't close file descriptor from readonly parameter {}",
5225                        varid
5226                    ));
5227                    with_executor(|exec| exec.redirect_failed = true);
5228                    return Value::Status(1);
5229                }
5230                // c:3830-3835 — bad=3: fd >= 10 marked FDT_INTERNAL.
5231                if fd1 >= 10
5232                    && fd1 <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
5233                    && crate::ported::utils::fdtable_get(fd1) == crate::ported::zsh_h::FDT_INTERNAL
5234                {
5235                    crate::ported::utils::zwarn(&format!(
5236                        "file descriptor {} used by shell, not closed",
5237                        fd1
5238                    ));
5239                    with_executor(|exec| exec.redirect_failed = true);
5240                    return Value::Status(1);
5241                }
5242                // c:3870-3873 — close; report failure (varid form
5243                // always reports, unlike bare `N>&-`).
5244                if crate::ported::utils::zclose(fd1) < 0 {
5245                    crate::ported::utils::zwarn(&format!(
5246                        "failed to close file descriptor {}: {}",
5247                        fd1,
5248                        std::io::Error::last_os_error()
5249                    ));
5250                    return Value::Status(1);
5251                }
5252                return Value::Status(0);
5253            }
5254            // `{varid}>&N` — dup N to a fresh fd >= 10, store in varid.
5255            if let Ok(src) = n.parse::<i32>() {
5256                if param_readonly {
5257                    crate::ported::utils::zwarn(&format!(
5258                        "can't allocate file descriptor to readonly parameter {}",
5259                        varid
5260                    ));
5261                    with_executor(|exec| exec.redirect_failed = true);
5262                    return Value::Status(1);
5263                }
5264                let dup = unsafe { libc::dup(src) };
5265                if dup < 0 {
5266                    crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src));
5267                    with_executor(|exec| exec.redirect_failed = true);
5268                    return Value::Status(1);
5269                }
5270                // c:2404-2412 addfd varid arm — movefd + FDT_EXTERNAL.
5271                let final_fd = crate::ported::utils::movefd(dup);
5272                crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
5273                with_executor(|exec| {
5274                    exec.set_scalar(varid, final_fd.to_string());
5275                });
5276                return Value::Status(0);
5277            }
5278            return Value::Status(1);
5279        }
5280        // `{varid}<<HERE` / `{varid}<<<str` — op byte 255 (zshrs-side
5281        // contract with compile_redir; fusevm's redirect_op stops at
5282        // 8). C path: gethere/getherestr write the body to a temp
5283        // file (Src/exec.c:4660-4682), then addfd's varid arm moves
5284        // the read fd >= 10, marks FDT_EXTERNAL and sets the param
5285        // (c:2402-2412). `path` carries the BODY text here.
5286        if op_byte == 255 {
5287            if param_readonly {
5288                crate::ported::utils::zwarn(&format!(
5289                    "can't allocate file descriptor to readonly parameter {}",
5290                    varid
5291                ));
5292                with_executor(|exec| exec.redirect_failed = true);
5293                return Value::Status(1);
5294            }
5295            let body = format!("{}\n", path.trim_end_matches('\n'));
5296            let mut tmpl: Vec<u8> = b"/tmp/zshrs_hd_XXXXXX\0".to_vec();
5297            let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
5298            if write_fd < 0 {
5299                crate::ported::utils::zwarn(&format!(
5300                    "can't create temp file for here document: {}",
5301                    std::io::Error::last_os_error()
5302                ));
5303                return Value::Status(1);
5304            }
5305            let bytes = body.as_bytes();
5306            let mut off = 0;
5307            while off < bytes.len() {
5308                let n = unsafe {
5309                    libc::write(
5310                        write_fd,
5311                        bytes[off..].as_ptr() as *const libc::c_void,
5312                        bytes.len() - off,
5313                    )
5314                };
5315                if n <= 0 {
5316                    unsafe { libc::close(write_fd) };
5317                    return Value::Status(1);
5318                }
5319                off += n as usize;
5320            }
5321            unsafe { libc::close(write_fd) };
5322            let read_fd =
5323                unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
5324            unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
5325            if read_fd < 0 {
5326                return Value::Status(1);
5327            }
5328            let final_fd = crate::ported::utils::movefd(read_fd);
5329            if final_fd < 0 {
5330                return Value::Status(1);
5331            }
5332            crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
5333            with_executor(|exec| {
5334                exec.set_scalar(varid, final_fd.to_string());
5335            });
5336            return Value::Status(0);
5337        }
5338        // Open form: `{varid}>file` etc.
5339        // c:Src/exec.c:2177-2215 checkclobberparam — gate BEFORE open.
5340        if param_readonly {
5341            // c:2191-2197
5342            crate::ported::utils::zwarn(&format!(
5343                "can't allocate file descriptor to readonly parameter {}",
5344                varid
5345            ));
5346            with_executor(|exec| exec.redirect_failed = true);
5347            return Value::Status(1);
5348        }
5349        // c:2199-2213 — NO_CLOBBER refuses to overwrite a parameter
5350        // already holding an OPEN fd (decimal value, fdtable says
5351        // FDT_EXTERNAL).
5352        if !isset(crate::ported::zsh_h::CLOBBER) && op_byte != fusevm::op::redirect_op::CLOBBER {
5353            if let Some(val) = with_executor(|exec| exec.scalar(&varid)) {
5354                if let Ok(fd) = val.parse::<i32>() {
5355                    if fd >= 0
5356                        && fd <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
5357                        && crate::ported::utils::fdtable_get(fd)
5358                            == crate::ported::zsh_h::FDT_EXTERNAL
5359                    {
5360                        crate::ported::utils::zwarn(&format!(
5361                            "can't clobber parameter {} containing file descriptor {}",
5362                            varid, fd
5363                        ));
5364                        with_executor(|exec| exec.redirect_failed = true);
5365                        return Value::Status(1);
5366                    }
5367                }
5368            }
5369        }
5370        let path_c = match CString::new(path.clone()) {
5371            Ok(c) => c,
5372            Err(_) => return Value::Status(1),
5373        };
5374        let flags = match op_byte {
5375            b if b == fusevm::op::redirect_op::READ => libc::O_RDONLY,
5376            b if b == fusevm::op::redirect_op::WRITE || b == fusevm::op::redirect_op::CLOBBER => {
5377                libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC
5378            }
5379            b if b == fusevm::op::redirect_op::APPEND => {
5380                libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND
5381            }
5382            b if b == fusevm::op::redirect_op::READ_WRITE => libc::O_RDWR | libc::O_CREAT,
5383            _ => return Value::Status(1),
5384        };
5385        let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o666) };
5386        if fd < 0 {
5387            return Value::Status(1);
5388        }
5389        // c:2404-2412 addfd varid arm — `fd1 = movefd(fd2);
5390        // fdtable[fd1] = FDT_EXTERNAL; setiparam(varid, fd1);`.
5391        // FDT_EXTERNAL (not INTERNAL): the user owns this fd — the
5392        // NO_CLOBBER gate above and `{fd}>&-` close both key off it.
5393        let final_fd = crate::ported::utils::movefd(fd);
5394        if final_fd < 0 {
5395            crate::ported::utils::zerr(&format!(
5396                "cannot move fd {}: {}",
5397                fd,
5398                std::io::Error::last_os_error()
5399            ));
5400            return Value::Status(1);
5401        }
5402        crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
5403        let _ = Ordering::Relaxed;
5404        with_executor(|exec| {
5405            exec.set_scalar(varid, final_fd.to_string());
5406        });
5407        Value::Status(0)
5408    });
5409
5410    // BUILTIN_SET_TRY_BLOCK_ERROR — capture the try-block's exit
5411    // status into `__zshrs_try_block_saved_status` (a scratch
5412    // scalar) so the always-arm can later restore it. Also set
5413    // `TRY_BLOCK_ERROR` per zsh semantics: it stays at -1 unless
5414    // the try-block fired an explicit error (errflag), per
5415    // c:Src/exec.c execlist's WC_TRYBLOCK arm.
5416    vm.register_builtin(BUILTIN_SET_TRY_BLOCK_ERROR, |vm, _argc| {
5417        use std::sync::atomic::Ordering;
5418        let vm_status = vm.last_status;
5419        let errored = (crate::ported::utils::errflag.load(Ordering::Relaxed)
5420            & crate::ported::zsh_h::ERRFLAG_ERROR)
5421            != 0;
5422        // c:Src/exec.c WC_TRYBLOCK — the always-arm runs with a
5423        // clean escape state. Snapshot RETFLAG / BREAKS / CONTFLAG /
5424        // EXIT_PENDING here and clear them; RESTORE_TRY_BLOCK_STATUS
5425        // re-applies them at always-arm exit so the propagation jump
5426        // emitted by compile_zsh fires correctly.
5427        let ret_save = crate::ported::builtin::RETFLAG.swap(0, Ordering::Relaxed);
5428        let brk_save = crate::ported::builtin::BREAKS.swap(0, Ordering::Relaxed);
5429        let cont_save = crate::ported::builtin::CONTFLAG.swap(0, Ordering::Relaxed);
5430        let exit_save = crate::ported::builtin::EXIT_PENDING.swap(0, Ordering::Relaxed);
5431        TRY_ESCAPE_SAVE.with(|s| {
5432            s.borrow_mut()
5433                .push((ret_save, brk_save, cont_save, exit_save));
5434        });
5435        with_executor(|exec| {
5436            exec.set_scalar(
5437                "__zshrs_try_block_saved_status".to_string(),
5438                vm_status.to_string(),
5439            );
5440            // c:Src/loop.c:765-766 — `try_errflag = errflag &
5441            // ERRFLAG_ERROR; try_interrupt = (errflag & ERRFLAG_INT) ?
5442            // 1 : 0`. Updates the canonical loop.c globals that
5443            // `$TRY_BLOCK_ERROR` / `$TRY_BLOCK_INTERRUPT` read from
5444            // (port at src/ported/loop.rs::try_errflag etc.).
5445            // Mirror into paramtab too via set_scalar so
5446            // `${parameters[TRY_BLOCK_ERROR]}` and direct assignment
5447            // shapes stay in sync.
5448            let try_err = if errored { vm_status as i64 } else { 0 };
5449            crate::ported::r#loop::try_errflag.store(try_err, Ordering::Relaxed);
5450            crate::ported::r#loop::try_interrupt.store(0, Ordering::Relaxed);
5451            if errored {
5452                exec.set_scalar("TRY_BLOCK_ERROR".to_string(), vm_status.to_string());
5453                // Clear errflag so always-arm runs cleanly. c:768.
5454                crate::ported::utils::errflag
5455                    .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
5456            } else {
5457                exec.set_scalar("TRY_BLOCK_ERROR".to_string(), "0".to_string());
5458            }
5459        });
5460        Value::Status(0)
5461    });
5462
5463    // BUILTIN_BEGIN_INLINE_ENV / END_INLINE_ENV — wrap an
5464    // inline-assignment-prefixed command (`X=foo Y=bar cmd`):
5465    // BEGIN pushes a save frame; SET_VAR fires for each assign and
5466    // ALSO env::set_var's the value (visible to cmd's child); the
5467    // command runs; END pops the frame and restores both shell-var
5468    // and process-env state. Direct port of zsh's addvars() →
5469    // execute_simple → restore-after-exec contract.
5470    vm.register_builtin(BUILTIN_BEGIN_INLINE_ENV, |_vm, _argc| {
5471        with_executor(|exec| {
5472            exec.inline_env_stack.push(Vec::new());
5473        });
5474        Value::Status(0)
5475    });
5476    vm.register_builtin(BUILTIN_END_INLINE_ENV, |_vm, _argc| {
5477        with_executor(|exec| {
5478            if let Some(frame) = exec.inline_env_stack.pop() {
5479                for (name, prev_var, prev_env) in frame.into_iter().rev() {
5480                    match prev_var {
5481                        Some(v) => {
5482                            exec.set_scalar(name.clone(), v);
5483                        }
5484                        None => {
5485                            exec.unset_scalar(&name);
5486                        }
5487                    }
5488                    match prev_env {
5489                        Some(v) => env::set_var(&name, &v),
5490                        None => env::remove_var(&name),
5491                    }
5492                }
5493            }
5494        });
5495        Value::Status(0)
5496    });
5497    // c:Src/exec.c:3969-3976 — bare-exec assignment epilogue: see the
5498    // const's doc block. POSIX_BUILTINS → assignments persist (pop the
5499    // frame, discard the saved state); otherwise → restore_params
5500    // (same walk as END_INLINE_ENV).
5501    vm.register_builtin(BUILTIN_EXEC_INLINE_ENV_DONE, |_vm, _argc| {
5502        let persist = isset(crate::ported::zsh_h::POSIXBUILTINS);
5503        with_executor(|exec| {
5504            if let Some(frame) = exec.inline_env_stack.pop() {
5505                if persist {
5506                    return; // c:3971 — no save/restore under POSIX_BUILTINS
5507                }
5508                for (name, prev_var, prev_env) in frame.into_iter().rev() {
5509                    match prev_var {
5510                        Some(v) => {
5511                            exec.set_scalar(name.clone(), v);
5512                        }
5513                        None => {
5514                            exec.unset_scalar(&name);
5515                        }
5516                    }
5517                    match prev_env {
5518                        Some(v) => env::set_var(&name, &v),
5519                        None => env::remove_var(&name),
5520                    }
5521                }
5522            }
5523        });
5524        Value::Status(0)
5525    });
5526
5527    // BUILTIN_RESTORE_TRY_BLOCK_STATUS — emitted at the end of an
5528    // `always` arm. Per zshmisc, the exit status of the entire
5529    // `{ try } always { finally }` construct is the try-list's
5530    // status, regardless of what happens in the always-list (the
5531    // exception is `return`/`exit` inside always, which short-
5532    // circuits and the cleanup is the only thing that runs). So
5533    // restore TRY_BLOCK_ERROR unconditionally — the always-list's
5534    // exit status is discarded for the construct.
5535    vm.register_builtin(BUILTIN_RESTORE_TRY_BLOCK_STATUS, |_vm, _argc| {
5536        use std::sync::atomic::Ordering;
5537        // c:Src/exec.c — the entire `{try} always {…}` construct's
5538        // exit status is the try-block's last status. Per zsh
5539        // semantics this carries through regardless of what the
5540        // always-arm did (including reads/writes of TRY_BLOCK_ERROR
5541        // — those affect later commands' visible value but don't
5542        // override the construct's exit). The "swallow" idiom in
5543        // C is gated on errflag state at always-arm exit, not on
5544        // TBE's literal value; full fidelity needs more state and
5545        // is deferred.
5546        let saved = with_executor(|exec| {
5547            exec.scalar("__zshrs_try_block_saved_status")
5548                .and_then(|s| s.parse::<i32>().ok())
5549                .unwrap_or(0)
5550        });
5551        // Re-apply the escape flags captured by SET_TRY_BLOCK_ERROR.
5552        // If the always-arm itself fired return/break/continue/exit,
5553        // its handler already overwrote the canonical atomics; let
5554        // those win — the always-arm's own escape always takes
5555        // priority over the try-block's deferred one.
5556        if let Some((ret, brk, cont, exit_p)) = TRY_ESCAPE_SAVE.with(|s| s.borrow_mut().pop()) {
5557            if crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) == 0 {
5558                crate::ported::builtin::RETFLAG.store(ret, Ordering::Relaxed);
5559            }
5560            if crate::ported::builtin::BREAKS.load(Ordering::Relaxed) == 0 {
5561                crate::ported::builtin::BREAKS.store(brk, Ordering::Relaxed);
5562            }
5563            if crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed) == 0 {
5564                crate::ported::builtin::CONTFLAG.store(cont, Ordering::Relaxed);
5565            }
5566            if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) == 0 {
5567                crate::ported::builtin::EXIT_PENDING.store(exit_p, Ordering::Relaxed);
5568            }
5569        }
5570        Value::Status(saved)
5571    });
5572
5573    // `[[ -r/-w/-x file ]]` — the cond path must use access(2) (the
5574    // C-faithful doaccess), NOT fusevm's generic Op::TestFile which only
5575    // checks existence for -r/-w (so a `chmod 000` file read as readable;
5576    // C02cond.ztst:13). Stack: [path, mode]; mode is the access(2) bit
5577    // (R_OK=4, W_OK=2, X_OK=1). Mirrors cond.rs:232/238/267 `doaccess`.
5578    vm.register_builtin(BUILTIN_COND_ACCESS, |vm, _argc| {
5579        let mode = vm.pop().to_int() as i32;
5580        let path = vm.pop().to_str();
5581        Value::Bool(crate::ported::cond::doaccess(&path, mode) != 0)
5582    });
5583
5584    vm.register_builtin(BUILTIN_IS_TTY, |vm, _argc| {
5585        let fd_str = vm.pop().to_str();
5586        let fd: i32 = fd_str.trim().parse().unwrap_or(-1);
5587        let is_tty = if fd < 0 {
5588            false
5589        } else {
5590            unsafe { libc::isatty(fd) != 0 }
5591        };
5592        Value::Bool(is_tty)
5593    });
5594
5595    // Set $LINENO before executing the next statement. Direct
5596    // port of zsh's `lineno` global tracking from Src/input.c
5597    // (`if ((inbufflags & INP_LINENO) || !strin) && c == '\n')
5598    // lineno++;`). The compiler emits one of these before each
5599    // top-level pipe in `compile_sublist`, carrying the line
5600    // number captured by the parser at `ZshPipe.lineno`. Pops
5601    // [n], updates `$LINENO` in the variable table.
5602    vm.register_builtin(BUILTIN_SET_LINENO, |vm, _argc| {
5603        let n = vm.pop().to_int();
5604        // c:Src/exec.c:lineno = N — direct write to the param's
5605        // u_val. Cannot go through setsparam because LINENO carries
5606        // PM_READONLY (so `(t)LINENO` reads `integer-readonly-special`
5607        // per zsh); setsparam → assignstrvalue's PM_READONLY guard
5608        // would reject the internal write. C zsh handles this via the
5609        // PM_SPECIAL GSU vtable's setfn callback which bypasses the
5610        // generic readonly check; the Rust port writes the canonical
5611        // field directly instead.
5612        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
5613            if let Some(pm) = tab.get_mut("LINENO") {
5614                pm.u_val = n;
5615                pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
5616            }
5617        }
5618        // Mirror to the file-static `lineno` (utils.c:121) that
5619        // zerrmsg reads at utils.c:301 for the `:N: msg` prefix.
5620        crate::ported::utils::set_lineno(n as i32);
5621        // Also drive lex::LEX_LINENO — zerrmsg (utils.rs:376) reads
5622        // THAT counter for the `name:N:` prefix. C zsh interleaves
5623        // parse and execute per top-level list, so its single
5624        // `lineno` global serves both; zshrs compiles the whole
5625        // script before running, leaving LEX_LINENO parked at EOF.
5626        // Without this write, every runtime zwarn/zerr reported the
5627        // script's LAST line instead of the failing statement's.
5628        crate::ported::lex::set_lineno(n as u64);
5629        // DAP hook — checks breakpoints / step mode / pause-request
5630        // for the line we just landed on. O(1) no-op when DAP is off
5631        // (single atomic load on a OnceLock). Inside `--dap` mode
5632        // this is the call that blocks the executor on a Condvar
5633        // until the IDE sends `continue`. Mirrors strykelang's
5634        // `debugger.should_stop(line) → debugger.prompt(...)` flow.
5635        crate::extensions::dap::check_line(n as u32);
5636        Value::Status(0)
5637    });
5638
5639    // Direct port of Src/prompt.c:1623 cmdpush. Token is a `CS_*`
5640    // value (zsh.h:2775-2806) emitted by compile_zsh around each
5641    // compound command (if/while/[[…]]/((…))/$(…)) and consumed by
5642    // `%_` in PS4 / prompt expansion.
5643    vm.register_builtin(BUILTIN_CMD_PUSH, |vm, _argc| {
5644        let token = vm.pop().to_int() as u8;
5645        // Route through canonical cmdpush (Src/prompt.c:1623). The
5646        // prompt expander reads from the file-static `CMDSTACK` at
5647        // `prompt.rs:2006`, not `exec.cmd_stack` — without this,
5648        // `%_` in PS4 saw an empty stack during xtrace.
5649        if (token as i32) < crate::ported::zsh_h::CS_COUNT {
5650            crate::ported::prompt::cmdpush(token);
5651        }
5652        Value::Status(0)
5653    });
5654
5655    // Direct port of Src/prompt.c:1631 cmdpop.
5656    vm.register_builtin(BUILTIN_CMD_POP, |_vm, _argc| {
5657        crate::ported::prompt::cmdpop();
5658        Value::Status(0)
5659    });
5660
5661    vm.register_builtin(BUILTIN_OPTION_SET, |vm, _argc| {
5662        let name = vm.pop().to_str();
5663        // Direct port of `optison(char *name, char *s)` at Src/cond.c:502 — `[[ -o NAME ]]`
5664        // reads through the same `opts[]` array that `setopt NAME`
5665        // writes via `dosetopt`. Earlier code read a duplicate Executor
5666        // HashMap which never saw `bin_setopt`'s writes (those land in
5667        // `OPTS_LIVE` via `opt_state_set`). Routing through the canonical
5668        // C port restores the single-store invariant: one `opts[]`,
5669        // shared between setopt/unsetopt and `[[ -o ]]`.
5670        let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
5671        match r {
5672            0 => Value::Bool(true),  // c:cond.c:520 set
5673            1 => Value::Bool(false), // c:cond.c:518/520 unset
5674            _ => {
5675                // c:cond.c:514 — unknown option. optison already emitted the
5676                // diagnostic (via zwarn, now that fromtest=NULL for
5677                // `[[ -o ]]`); re-printing here double-emitted for
5678                // `[[ ! -o bad ]]` / `[[ -o a || -o b ]]`.
5679                Value::Bool(false)
5680            }
5681        }
5682    });
5683    // Tri-state `-o` for compile_cond's direct status path. Returns
5684    // 0 / 1 / 3 as a Value::Int that compile_cond consumes via
5685    // Op::SetStatus. Mirrors zsh's `[[ -o invalid ]]` returning $?=3.
5686    vm.register_builtin(BUILTIN_OPTION_CHECK_TRISTATE, |vm, _argc| {
5687        let name = vm.pop().to_str();
5688        let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
5689                                                             // optison itself prints the diagnostic via zwarnnam when r=3
5690                                                             // and POSIXBUILTINS is unset (the canonical path). Don't
5691                                                             // double-emit here. r is already 0/1/3.
5692        Value::Int(r as i64)
5693    });
5694
5695    // BUILTIN_PARAM_FILTER — `${var:#pat}` / `${var:|name}` etc.
5696    // PURE PASSTHRU: rebuild `${name:#pat}` and route to paramsubst.
5697    vm.register_builtin(BUILTIN_PARAM_FILTER, |vm, _argc| {
5698        let pattern = vm.pop().to_str();
5699        let name = vm.pop().to_str();
5700        let body = format!("${{{}:#{}}}", name, pattern);
5701        paramsubst_to_value(&body)
5702    });
5703
5704    // `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()`
5705    // — subscripted-array assign with array RHS. Stack pushed by
5706    // compile_assign as: [elem0, elem1, …, elemN-1, name, key].
5707    vm.register_builtin(BUILTIN_SET_SUBSCRIPT_RANGE, |vm, argc| {
5708        let n = argc as usize;
5709        let mut popped: Vec<Value> = Vec::with_capacity(n);
5710        for _ in 0..n {
5711            popped.push(vm.pop());
5712        }
5713        popped.reverse();
5714        if popped.len() < 3 {
5715            return Value::Status(1);
5716        }
5717        // c:Src/params.c:3511-3526 — trailing append flag. The ARRAY
5718        // subscript path (`a[N]+=(v)` / `a[lo,hi]+=(v)`) sets it so the
5719        // AUGMENT transform below collapses the range to an empty range
5720        // after the slice end and inserts ONLY the new value. The scalar
5721        // path pre-concats the old slice (ARRAY_INDEX+Concat) and passes
5722        // 0, so it keeps plain-replace semantics.
5723        let append = popped.pop().map_or(false, |v| v.to_str() == "1");
5724        let key = popped.pop().unwrap().to_str();
5725        let name = popped.pop().unwrap().to_str();
5726        let mut values: Vec<String> = Vec::new();
5727        for v in popped {
5728            match v {
5729                Value::Array(items) => {
5730                    for it in items {
5731                        values.push(it.to_str());
5732                    }
5733                }
5734                other => values.push(other.to_str()),
5735            }
5736        }
5737        with_executor(|exec| {
5738            // Parse subscript: slice `lo,hi` or single index `i`.
5739            // setarrvalue (Src/params.c:2895) expects 1-based start/
5740            // end inclusive where start==end means replace one
5741            // element. Negative bounds translate to len+n+1 (1-based).
5742            //
5743            // c:Src/params.c — the END side accepts 0 as a valid value
5744            // that signals "insert BEFORE start position" (the canonical
5745            // `a[N,N-1]=val` prepend / mid-insert idiom). Bug #275 in
5746            // docs/BUGS.md: the previous Rust port clamped end up to 1,
5747            // collapsing `a[1,0]=(X Y)` into `a[1,1]=(X Y)` which
5748            // OVERWRITES position 1 instead of prepending. Provide two
5749            // translators — start_translate clamps to 1 (1-based);
5750            // end_translate keeps 0 intact so the splice in
5751            // setarrvalue (start_idx=0..end_idx=0) inserts at the front.
5752            // Bug #589: for scalars (no array), use the scalar's char
5753            // count as `len` so negative-index translation (`a[2,-1]`)
5754            // computes against the actual string length, not 0.
5755            let len = exec
5756                .array(&name)
5757                .map(|a| a.len() as i64)
5758                .or_else(|| {
5759                    crate::ported::params::paramtab().read().ok().and_then(|t| {
5760                        t.get(&name).and_then(|pm| {
5761                            if crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
5762                                == crate::ported::zsh_h::PM_SCALAR
5763                            {
5764                                pm.u_str.as_ref().map(|s| s.chars().count() as i64)
5765                            } else {
5766                                None
5767                            }
5768                        })
5769                    })
5770                })
5771                .unwrap_or(0);
5772            let start_translate = |raw: i64| -> i32 {
5773                if raw < 0 {
5774                    (len + raw + 1).max(1) as i32
5775                } else {
5776                    raw.max(1) as i32
5777                }
5778            };
5779            let end_translate = |raw: i64| -> i32 {
5780                if raw < 0 {
5781                    (len + raw + 1).max(0) as i32
5782                } else {
5783                    raw.max(0) as i32
5784                }
5785            };
5786            // c:Src/params.c — KSH_ARRAYS option flips array subscripts
5787            // from 1-based to 0-based. setarrvalue expects 1-based
5788            // inclusive bounds, so under KSH_ARRAYS we shift positive
5789            // inputs by +1 before translation. Negative bounds left
5790            // alone (count from end). Sibling of #610/#611/#612.
5791            // Bug #613.
5792            let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
5793            let ksh_shift = |raw: i64| -> i64 {
5794                if ksh_arrays && raw >= 0 {
5795                    raw + 1
5796                } else {
5797                    raw
5798                }
5799            };
5800            // c:Src/params.c getindex — subscript bounds are MATH
5801            // expressions, not bare integers: `a[(( ${#a}+1 ))]=(x)`,
5802            // `a[n+1]=(x)`. Plain `parse::<i64>()` returned 0 on any
5803            // arithmetic subscript, which the `i == 0` guard turned into
5804            // a silent no-op (computed-index append never landed). Parse
5805            // the literal fast-path first, then fall back to mathevali
5806            // (which handles `(( ))` grouping, var refs, and operators).
5807            let eval_bound = |s: &str| -> i64 {
5808                let t = s.trim();
5809                t.parse::<i64>()
5810                    .ok()
5811                    .or_else(|| crate::ported::math::mathevali(t).ok())
5812                    .unwrap_or(0)
5813            };
5814            let (start, end) = if let Some((s_str, e_str)) = key.split_once(',') {
5815                let s = ksh_shift(eval_bound(s_str));
5816                let e = ksh_shift(eval_bound(e_str));
5817                (start_translate(s), end_translate(e))
5818            } else {
5819                let i = ksh_shift(eval_bound(&key));
5820                if i == 0 {
5821                    return;
5822                }
5823                let n = start_translate(i);
5824                (n, n)
5825            };
5826            // c:Src/params.c:3518-3520 (assignaparam ASSPM_AUGMENT) — a
5827            // subscripted `+=` to an array does NOT prepend the old slice;
5828            // it collapses the range to an EMPTY range positioned right
5829            // AFTER the slice end (`v->start = v->end--`) and splices in
5830            // ONLY the new value: `a[2]+=(d)` on (a b c) → (a b d c);
5831            // `a[2,3]+=(x)` on (1 2 3 4) → (1 2 3 x 4). In setarrvalue's
5832            // 1-based convention here that means start = end+1 (so
5833            // start_idx == end_idx == end → splice arr[end..end]).
5834            let (start, end) = if append && end > 0 {
5835                (end + 1, end)
5836            } else {
5837                (start, end)
5838            };
5839            // c:Src/params.c:392-430 IPDEF9("argv"/"@"/"*", &pparams) —
5840            // the positional parameters live in the `pparams` vector, NOT
5841            // paramtab, so a subscript splice (`argv[2]=(X Y Z)`,
5842            // `2=(X Y Z)`) must read/write pparams. Splice a synthetic
5843            // array param holding the current positionals via the
5844            // canonical setarrvalue, then store the result back to
5845            // pparams — mirroring assignaparam's argv/@/* special-case
5846            // (params.rs:6937) for the whole-array form.
5847            if name == "argv" || name == "@" || name == "*" {
5848                let mut pm = {
5849                    crate::ported::params::createparam(
5850                        &name,
5851                        crate::ported::zsh_h::PM_ARRAY as i32,
5852                    );
5853                    crate::ported::params::paramtab()
5854                        .write()
5855                        .ok()
5856                        .and_then(|mut t| t.remove(&name))
5857                };
5858                if let Some(ref mut p) = pm {
5859                    p.u_arr = Some(exec.pparams());
5860                }
5861                let mut v = crate::ported::zsh_h::value {
5862                    pm,
5863                    arr: Vec::new(),
5864                    scanflags: 0,
5865                    valflags: 0,
5866                    start,
5867                    end,
5868                };
5869                crate::ported::params::setarrvalue(&mut v, values);
5870                let result = v.pm.and_then(|p| p.u_arr).unwrap_or_default();
5871                exec.set_pparams(result);
5872                return;
5873            }
5874            // Route through canonical setarrvalue (Src/params.c:2895).
5875            // It handles PM_READONLY rejection, PM_HASHED slice-error,
5876            // PM_ARRAY splice + bounds clamp + padding (c:2980+).
5877            let taken = match crate::ported::params::paramtab().write() {
5878                Ok(mut tab) => tab.remove(&name),
5879                Err(_) => None,
5880            };
5881            // c:Src/exec.c:2640 / getvalue(…, 1) — a subscript assignment to
5882            // a NONEXISTENT parameter auto-creates it. getindex/fetchvalue
5883            // with the create flag calls createparam(name, PM_ARRAY) so the
5884            // splice has an array to write into; `unset u; u[1,2]=(a z)`
5885            // then yields the array (a z). Without this, setarrvalue saw
5886            // v.pm == None and silently stored nothing. The single-index
5887            // scalar-value path (SET_ASSOC/SET_ARRAY_AT) already vivifies;
5888            // this brings the range/array-value path to parity.
5889            let taken = taken.or_else(|| {
5890                crate::ported::params::createparam(&name, crate::ported::zsh_h::PM_ARRAY as i32);
5891                crate::ported::params::paramtab()
5892                    .write()
5893                    .ok()
5894                    .and_then(|mut t| t.remove(&name))
5895            });
5896            // c:Src/params.c:2748+ — PM_SCALAR with subscript range
5897            // SPLICES the value into the scalar's char string. Bug
5898            // #589: zshrs's slice handler always called setarrvalue,
5899            // erroring "attempt to assign array value to non-array"
5900            // for `a=hello; a[2,3]=XYZ`. Detect PM_SCALAR and route
5901            // through assignstrvalue (which does scalar splice via
5902            // the PM_SCALAR arm at params.rs:3709-3789).
5903            let is_scalar = taken.as_ref().map_or(false, |pm| {
5904                crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
5905                    == crate::ported::zsh_h::PM_SCALAR
5906            });
5907            let mut v = crate::ported::zsh_h::value {
5908                pm: taken,
5909                arr: Vec::new(),
5910                scanflags: 0,
5911                valflags: 0,
5912                start,
5913                end,
5914            };
5915            if is_scalar {
5916                // Scalar splice — concat values, route through
5917                // assignstrvalue which dispatches by PM_TYPE.
5918                // start_translate returns 1-based positions; assignstrvalue's
5919                // PM_SCALAR arm at params.rs:3735+ expects 0-based start
5920                // (chars before start are kept) and 0-based end-exclusive
5921                // (chars from end are kept). Convert: start-=1.
5922                if v.start > 0 {
5923                    v.start -= 1;
5924                }
5925                let val: String = values.join("");
5926                crate::ported::params::assignstrvalue(Some(&mut v), Some(val), 0);
5927            } else {
5928                crate::ported::params::setarrvalue(&mut v, values);
5929            }
5930            // Write the mutated Param back to paramtab — setarrvalue
5931            // mutated v.pm in-place; the prior `tab.remove(&name)` at
5932            // the top of this handler took ownership, so we re-insert
5933            // here. setarrvalue + this re-insert IS the canonical
5934            // store (Src/params.c:2895). No further mirror needed.
5935            if let Some(pm) = v.pm {
5936                if let Ok(mut tab) = crate::ported::params::paramtab().write() {
5937                    tab.insert(name, pm);
5938                }
5939            }
5940        });
5941        Value::Status(0)
5942    });
5943
5944    // BUILTIN_CONCAT_SPLICE — word-segment concat with first/last
5945    // sticking (default zsh splice semantics for `${arr[@]}`, `$@`).
5946    vm.register_builtin(BUILTIN_CONCAT_SPLICE, |vm, _argc| {
5947        let rhs = vm.pop();
5948        let lhs = vm.pop();
5949        match (lhs, rhs) {
5950            (Value::Array(mut la), Value::Array(ra)) => {
5951                if la.is_empty() {
5952                    return Value::Array(ra);
5953                }
5954                if ra.is_empty() {
5955                    return Value::Array(la);
5956                }
5957                // Last of la merges with first of ra; rest unchanged.
5958                let last_l = la.pop().unwrap();
5959                let mut ra_iter = ra.into_iter();
5960                let first_r = ra_iter.next().unwrap();
5961                let l_s = last_l.as_str_cow();
5962                let r_s = first_r.as_str_cow();
5963                let mut merged = String::with_capacity(l_s.len() + r_s.len());
5964                merged.push_str(&l_s);
5965                merged.push_str(&r_s);
5966                la.push(Value::str(merged));
5967                la.extend(ra_iter);
5968                Value::Array(la)
5969            }
5970            (Value::Array(mut la), rhs_scalar) => {
5971                // c:Src/subst.c paramsubst splice — empty array on
5972                // either side preserves the empty (zero words),
5973                // doesn't collapse into a single-empty-string scalar.
5974                // Bug #120 in docs/BUGS.md: empty array slice
5975                // concatenated with empty literal returned
5976                // Value::str("") which surfaced as one empty arg
5977                // instead of zero args.
5978                let rhs_s = rhs_scalar.as_str_cow();
5979                if la.is_empty() {
5980                    if rhs_s.is_empty() {
5981                        return Value::Array(Vec::new());
5982                    }
5983                    return Value::str(rhs_s.to_string());
5984                }
5985                let last = la.pop().unwrap();
5986                let l_s = last.as_str_cow();
5987                let mut s = String::with_capacity(l_s.len() + rhs_s.len());
5988                s.push_str(&l_s);
5989                s.push_str(&rhs_s);
5990                la.push(Value::str(s));
5991                Value::Array(la)
5992            }
5993            (lhs_scalar, Value::Array(mut ra)) => {
5994                let lhs_s = lhs_scalar.as_str_cow();
5995                if ra.is_empty() {
5996                    // Empty-array RHS — preserve emptiness when the
5997                    // LHS is also empty (no prefix to attach). Bug
5998                    // #120 in docs/BUGS.md.
5999                    if lhs_s.is_empty() {
6000                        return Value::Array(Vec::new());
6001                    }
6002                    return Value::str(lhs_s.to_string());
6003                }
6004                let first = ra.remove(0);
6005                let r_s = first.as_str_cow();
6006                let mut s = String::with_capacity(lhs_s.len() + r_s.len());
6007                s.push_str(&lhs_s);
6008                s.push_str(&r_s);
6009                let mut out = Vec::with_capacity(ra.len() + 1);
6010                out.push(Value::str(s));
6011                out.extend(ra);
6012                Value::Array(out)
6013            }
6014            (lhs_s, rhs_s) => {
6015                let l = lhs_s.as_str_cow();
6016                let r = rhs_s.as_str_cow();
6017                let mut s = String::with_capacity(l.len() + r.len());
6018                s.push_str(&l);
6019                s.push_str(&r);
6020                Value::str(s)
6021            }
6022        }
6023    });
6024
6025    // BUILTIN_CONCAT_DISTRIBUTE — word-segment concat. With
6026    // rcexpandparam (zsh option), distributes element-wise (cartesian
6027    // product). Default mode: joins arrays with IFS first char to a
6028    // single scalar before concat, matching zsh's default unquoted
6029    // and DQ semantics. Direct port of Src/subst.c sepjoin path
6030    // (line ~1813) which gates element-vs-join on the rc_expand_param
6031    // option, defaulting to join.
6032    // BUILTIN_CONCAT_DISTRIBUTE_FORCED — same shape as
6033    // CONCAT_DISTRIBUTE, but always cartesian-distributes when one
6034    // side is Array. Used for compile-time-detected explicit
6035    // distribution forms (`${^arr}` etc.) where the source flag
6036    // overrides the rcexpandparam option default.
6037    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE_FORCED, |vm, _argc| {
6038        let rhs = vm.pop();
6039        let lhs = vm.pop();
6040        match (lhs, rhs) {
6041            (Value::Array(la), Value::Array(ra)) => {
6042                if ra.is_empty() {
6043                    return Value::Array(la);
6044                }
6045                if la.is_empty() {
6046                    return Value::Array(ra);
6047                }
6048                let mut out = Vec::with_capacity(la.len() * ra.len());
6049                for a in &la {
6050                    let a_s = a.as_str_cow();
6051                    for b in &ra {
6052                        let b_s = b.as_str_cow();
6053                        let mut s = String::with_capacity(a_s.len() + b_s.len());
6054                        s.push_str(&a_s);
6055                        s.push_str(&b_s);
6056                        out.push(Value::str(s));
6057                    }
6058                }
6059                Value::Array(out)
6060            }
6061            (Value::Array(la), rhs_scalar) => {
6062                // An EMPTY array contributes nothing to a concatenated
6063                // word — the surrounding scalar text survives. zsh:
6064                // `x${^a}y` (a=()) / `x${(P)scalar-empty}y` → "xy", NOT
6065                // a dropped word. Without this, a `(P)` indirect to an
6066                // unset/empty scalar (which nodes_to_value collapses to
6067                // Value::Array([]) for standalone-removal semantics)
6068                // cartesian-dropped the whole word — p10k's
6069                // `typeset -g _$2=${(P)2}` then arrived as a bare
6070                // `typeset` and dumped every parameter (~217× → 19 MB
6071                // terminal flood → startup hang).
6072                if la.is_empty() {
6073                    return rhs_scalar;
6074                }
6075                let r = rhs_scalar.as_str_cow();
6076                let out: Vec<Value> = la
6077                    .into_iter()
6078                    .map(|a| {
6079                        let a_s = a.as_str_cow();
6080                        let mut s = String::with_capacity(a_s.len() + r.len());
6081                        s.push_str(&a_s);
6082                        s.push_str(&r);
6083                        Value::str(s)
6084                    })
6085                    .collect();
6086                Value::Array(out)
6087            }
6088            (lhs_scalar, Value::Array(ra)) => {
6089                // Symmetric empty-array-contributes-nothing rule; see
6090                // the (Array, scalar) arm above.
6091                if ra.is_empty() {
6092                    return lhs_scalar;
6093                }
6094                let l = lhs_scalar.as_str_cow();
6095                let out: Vec<Value> = ra
6096                    .into_iter()
6097                    .map(|b| {
6098                        let b_s = b.as_str_cow();
6099                        let mut s = String::with_capacity(l.len() + b_s.len());
6100                        s.push_str(&l);
6101                        s.push_str(&b_s);
6102                        Value::str(s)
6103                    })
6104                    .collect();
6105                Value::Array(out)
6106            }
6107            (lhs_s, rhs_s) => {
6108                let l = lhs_s.as_str_cow();
6109                let r = rhs_s.as_str_cow();
6110                let mut s = String::with_capacity(l.len() + r.len());
6111                s.push_str(&l);
6112                s.push_str(&r);
6113                Value::str(s)
6114            }
6115        }
6116    });
6117
6118    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE, |vm, argc| {
6119        let rhs = vm.pop();
6120        let lhs = vm.pop();
6121        // c:Src/options.c — RC_EXPAND_PARAM applies to UNQUOTED
6122        // expansions only; inside DQ `"$foo${arr}bar"` joins via
6123        // $IFS[0] regardless of the option. The compiler emits
6124        // CallBuiltin(BUILTIN_CONCAT_DISTRIBUTE, 1) when the parent
6125        // word is DQ-wrapped (compile_zsh.rs parent_is_dq); the
6126        // default UNQUOTED path emits argc=2 (lhs + rhs). Treat
6127        // argc==1 as "force rc_expand off." Bug #246 in docs/BUGS.md.
6128        let dq_suppress = argc == 1;
6129        let rc_expand =
6130            !dq_suppress && with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
6131        // Helper: join an Array to scalar via sepjoin's IFS default.
6132        // c:Src/utils.c:3936-3945 — set-but-empty IFS joins with ""
6133        // (`IFS=""; echo "x$*y"` → `xabcy`); only unset /
6134        // space-leading IFS yields " ".
6135        let join_arr = |arr: Vec<Value>| -> String {
6136            let strs: Vec<String> = arr.iter().map(|v| v.as_str_cow().into_owned()).collect();
6137            crate::ported::utils::sepjoin(&strs, None)
6138        };
6139        if !rc_expand {
6140            // Default: join any Array side to scalar, then concat.
6141            let l = match lhs {
6142                Value::Array(a) => join_arr(a),
6143                other => other.as_str_cow().into_owned(),
6144            };
6145            let r = match rhs {
6146                Value::Array(a) => join_arr(a),
6147                other => other.as_str_cow().into_owned(),
6148            };
6149            let mut s = String::with_capacity(l.len() + r.len());
6150            s.push_str(&l);
6151            s.push_str(&r);
6152            return Value::str(s);
6153        }
6154        match (lhs, rhs) {
6155            (Value::Array(la), Value::Array(ra)) => {
6156                // Cartesian product: [a + b for a in la for b in ra].
6157                let mut out = Vec::with_capacity(la.len() * ra.len().max(1));
6158                if ra.is_empty() {
6159                    return Value::Array(la);
6160                }
6161                if la.is_empty() {
6162                    return Value::Array(ra);
6163                }
6164                for a in &la {
6165                    let a_s = a.as_str_cow();
6166                    for b in &ra {
6167                        let b_s = b.as_str_cow();
6168                        let mut s = String::with_capacity(a_s.len() + b_s.len());
6169                        s.push_str(&a_s);
6170                        s.push_str(&b_s);
6171                        out.push(Value::str(s));
6172                    }
6173                }
6174                Value::Array(out)
6175            }
6176            (Value::Array(la), rhs_scalar) => {
6177                // An EMPTY array contributes nothing to a concatenated
6178                // word — the surrounding scalar text survives. zsh:
6179                // `x${^a}y` (a=()) / `x${(P)scalar-empty}y` → "xy", NOT
6180                // a dropped word. Without this, a `(P)` indirect to an
6181                // unset/empty scalar (which nodes_to_value collapses to
6182                // Value::Array([]) for standalone-removal semantics)
6183                // cartesian-dropped the whole word — p10k's
6184                // `typeset -g _$2=${(P)2}` then arrived as a bare
6185                // `typeset` and dumped every parameter (~217× → 19 MB
6186                // terminal flood → startup hang).
6187                if la.is_empty() {
6188                    return rhs_scalar;
6189                }
6190                let r = rhs_scalar.as_str_cow();
6191                let out: Vec<Value> = la
6192                    .into_iter()
6193                    .map(|a| {
6194                        let a_s = a.as_str_cow();
6195                        let mut s = String::with_capacity(a_s.len() + r.len());
6196                        s.push_str(&a_s);
6197                        s.push_str(&r);
6198                        Value::str(s)
6199                    })
6200                    .collect();
6201                Value::Array(out)
6202            }
6203            (lhs_scalar, Value::Array(ra)) => {
6204                // Symmetric empty-array-contributes-nothing rule; see
6205                // the (Array, scalar) arm above.
6206                if ra.is_empty() {
6207                    return lhs_scalar;
6208                }
6209                let l = lhs_scalar.as_str_cow();
6210                let out: Vec<Value> = ra
6211                    .into_iter()
6212                    .map(|b| {
6213                        let b_s = b.as_str_cow();
6214                        let mut s = String::with_capacity(l.len() + b_s.len());
6215                        s.push_str(&l);
6216                        s.push_str(&b_s);
6217                        Value::str(s)
6218                    })
6219                    .collect();
6220                Value::Array(out)
6221            }
6222            (lhs_s, rhs_s) => {
6223                // Fast path: both scalar → identical to Op::Concat.
6224                let l = lhs_s.as_str_cow();
6225                let r = rhs_s.as_str_cow();
6226                let mut s = String::with_capacity(l.len() + r.len());
6227                s.push_str(&l);
6228                s.push_str(&r);
6229                Value::str(s)
6230            }
6231        }
6232    });
6233
6234    // `[[ a -ef b ]]` — same-inode test. Resolves both paths via fs::metadata
6235    // (follows symlinks the way zsh's -ef does) and compares (dev, inode).
6236    // Returns false on any I/O error (path missing, permission denied, etc.).
6237    vm.register_builtin(BUILTIN_SAME_FILE, |vm, _argc| {
6238        let b = vm.pop().to_str();
6239        let a = vm.pop().to_str();
6240        let same = match (fs::metadata(&a), fs::metadata(&b)) {
6241            (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
6242            _ => false,
6243        };
6244        Value::Bool(same)
6245    });
6246
6247    // `[[ -c path ]]` — character device.
6248    vm.register_builtin(BUILTIN_IS_CHARDEV, |vm, _argc| {
6249        let path = vm.pop().to_str();
6250        let result = fs::metadata(&path)
6251            .map(|m| m.file_type().is_char_device())
6252            .unwrap_or(false);
6253        Value::Bool(result)
6254    });
6255    // `[[ -b path ]]` — block device.
6256    vm.register_builtin(BUILTIN_IS_BLOCKDEV, |vm, _argc| {
6257        let path = vm.pop().to_str();
6258        let result = fs::metadata(&path)
6259            .map(|m| m.file_type().is_block_device())
6260            .unwrap_or(false);
6261        Value::Bool(result)
6262    });
6263    // `[[ -p path ]]` — FIFO (named pipe).
6264    vm.register_builtin(BUILTIN_IS_FIFO, |vm, _argc| {
6265        let path = vm.pop().to_str();
6266        let result = fs::metadata(&path)
6267            .map(|m| m.file_type().is_fifo())
6268            .unwrap_or(false);
6269        Value::Bool(result)
6270    });
6271    // `[[ -S path ]]` — socket.
6272    vm.register_builtin(BUILTIN_IS_SOCKET, |vm, _argc| {
6273        let path = vm.pop().to_str();
6274        let result = fs::symlink_metadata(&path)
6275            .map(|m| m.file_type().is_socket())
6276            .unwrap_or(false);
6277        Value::Bool(result)
6278    });
6279
6280    // `[[ -k path ]]` / `-u` / `-g` — sticky / setuid / setgid bit.
6281    vm.register_builtin(BUILTIN_HAS_STICKY, |vm, _argc| {
6282        let path = vm.pop().to_str();
6283        let result = fs::metadata(&path)
6284            .map(|m| m.permissions().mode() & libc::S_ISVTX as u32 != 0)
6285            .unwrap_or(false);
6286        Value::Bool(result)
6287    });
6288    vm.register_builtin(BUILTIN_HAS_SETUID, |vm, _argc| {
6289        let path = vm.pop().to_str();
6290        let result = fs::metadata(&path)
6291            .map(|m| m.permissions().mode() & libc::S_ISUID as u32 != 0)
6292            .unwrap_or(false);
6293        Value::Bool(result)
6294    });
6295    vm.register_builtin(BUILTIN_HAS_SETGID, |vm, _argc| {
6296        let path = vm.pop().to_str();
6297        let result = fs::metadata(&path)
6298            .map(|m| m.permissions().mode() & libc::S_ISGID as u32 != 0)
6299            .unwrap_or(false);
6300        Value::Bool(result)
6301    });
6302    vm.register_builtin(BUILTIN_OWNED_BY_USER, |vm, _argc| {
6303        let path = vm.pop().to_str();
6304        let euid = unsafe { libc::geteuid() };
6305        let result = fs::metadata(&path)
6306            .map(|m| m.uid() == euid)
6307            .unwrap_or(false);
6308        Value::Bool(result)
6309    });
6310    vm.register_builtin(BUILTIN_OWNED_BY_GROUP, |vm, _argc| {
6311        let path = vm.pop().to_str();
6312        let egid = unsafe { libc::getegid() };
6313        let result = fs::metadata(&path)
6314            .map(|m| m.gid() == egid)
6315            .unwrap_or(false);
6316        Value::Bool(result)
6317    });
6318
6319    // `[[ -N path ]]` — file's access time is NOT newer than its
6320    // modification time (zsh man: "true if file exists and its
6321    // access time is not newer than its modification time"). Used
6322    // by zsh's mailbox-watching code. The semantic is `atime <=
6323    // mtime` (equivalent to `mtime >= atime`) — equal counts as
6324    // true, which a strict `mtime > atime` check missed for newly
6325    // created files where both stamps are identical.
6326    vm.register_builtin(BUILTIN_FILE_MODIFIED_SINCE_ACCESS, |vm, _argc| {
6327        let path = vm.pop().to_str();
6328        let result = fs::metadata(&path)
6329            .map(|m| m.atime() <= m.mtime())
6330            .unwrap_or(false);
6331        Value::Bool(result)
6332    });
6333
6334    // `[[ a -nt b ]]` — true if `a`'s mtime is strictly later than `b`'s.
6335    // BOTH files must exist; if either is missing the result is false.
6336    // (Earlier behavior was bash's "missing == infinitely-old"; zsh
6337    // strictly requires both files to exist.)
6338    vm.register_builtin(BUILTIN_FILE_NEWER, |vm, _argc| {
6339        let b = vm.pop().to_str();
6340        let a = vm.pop().to_str();
6341        // Use SystemTime modified() for nanosecond precision —
6342        // MetadataExt::mtime() returns seconds only, so two files
6343        // touched within the same second compared equal even when
6344        // 500ms apart. zsh tracks ns and uses `>=` for ties (touching
6345        // a then b in quick succession should still report b newer).
6346        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
6347        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
6348        let result = match (ta, tb) {
6349            (Some(ta), Some(tb)) => ta > tb,
6350            _ => false,
6351        };
6352        Value::Bool(result)
6353    });
6354
6355    // `[[ a -ot b ]]` — mirror of -nt. Same both-must-exist contract.
6356    vm.register_builtin(BUILTIN_FILE_OLDER, |vm, _argc| {
6357        let b = vm.pop().to_str();
6358        let a = vm.pop().to_str();
6359        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
6360        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
6361        let result = match (ta, tb) {
6362            (Some(ta), Some(tb)) => ta < tb,
6363            _ => false,
6364        };
6365        Value::Bool(result)
6366    });
6367
6368    // `set -e` / `setopt errexit` post-command check. Compiler emits
6369    // this after each top-level command's SetStatus (skipped inside
6370    // conditionals/pipelines/&&||/`!`). If errexit is on AND the last
6371    // command exited non-zero AND it's not a `return` from a function,
6372    // exit the shell with that status.
6373    // `set -x` / `setopt xtrace` — print each command before it runs.
6374    // The compiler emits this BEFORE the actual builtin/external call
6375    // with the command's literal text as a single string arg. We
6376    // print to stderr if xtrace is on. Honors `$PS4` (default `+ `).
6377    //
6378    // ── XTRACE flow control ────────────────────────────────────────
6379    // Mirror of C zsh's `doneps4` flag in execcmd_exec (Src/exec.c).
6380    // When an assignment trace fires (XTRACE_ASSIGN), it emits PS4
6381    // and sets this flag so the subsequent XTRACE_ARGS skips its own
6382    // PS4 emission — the assignment + command end up on the SAME
6383    // line: `<PS4>a=1 echo hello\n`. XTRACE_ARGS / XTRACE_NEWLINE
6384    // reset the flag after emitting the trailing `\n`.
6385    vm.register_builtin(BUILTIN_XTRACE_IS_ON, |_vm, _argc| {
6386        // Push live xtrace state. Caller pairs this with JumpIfFalse
6387        // to skip the trace-string-building block when xtrace is off,
6388        // avoiding side-effectful operand re-evaluation. Bug #159 in
6389        // docs/BUGS.md.
6390        let on = with_executor(|_| opt_state_get("xtrace").unwrap_or(false));
6391        Value::Int(if on { 1 } else { 0 })
6392    });
6393
6394    vm.register_builtin(BUILTIN_XTRACE_LINE, |vm, _argc| {
6395        let cmd_text = vm.pop().to_str();
6396        // Sync exec.last_status with the live vm.last_status BEFORE
6397        // the next command runs. Direct port of the zsh exec.c
6398        // contract — `$?` reads the exit status of the *most recent*
6399        // command. XTRACE_LINE is emitted by the compiler BEFORE
6400        // every simple command, so it's the natural sync point.
6401        let live = vm.last_status;
6402        with_executor(|exec| {
6403            exec.set_last_status(live);
6404        });
6405        // C zsh emits xtrace for `(( … ))` / `[[ … ]]` / `case` /
6406        // `if/while/until/for/repeat` head expressions via
6407        // `printprompt4(); fprintf(xtrerr, "%s\n", expr)` at
6408        // Src/exec.c:5240 (math), c:5286 (cond), c:4117 (for), etc.
6409        // The compiler emits BUILTIN_XTRACE_LINE only at those
6410        // construct boundaries (compile_arith / compile_cond /
6411        // compile_if / compile_while / compile_for / compile_case);
6412        // simple commands route to BUILTIN_XTRACE_ARGS instead. So
6413        // this handler always emits when xtrace is on — no prefix-
6414        // string heuristic.
6415        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
6416        if on {
6417            let already = XTRACE_DONE_PS4.with(|f| f.get());
6418            if !already {
6419                printprompt4();
6420            }
6421            // c:exec.c:5240/5286 — `fprintf(xtrerr, "%s\n", expr)`. Buffer
6422            // the line + newline, flush once (single write).
6423            xtrerr_fputs(&cmd_text);
6424            xtrerr_fputs("\n");
6425            xtrerr_flush();
6426            XTRACE_DONE_PS4.with(|f| f.set(false));
6427        }
6428        Value::Status(0)
6429    });
6430
6431    // Like XTRACE_LINE but reads the top `argc - 1` values from the
6432    // VM stack WITHOUT consuming them (peek), then pops a prefix
6433    // string at the top. Joins prefix + peeked args with spaces using
6434    // zsh's quotedzputs-equivalent quoting. Direct port of
6435    // Src/exec.c:2055-2066 — emit AFTER expansion, with each arg
6436    // shell-quoted, so `for i in a b; echo for $i` traces as
6437    // `echo for a` / `echo for b`, not `echo for $i`.
6438    //
6439    // Stack contract on entry: [arg1, arg2, ..., argN, prefix].
6440    // Pops prefix; peeks argN..arg1 below. argc = N + 1.
6441    vm.register_builtin(BUILTIN_XTRACE_ARGS, |vm, argc| {
6442        let prefix = vm.pop().to_str();
6443        let live = vm.last_status;
6444        with_executor(|exec| {
6445            exec.set_last_status(live);
6446        });
6447        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
6448        if on {
6449            let n_args = argc.saturating_sub(1) as usize;
6450            let len = vm.stack.len();
6451            // c:Src/exec.c:2055 — argv is the POST-expansion word
6452            // list, so an arg that expanded to multiple words splats
6453            // into multiple trace tokens AND an arg that expanded to
6454            // zero words (empty unquoted `${UNSET}`) emits nothing.
6455            // pop_args (line 6243) already does this splat for the
6456            // real handler; mirror the same Array → splat / empty →
6457            // drop logic here so xtrace renders `echo ${UNSET}` as
6458            // `echo` (zsh) instead of `echo ''` (the previous
6459            // single-arg stringify path returned "" and then
6460            // quotedzputs wrapped it in `''`).
6461            let arg_strs: Vec<String> = if n_args > 0 && len >= n_args {
6462                let mut out = Vec::new();
6463                for v in &vm.stack[len - n_args..] {
6464                    match v {
6465                        Value::Array(items) => {
6466                            for item in items {
6467                                out.push(quotedzputs(&item.to_str()));
6468                            }
6469                        }
6470                        other => out.push(quotedzputs(&other.to_str())),
6471                    }
6472                }
6473                out
6474            } else {
6475                Vec::new()
6476            };
6477            // Builtins dispatch through `execbuiltin` (Src/builtin.c:442)
6478            // which emits its own PS4 + name + args xtrace. To avoid
6479            // double-emission, skip our emission here when the first
6480            // arg is a known builtin with a registered HandlerFunc —
6481            // those go through execbuiltin and will trace themselves.
6482            // Externals + builtins-not-yet-routed-through-execbuiltin
6483            // keep our emission as a stand-in.
6484            let goes_through_execbuiltin = crate::ported::builtin::BUILTINS
6485                .iter()
6486                .any(|b| b.node.nam == prefix && b.handlerfunc.is_some());
6487            if !goes_through_execbuiltin {
6488                let line = if arg_strs.is_empty() {
6489                    prefix
6490                } else {
6491                    format!("{} {}", prefix, arg_strs.join(" "))
6492                };
6493                // Mirrors Src/exec.c:2055 xtrace emission. C does:
6494                //   if (!doneps4) printprompt4();
6495                //   ... emit args + spaces ...
6496                //   fputc('\n', xtrerr); fflush(xtrerr);
6497                // printprompt4 + the args + `\n` all land in the xtrerr
6498                // buffer; the single fflush below writes the whole line in
6499                // one syscall so concurrent pipeline stages never
6500                // interleave (c:makecline:2122-2123).
6501                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
6502                if !already_ps4 {
6503                    printprompt4();
6504                }
6505                xtrerr_fputs(&line);
6506                xtrerr_fputs("\n"); // c:2122 fputc('\n', xtrerr)
6507                xtrerr_flush(); // c:2123 fflush(xtrerr)
6508            }
6509            XTRACE_DONE_PS4.with(|f| f.set(false));
6510        }
6511        Value::Status(0)
6512    });
6513
6514    // BUILTIN_XTRACE_ASSIGN — direct port of the per-assignment
6515    // trace block at Src/exec.c:2517-2582. C body excerpt:
6516    //   xtr = isset(XTRACE);
6517    //   if (xtr) { printprompt4(); doneps4 = 1; }
6518    //   while (assign) {
6519    //       if (xtr) fprintf(xtrerr, "%s+=" or "%s=", name);
6520    //       ... eval value into `val` ...
6521    //       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
6522    //       ...
6523    //   }
6524    //
6525    // Stack on entry: [..., name, value]. PEEKS both (they're left
6526    // on stack for SET_VAR to pop). Emits `name=<quoted-val> ` with
6527    // no newline; trailing `\n` comes from XTRACE_ARGS (cmd path)
6528    // or XTRACE_NEWLINE (assignment-only path).
6529    vm.register_builtin(BUILTIN_XTRACE_ASSIGN, |vm, _argc| {
6530        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
6531        if on {
6532            // PEEK [..., name, value] — argc==2 by contract.
6533            let len = vm.stack.len();
6534            if len >= 2 {
6535                let name = vm.stack[len - 2].to_str();
6536                let value = vm.stack[len - 1].to_str();
6537                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
6538                if !already_ps4 {
6539                    printprompt4();
6540                    XTRACE_DONE_PS4.with(|f| f.set(true));
6541                }
6542                // C: `fprintf(xtrerr, "%s=", name)` then `quotedzputs
6543                // (val); fputc(' ', xtrerr);`. Append to the xtrerr buffer
6544                // (no newline / no flush — the line continues with the
6545                // command via XTRACE_ARGS, or ends at XTRACE_NEWLINE).
6546                xtrerr_fputs(&format!("{}={} ", name, quotedzputs(&value)));
6547            }
6548        }
6549        Value::Status(0)
6550    });
6551
6552    // BUILTIN_XTRACE_NEWLINE — emit trailing `\n` + flush iff a
6553    // prior XTRACE_ASSIGN this line already emitted PS4. Mirrors
6554    // C's `fputc('\n', xtrerr); fflush(xtrerr);` at exec.c:3398
6555    // (the assignment-only path through execcmd_exec).
6556    vm.register_builtin(BUILTIN_XTRACE_NEWLINE, |_vm, _argc| {
6557        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
6558        if on {
6559            let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
6560            if already_ps4 {
6561                xtrerr_fputs("\n"); // c:3398 fputc('\n', xtrerr)
6562                xtrerr_flush(); // c:3398 fflush(xtrerr)
6563                XTRACE_DONE_PS4.with(|f| f.set(false));
6564            }
6565        }
6566        Value::Status(0)
6567    });
6568
6569    // c:Src/exec.c WC_TRYBLOCK — post-always re-jump probes. Each
6570    // returns 1 + consumes the atomic when the corresponding
6571    // escape flag is set; the try-block compile pairs each with
6572    // a JumpIfFalse + Jump → outer scope's return / break /
6573    // continue patches.
6574    vm.register_builtin(BUILTIN_RETFLAG_CHECK, |_vm, _argc| {
6575        use std::sync::atomic::Ordering;
6576        let r = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
6577        if r != 0 {
6578            // Don't clear here — doshfunc owns the clear at c:6047
6579            // when the function unwinds. Leaving it set propagates
6580            // through nested `eval`/`source` callers correctly.
6581            Value::Int(1)
6582        } else {
6583            Value::Int(0)
6584        }
6585    });
6586    vm.register_builtin(BUILTIN_BREAKS_CHECK, |_vm, _argc| {
6587        use std::sync::atomic::Ordering;
6588        let b = crate::ported::builtin::BREAKS.load(Ordering::Relaxed);
6589        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
6590        // `break` sets BREAKS but NOT CONTFLAG; `continue` sets both.
6591        // Filter out the continue path here so the two checks are
6592        // mutually exclusive.
6593        if b != 0 && c == 0 {
6594            // Consume BREAKS so the outer loop's break_patches
6595            // landing doesn't double-decrement.
6596            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
6597            Value::Int(1)
6598        } else {
6599            Value::Int(0)
6600        }
6601    });
6602    vm.register_builtin(BUILTIN_CONTFLAG_CHECK, |_vm, _argc| {
6603        use std::sync::atomic::Ordering;
6604        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
6605        if c != 0 {
6606            crate::ported::builtin::CONTFLAG.store(0, Ordering::Relaxed);
6607            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
6608            Value::Int(1)
6609        } else {
6610            Value::Int(0)
6611        }
6612    });
6613    vm.register_builtin(BUILTIN_NOEXEC_CHECK, |_vm, _argc| {
6614        // c:Src/exec.c:1390 — `set -n` / `noexec` option: parse but
6615        // don't execute. Returns Int(1) when noexec is set so the
6616        // emit-side JumpIfTrue skips the statement body.
6617        if opt_state_get("noexec").unwrap_or(false) {
6618            return Value::Int(1);
6619        }
6620        // c:Src/exec.c:1390 — execlist's list-loop gate:
6621        //   `while (wc_code(code) == WC_LIST && !breaks && !retflag
6622        //          && !errflag)`
6623        // — once errflag is set, the NEXT sublist never starts, so
6624        // lastval survives untouched to the shell exit. Without this
6625        // prologue gate the follow-up statement RAN, its dispatch
6626        // saw errflag, returned 1, and SetStatus clobbered lastval —
6627        // `[[ x == [a- ]]; print rc=$?` exited 1 instead of zsh's 2
6628        // (the cond syntax error set lastval=2 per exec.c:5216-5221).
6629        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
6630            & crate::ported::zsh_h::ERRFLAG_ERROR)
6631            != 0
6632        {
6633            return Value::Int(1);
6634        }
6635        Value::Int(0)
6636    });
6637    vm.register_builtin(BUILTIN_DONETRAP_RESET, |_vm, _argc| {
6638        // c:Src/exec.c:1455 — `donetrap = 0;` at sublist start.
6639        // Reset before each top-level statement so the next
6640        // sublist's ERREXIT_CHECK fires the ZERR trap on its FIRST
6641        // non-zero command. Carries the "already fired" state
6642        // across function-call returns within the SAME outer
6643        // sublist (per C semantics — donetrap is process-global).
6644        // Bug #303 in docs/BUGS.md.
6645        crate::ported::exec::DONETRAP.store(0, std::sync::atomic::Ordering::Relaxed);
6646        Value::Status(0)
6647    });
6648
6649    // `[[ -z X ]]` / `[[ -n X ]]` — pop one Value, route through
6650    // canonical `src/ported/cond.rs::evalcond` so the actual
6651    // empty/non-empty test reuses the C-port at `cond.rs:270-271`
6652    // (`'n' => !arg.is_empty()`, `'z' => arg.is_empty()`).
6653    //
6654    // The Array→args conversion lives at the bridge because cond.rs
6655    // expects `&[&str]` (C `cond_str` signature equivalent). For
6656    // `"${arr[@]}"` in DQ context the splice yields `Value::Array`
6657    // — an empty array still expands to one implicit empty word
6658    // (per zsh's "${arr[@]}" splat preserving at least one slot
6659    // in cond context), so:
6660    //   - Array(0)   → ["-z", ""]            → evalcond → 0 (true)
6661    //   - Array(1)   → ["-z", word]          → evalcond → 0/1
6662    //   - Array(2+)  → ["-z", w1, w2, ...]   → evalcond → 2 (parse
6663    //                                          error: too many ops)
6664    //                                          → coerced to false
6665    //   - Str(s)     → ["-z", s]             → evalcond → 0/1
6666    //
6667    // Bug #185 in docs/BUGS.md.
6668    fn run_cond_str_empty(v: Value, op: &str) -> Value {
6669        let words: Vec<String> = match v {
6670            Value::Array(arr) => arr.into_iter().map(|x| x.to_str()).collect(),
6671            Value::Str(s) => vec![s.to_string()],
6672            other => vec![other.to_str()],
6673        };
6674        let mut args: Vec<&str> = vec![op];
6675        if words.is_empty() {
6676            args.push("");
6677        } else {
6678            args.extend(words.iter().map(|s| s.as_str()));
6679        }
6680        let opts: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
6681        let vars: std::collections::HashMap<String, String> = std::collections::HashMap::new();
6682        // c:Src/cond.c:62-66 — `evalcond` returns 0=true, 1=false,
6683        // 2=syntax-error. Coerce error to false (observable behavior
6684        // in zsh: `[[ -z a b ]]` errors and the test as a whole
6685        // returns non-zero).
6686        // `[[ ]]` dispatch — C's `evalcond(state, NULL)` calling convention.
6687        // `None` for from_test → mathevali integer-compare coercion path.
6688        let ret = crate::ported::cond::evalcond(&args, &opts, &vars, false, None);
6689        Value::Int(if ret == 0 { 1 } else { 0 })
6690    }
6691    vm.register_builtin(BUILTIN_COND_STR_EMPTY, |vm, _argc| {
6692        let v = vm.pop();
6693        run_cond_str_empty(v, "-z")
6694    });
6695    vm.register_builtin(BUILTIN_COND_STR_NONEMPTY, |vm, _argc| {
6696        let v = vm.pop();
6697        run_cond_str_empty(v, "-n")
6698    });
6699
6700    // `exec N<<<"str"` — herestring redirect to explicit fd, applied
6701    // permanently. Direct port of `Src/exec.c:4655 getherestr` +
6702    // `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)` at c:3766-
6703    // 3780 for the nullexec=1 bare-exec-redir path. Bug #205 in
6704    // docs/BUGS.md.
6705    vm.register_builtin(BUILTIN_EXEC_HERESTR_FD, |vm, _argc| {
6706        let fd = vm.pop().to_int() as i32;
6707        let content = vm.pop().to_str();
6708        // c:4671-4672 — append `\n` for "real" herestrings (not
6709        // heredoc-derived). zshrs's bare-exec path only fires for
6710        // the `<<<` syntax (REDIR_HERESTR), so always append.
6711        let body = format!("{}\n", content);
6712        // c:4673-4679 — gettempfile → write_loop → close → reopen
6713        // read-only → unlink. Rust equivalent via tempfile crate or
6714        // explicit O_TMPFILE; use mkstemp + unlink-immediately to
6715        // mirror C exactly.
6716        use std::ffi::CString;
6717        let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
6718        let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
6719        if write_fd < 0 {
6720            crate::ported::utils::zwarn(&format!(
6721                "can't create temp file for here document: {}",
6722                std::io::Error::last_os_error()
6723            ));
6724            return Value::Status(1);
6725        }
6726        // c:4675 — write_loop(fd, t, len)
6727        let bytes = body.as_bytes();
6728        let mut off = 0;
6729        while off < bytes.len() {
6730            let n = unsafe {
6731                libc::write(
6732                    write_fd,
6733                    bytes[off..].as_ptr() as *const libc::c_void,
6734                    bytes.len() - off,
6735                )
6736            };
6737            if n <= 0 {
6738                unsafe { libc::close(write_fd) };
6739                return Value::Status(1);
6740            }
6741            off += n as usize;
6742        }
6743        unsafe { libc::close(write_fd) }; // c:4676
6744                                          // Path null-terminated by mkstemp; reopen for reading.
6745        let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
6746        // c:4678 — unlink immediately so the file disappears on
6747        // close, leaving only the fd reference.
6748        unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
6749        if read_fd < 0 {
6750            return Value::Status(1);
6751        }
6752        // c:3779 addfd → dup2 to target fd, close intermediate.
6753        let r = unsafe { libc::dup2(read_fd, fd) };
6754        unsafe { libc::close(read_fd) };
6755        if r < 0 {
6756            return Value::Status(1);
6757        }
6758        Value::Status(0)
6759    });
6760    // c:Src/exec.c:2418 + addfd splice — MULTIOS fan-out. Stack
6761    // layout pushed by compile_zsh's coalescing pass:
6762    //   [target_1, op_byte_1, target_2, op_byte_2, …, target_N,
6763    //    op_byte_N, fd]
6764    // argc = 2N + 1. Pops, opens every target, sets up a pipe +
6765    // splitter thread that reads pipe → writes every chunk to
6766    // every opened target, dup2's pipe-write-end onto fd. The
6767    // splitter is closed + joined by host_redirect_scope_end.
6768    // Bug #36 in docs/BUGS.md.
6769    vm.register_builtin(BUILTIN_MULTIOS_REDIRECT, |vm, argc| {
6770        if argc < 3 || argc % 2 == 0 {
6771            // Bad shape — bail.
6772            return Value::Status(1);
6773        }
6774        // Pop fd first (top of stack).
6775        let fd = vm.pop().to_int() as i32;
6776        // Then pop (op, target) pairs in reverse compile order. Keep
6777        // targets as Values — a glob-bearing target arrives as a
6778        // Value::Array of matches.
6779        let n_targets = ((argc - 1) / 2) as usize;
6780        let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_targets);
6781        for _ in 0..n_targets {
6782            let op_byte = vm.pop().to_int() as u8;
6783            let target = vm.pop();
6784            pairs.push((op_byte, target));
6785        }
6786        // Restore compile order (target_1 first).
6787        pairs.reverse();
6788
6789        // c:Src/glob.c:2195-2203 xpandredir — "Loop over matches,
6790        // duplicating the redirection for each file found": a glob
6791        // target with N matches becomes N members of the same multio
6792        // (`echo hi > *.txt` with two matches writes both files).
6793        let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
6794        for (op_byte, target) in pairs {
6795            match target {
6796                Value::Array(items) => {
6797                    for item in items {
6798                        entries.push((op_byte, item.to_str()));
6799                    }
6800                }
6801                other => entries.push((op_byte, other.to_str())),
6802            }
6803        }
6804        if entries.is_empty() {
6805            return Value::Status(1);
6806        }
6807
6808        // c:Src/exec.c:2418 — `else if (!mfds[fd1] || unset(MULTIOS))`:
6809        // with MULTIOS unset every redirect takes the REPLACE path in
6810        // script order — each target is still opened (created /
6811        // truncated) and dup2'd over the fd, so the LAST one wins and
6812        // earlier files end up empty (`unsetopt multios; print x > a
6813        // > b` leaves `a` empty, `x` in `b`). host_apply_redirect is
6814        // exactly one replace step, noclobber gate included.
6815        let multios_on = opt_state_get("multios").unwrap_or(true);
6816        if !multios_on {
6817            with_executor(|exec| {
6818                for (op_byte, target) in &entries {
6819                    exec.host_apply_redirect(fd as u8, *op_byte, target);
6820                    if exec.redirect_failed {
6821                        // c:Src/exec.c execerr — abort the remaining
6822                        // redirect list on failure.
6823                        break;
6824                    }
6825                }
6826            });
6827            return Value::Status(0);
6828        }
6829
6830        if entries.len() == 1 {
6831            // Single member after splicing — a plain replace
6832            // (c:2418 new-multio arm). Route through
6833            // host_apply_redirect so the noclobber gate, the
6834            // pipeline-output split partial, and error handling all
6835            // apply exactly as for an un-bagged redirect.
6836            let (op_byte, target) = &entries[0];
6837            with_executor(|exec| {
6838                exec.host_apply_redirect(fd as u8, *op_byte, target);
6839            });
6840            return Value::Status(0);
6841        }
6842
6843        // c:Src/exec.c:3722-3724 — when this command's stdout IS the
6844        // pipeline output, C seeds mfds[1] with the pipe BEFORE
6845        // walking the redirect list, so the pipe is the multio's
6846        // first member (`print x >&1 > f | cat` sends `x` down the
6847        // pipe TWICE: once for the seed, once for the `>&1` dup).
6848        let pipe_seed = fd == 1
6849            && with_executor(|exec| {
6850                exec.pipe_output_scope
6851                    .is_some_and(|d| d + 1 == exec.redirect_scope_stack.len())
6852            });
6853
6854        // Save current fd state for scope-end restoration — BEFORE
6855        // the first member's replace dup2 below.
6856        let saved = unsafe { libc::dup(fd) };
6857        if saved >= 0 {
6858            with_executor(|exec| {
6859                if let Some(top) = exec.redirect_scope_stack.last_mut() {
6860                    top.push((fd, saved));
6861                } else {
6862                    unsafe { libc::close(saved) };
6863                }
6864            });
6865        }
6866
6867        // Accumulate member fds in redirect order. c:Src/exec.c:
6868        // 2447-2480 addfd — the FIRST member REPLACES the fd
6869        // (c:2448-2450 `mfds[fd1]->ct=1; mfds[fd1]->fds[0]=fd1;`), so
6870        // a later numeric `>&N` self-dup resolves against the fd's
6871        // value at that point in the sequence: `print x > f >&1`
6872        // writes f TWICE; `print x >&1 > f` writes the ORIGINAL
6873        // stdout + f.
6874        let mut target_fds: Vec<i32> = Vec::with_capacity(entries.len() + 1);
6875        if pipe_seed {
6876            let p = unsafe { libc::dup(fd) };
6877            if p >= 0 {
6878                target_fds.push(p);
6879            }
6880        }
6881        let noclobber = opt_state_get("noclobber").unwrap_or(false)
6882            || !opt_state_get("clobber").unwrap_or(true);
6883        for (i, (op_byte, target)) in entries.iter().enumerate() {
6884            let open_result: std::io::Result<i32> = match *op_byte {
6885                r::DUP_WRITE | r::DUP_READ => {
6886                    // Numeric `>&N` — dup the LIVE fd N (after any
6887                    // earlier member's replace).
6888                    match target.trim_start_matches('&').parse::<i32>() {
6889                        Ok(src) => {
6890                            let d = unsafe { libc::dup(src) };
6891                            if d >= 0 {
6892                                Ok(d)
6893                            } else {
6894                                Err(std::io::Error::last_os_error())
6895                            }
6896                        }
6897                        Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
6898                    }
6899                }
6900                r::WRITE => {
6901                    // c:Src/exec.c clobber_open — noclobber applies
6902                    // to multio file targets too; failure aborts the
6903                    // remaining redirect list (execerr), so `setopt
6904                    // noclobber; touch a; print x > a > b` errors on
6905                    // `a` and never creates `b`.
6906                    let target_meta = std::fs::metadata(target).ok();
6907                    let target_is_regular_file = target_meta
6908                        .as_ref()
6909                        .map(|m| m.file_type().is_file())
6910                        .unwrap_or(false);
6911                    // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY re-uses
6912                    // an empty regular file under noclobber (same allowance
6913                    // as the single-redirect path).
6914                    let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
6915                        && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
6916                    if noclobber && target_is_regular_file && !clobber_empty_ok {
6917                        eprintln!(
6918                            "{}:{}: file exists: {}",
6919                            shname(),
6920                            crate::ported::lex::lineno(),
6921                            target
6922                        );
6923                        for prev in &target_fds {
6924                            unsafe {
6925                                libc::close(*prev);
6926                            }
6927                        }
6928                        with_executor(|exec| {
6929                            exec.redirect_failed = true;
6930                        });
6931                        // Sink the upcoming command's output (mirrors
6932                        // the single-redirect noclobber arm in
6933                        // host_apply_redirect).
6934                        if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
6935                            let new_fd = file.into_raw_fd();
6936                            unsafe {
6937                                libc::dup2(new_fd, fd);
6938                                libc::close(new_fd);
6939                            }
6940                        }
6941                        return Value::Status(1);
6942                    }
6943                    fs::OpenOptions::new()
6944                        .write(true)
6945                        .create(true)
6946                        .truncate(true)
6947                        .open(target)
6948                        .map(|f| f.into_raw_fd())
6949                }
6950                r::APPEND => fs::OpenOptions::new()
6951                    .write(true)
6952                    .create(true)
6953                    .append(true)
6954                    .open(target)
6955                    .map(|f| f.into_raw_fd()),
6956                _ => fs::OpenOptions::new()
6957                    .write(true)
6958                    .create(true)
6959                    .truncate(true)
6960                    .open(target)
6961                    .map(|f| f.into_raw_fd()),
6962            };
6963            match open_result {
6964                Ok(tfd) => {
6965                    if i == 0 && !pipe_seed {
6966                        // c:2448-2450 — first member replaces the fd.
6967                        unsafe {
6968                            libc::dup2(tfd, fd);
6969                        }
6970                    }
6971                    target_fds.push(tfd);
6972                }
6973                Err(e) => {
6974                    // c:Src/exec.c:3741 — `zwarn("%e: %s", errno, fname)`:
6975                    // zwarning supplies the `name:LINE:` prefix with the
6976                    // REAL current lineno; redir_errno_msg builds the `%e`
6977                    // errno message (was a hardcoded ErrorKind match that
6978                    // showed generic "redirect failed" for EROFS/etc.).
6979                    let msg = redir_errno_msg(&e);
6980                    crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
6981                    // Close already-opened fds to avoid leaks.
6982                    for prev in &target_fds {
6983                        unsafe {
6984                            libc::close(*prev);
6985                        }
6986                    }
6987                    with_executor(|exec| {
6988                        exec.redirect_failed = true;
6989                    });
6990                    return Value::Status(1);
6991                }
6992            }
6993        }
6994
6995        // Create the splitter pipe.
6996        let (read_end, write_end) = match os_pipe::pipe() {
6997            Ok(p) => p,
6998            Err(_) => {
6999                for f in &target_fds {
7000                    unsafe {
7001                        libc::close(*f);
7002                    }
7003                }
7004                return Value::Status(1);
7005            }
7006        };
7007        let pipe_write_raw = AsRawFd::as_raw_fd(&write_end);
7008        // Spawn the splitter thread: read pipe → write every chunk
7009        // to every target fd. Each write inside the thread uses
7010        // libc::write directly on the raw fd (no Rust File ownership
7011        // so the splitter can close after EOF without racing main).
7012        let target_fds_for_thread = target_fds.clone();
7013        let handle = std::thread::spawn(move || {
7014            let mut r = read_end;
7015            let mut buf = [0u8; 8192];
7016            loop {
7017                match std::io::Read::read(&mut r, &mut buf) {
7018                    Ok(0) => break,
7019                    Ok(n) => {
7020                        for &tfd in &target_fds_for_thread {
7021                            let mut off = 0;
7022                            while off < n {
7023                                let w = unsafe {
7024                                    libc::write(
7025                                        tfd,
7026                                        buf[off..n].as_ptr() as *const libc::c_void,
7027                                        n - off,
7028                                    )
7029                                };
7030                                if w <= 0 {
7031                                    break;
7032                                }
7033                                off += w as usize;
7034                            }
7035                        }
7036                    }
7037                    Err(_) => break,
7038                }
7039            }
7040            // Close every target so file contents flush.
7041            for tfd in target_fds_for_thread {
7042                unsafe {
7043                    libc::close(tfd);
7044                }
7045            }
7046        });
7047
7048        // Dup the pipe write-end onto the target fd; close the
7049        // original write_end so EOF arrives when host_redirect_scope_end
7050        // closes our tracked pipe_write_fd.
7051        let write_dup = unsafe { libc::dup(pipe_write_raw) };
7052        drop(write_end);
7053        if write_dup < 0 {
7054            return Value::Status(1);
7055        }
7056        unsafe {
7057            libc::dup2(write_dup, fd);
7058            libc::close(write_dup);
7059        }
7060        // Track the running splitter so scope-end can drain + join.
7061        // The "write_fd" we store is the user-visible fd (e.g. 1).
7062        // Closing that fd at scope-end isn't quite right; we need a
7063        // way to send EOF. Solution: track the write_dup we just
7064        // closed; instead keep a second dup for the close-on-end.
7065        let close_on_end = unsafe { libc::dup(fd) };
7066        with_executor(|exec| {
7067            if let Some(top) = exec.multios_scope_stack.last_mut() {
7068                top.push((close_on_end, handle));
7069            } else {
7070                // No scope — leak the dup; thread will keep running
7071                // until process exit. Should not happen because
7072                // host_redirect_scope_begin pushed a frame.
7073                unsafe { libc::close(close_on_end) };
7074            }
7075        });
7076        Value::Status(0)
7077    });
7078    // c:Src/exec.c:2418 input-arm — MULTIOS read fan-in. Stack
7079    // layout pushed by compile_zsh (mirrors the write side):
7080    //   [source_1, op_1, source_2, op_2, …, source_N, op_N, fd]
7081    // argc = 2N + 1; op distinguishes file opens (READ) from numeric
7082    // `<&N` dups (DUP_READ); a glob source arrives as Value::Array
7083    // and splices into one member per match (c:Src/glob.c:2195-2203).
7084    // Opens every source, sets up a pipe + producer thread that
7085    // reads each source in order and writes to the pipe write-end,
7086    // then closes its write-end so the consumer gets EOF. dup2 the
7087    // pipe read-end onto fd. Bug #36 input side in docs/BUGS.md.
7088    vm.register_builtin(BUILTIN_MULTIOS_READ, |vm, argc| {
7089        if argc < 3 || argc % 2 == 0 {
7090            return Value::Status(1);
7091        }
7092        let fd = vm.pop().to_int() as i32;
7093        let n_sources = ((argc - 1) / 2) as usize;
7094        let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_sources);
7095        for _ in 0..n_sources {
7096            let op_byte = vm.pop().to_int() as u8;
7097            let source = vm.pop();
7098            pairs.push((op_byte, source));
7099        }
7100        pairs.reverse();
7101
7102        // Splice glob match arrays (c:Src/glob.c:2195-2203).
7103        let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
7104        for (op_byte, source) in pairs {
7105            match source {
7106                Value::Array(items) => {
7107                    for item in items {
7108                        entries.push((op_byte, item.to_str()));
7109                    }
7110                }
7111                other => entries.push((op_byte, other.to_str())),
7112            }
7113        }
7114        if entries.is_empty() {
7115            return Value::Status(1);
7116        }
7117
7118        // c:Src/exec.c:2418 — `unset(MULTIOS)`: sequential replace,
7119        // last source wins (`unsetopt multios; cat < a < b` reads
7120        // only b; a is still opened — and errors still surface).
7121        let multios_on = opt_state_get("multios").unwrap_or(true);
7122        if !multios_on {
7123            with_executor(|exec| {
7124                for (op_byte, source) in &entries {
7125                    exec.host_apply_redirect(fd as u8, *op_byte, source);
7126                    if exec.redirect_failed {
7127                        break;
7128                    }
7129                }
7130            });
7131            return Value::Status(0);
7132        }
7133
7134        if entries.len() == 1 {
7135            // Single member after splicing — plain replace.
7136            let (op_byte, source) = &entries[0];
7137            with_executor(|exec| {
7138                exec.host_apply_redirect(fd as u8, *op_byte, source);
7139            });
7140            return Value::Status(0);
7141        }
7142
7143        // Save current fd state for scope-end restoration — BEFORE
7144        // the first member's replace dup2 below.
7145        let saved = unsafe { libc::dup(fd) };
7146        if saved >= 0 {
7147            with_executor(|exec| {
7148                if let Some(top) = exec.redirect_scope_stack.last_mut() {
7149                    top.push((fd, saved));
7150                } else {
7151                    unsafe { libc::close(saved) };
7152                }
7153            });
7154        }
7155
7156        // Open every source in redirect order; numeric `<&N` dups
7157        // resolve against the LIVE fd table. First member replaces
7158        // the fd (c:2448-2450) so later self-dups see it.
7159        let mut source_fds: Vec<i32> = Vec::with_capacity(entries.len());
7160        for (i, (op_byte, source)) in entries.iter().enumerate() {
7161            let open_result: std::io::Result<i32> = match *op_byte {
7162                r::DUP_READ | r::DUP_WRITE => match source.trim_start_matches('&').parse::<i32>() {
7163                    Ok(src) => {
7164                        let d = unsafe { libc::dup(src) };
7165                        if d >= 0 {
7166                            Ok(d)
7167                        } else {
7168                            Err(std::io::Error::last_os_error())
7169                        }
7170                    }
7171                    Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
7172                },
7173                _ => fs::File::open(source).map(|f| f.into_raw_fd()),
7174            };
7175            match open_result {
7176                Ok(tfd) => {
7177                    if i == 0 {
7178                        unsafe {
7179                            libc::dup2(tfd, fd);
7180                        }
7181                    }
7182                    source_fds.push(tfd);
7183                }
7184                Err(e) => {
7185                    let msg = match e.kind() {
7186                        std::io::ErrorKind::PermissionDenied => "permission denied",
7187                        std::io::ErrorKind::NotFound => "no such file or directory",
7188                        _ => "open failed",
7189                    };
7190                    // c:Src/exec.c:3741 — zwarn with real lineno prefix.
7191                    crate::ported::utils::zwarn(&format!("{}: {}", msg, source));
7192                    for prev in &source_fds {
7193                        unsafe {
7194                            libc::close(*prev);
7195                        }
7196                    }
7197                    with_executor(|exec| {
7198                        exec.redirect_failed = true;
7199                    });
7200                    return Value::Status(1);
7201                }
7202            }
7203        }
7204
7205        // Create the concatenator pipe.
7206        let (read_end, write_end) = match os_pipe::pipe() {
7207            Ok(p) => p,
7208            Err(_) => {
7209                for f in &source_fds {
7210                    unsafe {
7211                        libc::close(*f);
7212                    }
7213                }
7214                return Value::Status(1);
7215            }
7216        };
7217        // dup the pipe read-end onto fd before spawning the
7218        // producer; close the original read_end so the consumer
7219        // (reading via fd) is the sole reference until scope-end.
7220        let read_dup = unsafe { libc::dup(AsRawFd::as_raw_fd(&read_end)) };
7221        drop(read_end);
7222        if read_dup < 0 {
7223            for f in &source_fds {
7224                unsafe {
7225                    libc::close(*f);
7226                }
7227            }
7228            return Value::Status(1);
7229        }
7230        unsafe {
7231            libc::dup2(read_dup, fd);
7232            libc::close(read_dup);
7233        }
7234        // Spawn the producer.
7235        let source_fds_for_thread = source_fds.clone();
7236        let handle = std::thread::spawn(move || {
7237            let mut w = write_end;
7238            let mut buf = [0u8; 8192];
7239            for sfd in source_fds_for_thread {
7240                loop {
7241                    let n = unsafe {
7242                        libc::read(sfd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
7243                    };
7244                    if n <= 0 {
7245                        break;
7246                    }
7247                    let n = n as usize;
7248                    if std::io::Write::write_all(&mut w, &buf[..n]).is_err() {
7249                        break;
7250                    }
7251                }
7252                unsafe {
7253                    libc::close(sfd);
7254                }
7255            }
7256            // Closing w (the write_end) at scope drop signals EOF
7257            // to the consumer.
7258        });
7259        with_executor(|exec| {
7260            // Track using a closed-write sentinel — the producer
7261            // owns write_end so we just need to join. Use -1 fd
7262            // marker meaning "no fd to close".
7263            if let Some(top) = exec.multios_scope_stack.last_mut() {
7264                top.push((-1, handle));
7265            } else {
7266                let _ = handle.join();
7267            }
7268        });
7269        Value::Status(0)
7270    });
7271    // c:Src/exec.c:3978-3986 — nullexec==1 marker. See the const's
7272    // doc block. Arg: 1 = entering a bare-exec redirect, 0 = leaving.
7273    vm.register_builtin(BUILTIN_EXEC_PERM_REDIRS, |vm, _argc| {
7274        let on = vm.pop().to_int() != 0;
7275        with_executor(|exec| exec.exec_redirs_permanent = on);
7276        Value::Status(0)
7277    });
7278    // Bare-exec redirect epilogue — see the const's doc block.
7279    // c:Src/exec.c:252-259 (execerr) + c:4367-4386 (done: POSIX gate).
7280    vm.register_builtin(BUILTIN_EXEC_REDIR_DONE, |vm, _argc| {
7281        use std::sync::atomic::Ordering;
7282        let failed = with_executor(|exec| {
7283            let f = exec.redirect_failed;
7284            exec.redirect_failed = false;
7285            f
7286        });
7287        if !failed {
7288            return Value::Status(0);
7289        }
7290        // c:255 — `redir_err = lastval = 1`.
7291        vm.last_status = 1;
7292        if isset(crate::ported::zsh_h::POSIXBUILTINS) && !isset(crate::ported::zsh_h::INTERACTIVE) {
7293            // c:4379-4383 — non-interactive POSIX fatal: exit(1).
7294            // In-process equivalent: arm EXIT_PENDING/EXIT_VAL so the
7295            // next BUILTIN_ERREXIT_CHECK (trigger 2) unwinds the
7296            // script with status 1 — same deferred-exit shape the
7297            // `exit` builtin uses inside subshell contexts.
7298            crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
7299            crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
7300        }
7301        Value::Status(1)
7302    });
7303    // c:Src/exec.c:3722-3724 — see the const's doc block. No args.
7304    vm.register_builtin(BUILTIN_PIPE_OUTPUT_MARK, |_vm, _argc| {
7305        with_executor(|exec| exec.pipe_output_pending = true);
7306        Value::Status(0)
7307    });
7308    // c:Src/exec.c — block-level redirect-failure gate. When a
7309    // compound command (`{ … } < file`, `( … ) > file`, etc.) has a
7310    // failing redirect (e.g. `< /nonexistent`), zsh skips the entire
7311    // body AND sets lastval to 1. The simple-command path's
7312    // redirect_failed check (line 215-221 above) only catches the
7313    // failure when a builtin dispatches and is consumed by that
7314    // single builtin call — so a multi-statement block kept running
7315    // its remaining statements after the redir error. Emit-side at
7316    // compile_zsh.rs::compile_command's Redirected arm pairs this
7317    // with a JumpIfTrue → WithRedirectsEnd to abandon the body.
7318    vm.register_builtin(BUILTIN_REDIRECT_FAILED_CHECK, |vm, _argc| {
7319        let failed = with_executor(|exec| {
7320            let f = exec.redirect_failed;
7321            exec.redirect_failed = false;
7322            f
7323        });
7324        if failed {
7325            vm.last_status = 1;
7326            Value::Int(1)
7327        } else {
7328            Value::Int(0)
7329        }
7330    });
7331    // c:Src/exec.c — drop-in replacement for fusevm's Op::Exec used by
7332    // the dynamic-first-word path (`$cmd`, `$(cmd)`, glob-named cmds).
7333    // fusevm's Op::Exec returns Value::Status(0) when post-expansion
7334    // argv is empty (vm.rs:1722) — that clobbers \$? for the
7335    // `\$(exit 1); echo \$?` case where the cmd-subst left
7336    // last_status = 1 but the empty expansion gets exec'd to 0.
7337    // Mirror C zsh: when the word list is empty after expansion,
7338    // \$? becomes whatever the inner cmd-subst's last_status is
7339    // (preserved here by returning Value::Status(last_status)).
7340    // c:Src/cond.c:308-316 — `if (!(pprog = patcompile(right, ...)))
7341    //   { zwarnnam(fromtest, "bad pattern: %s", right); return 2; }`.
7342    // The cond path must NOT use str_match/glob_match_static: the
7343    // case-statement consumer of those follows Src/loop.c:667 zerr
7344    // semantics (errflag abort), while cond is a zwarn + status-2
7345    // soft failure. COND_BAD_PATTERN carries the 2 across the
7346    // Bool-shaped stack contract (so `!=`'s LogNot can't lose it).
7347    thread_local! {
7348        static COND_BAD_PATTERN: std::cell::Cell<bool> =
7349            const { std::cell::Cell::new(false) };
7350    }
7351    vm.register_builtin(BUILTIN_COND_STRMATCH, |vm, _argc| {
7352        let pat = vm.pop().to_str();
7353        let s = vm.pop().to_str();
7354        let mut pat_tok = pat.clone();
7355        crate::ported::glob::tokenize(&mut pat_tok);
7356        if crate::ported::pattern::patcompile(
7357            &pat_tok,
7358            crate::ported::zsh_h::PAT_STATIC as i32,
7359            None,
7360        )
7361        .is_none()
7362        {
7363            // c:314 — zwarnnam(fromtest, "bad pattern: %s", right).
7364            crate::ported::utils::zwarn(&format!("bad pattern: {}", pat));
7365            COND_BAD_PATTERN.with(|c| c.set(true));
7366            return Value::Bool(false);
7367        }
7368        // Match via the shared engine so `(#b)`/`(#m)` backref and
7369        // MATCH-variable population stays in one place.
7370        Value::Bool(crate::vm_helper::glob_match_static(&s, &pat))
7371    });
7372    vm.register_builtin(BUILTIN_COND_UNKNOWN, |vm, _argc| {
7373        // c:Src/cond.c:150-188 — `zwarnnam(fromtest, "unknown condition: %s",
7374        // name)` for a `-X` op with no matching cond module. Like a cond
7375        // syntax error it yields status 2 and aborts: arm COND_BAD_PATTERN so
7376        // the downstream BUILTIN_COND_STATUS_FROM_BOOL carries the 2 across the
7377        // Bool-shaped stack and runs the shared errflag+set_last_status(2)+abort
7378        // path (c:Src/exec.c:5216-5221). Returns Bool(false) as the operand.
7379        let op = vm.pop().to_str();
7380        crate::ported::utils::zerr(&format!("unknown condition: {}", op));
7381        COND_BAD_PATTERN.with(|c| c.set(true));
7382        Value::Bool(false)
7383    });
7384    vm.register_builtin(BUILTIN_COND_STATUS_FROM_BOOL, |vm, _argc| {
7385        // `${~pat}` / `${(P)~pat}` inside a `[[ … ]]` operand flips
7386        // GLOB_SUBST on via the tilde carrier so the pattern match sees
7387        // active metacharacters. In C that flag is prefork-scoped and
7388        // gone once the operand is consumed; zshrs restores it at the
7389        // next command-dispatch boundary, but a bare `[[ … ]]` has no
7390        // trailing assignment to trigger that — so globsubst leaked ON
7391        // into the NEXT command's word expansion, filename-generating a
7392        // scalar value it should not (p10k `_p9k_set_prompt`: line 45
7393        // `[[ … != ${(P)~disabled} ]]` leaked into line 46's
7394        // `local val=$arr[idx]`, whose glob-char-laden value then hit
7395        // "no matches found" and aborted the whole prompt build →
7396        // garbled 25-line prompt / interactive hang). Consume the
7397        // carrier here: this builtin ends EVERY `[[ … ]]`, and runs
7398        // after the operands (and their pattern match) are done.
7399        consume_tilde_globsubst_carrier();
7400        let ok = vm.pop().to_int() != 0;
7401        let bad = COND_BAD_PATTERN.with(|c| {
7402            let b = c.get();
7403            c.set(false);
7404            b
7405        });
7406        if bad {
7407            // c:Src/exec.c:5216-5221 — `stat = evalcond(...);
7408            //   /* 2 indicates a syntax error. For compatibility,
7409            //      turn this into a shell error. */
7410            //   if (stat == 2) errflag |= ERRFLAG_ERROR;`
7411            // The errflag abort exits the script with lastval (2),
7412            // matching `zsh -fc '[[ x == [a- ]]; print rc=$?'`
7413            // printing nothing after the diagnostic and exiting 2.
7414            crate::ported::utils::errflag.fetch_or(
7415                crate::ported::zsh_h::ERRFLAG_ERROR,
7416                std::sync::atomic::Ordering::Relaxed,
7417            );
7418            with_executor(|exec| exec.set_last_status(2));
7419            return Value::Int(2); // c:Src/cond.c:316 `return 2;`
7420        }
7421        let status: i32 = if ok { 0 } else { 1 };
7422        // c:Src/exec.c:5216 — `lastval = evalcond(...)`: the conditional's
7423        // result IS the command's lastval, and c:Src/cond.c's evalcond
7424        // never inspects errflag while evaluating. So when a `[[ … ]]`
7425        // operand raised errflag (e.g. a nounset "parameter not set" zerr
7426        // on `${arr[99]}` under NO_UNSET), zsh STILL completes the test and
7427        // exits with the cond result; the errflag only aborts the FOLLOWING
7428        // commands. Sync the result to the executor's live lastval HERE —
7429        // the nounset site left it at a transient 1, and the next
7430        // BUILTIN_ERREXIT_CHECK reads the executor (not vm.last_status), so
7431        // without this sync `setopt NO_UNSET; [[ -z ${arr[99]} ]]` exited 1
7432        // instead of 0. The Op::SetStatus that follows sets vm.last_status;
7433        // this keeps the executor coherent with it before the abort check.
7434        with_executor(|exec| exec.set_last_status(status));
7435        Value::Int(status as i64)
7436    });
7437    vm.register_builtin(BUILTIN_USE_CMDOUTVAL_RESET, |_vm, _argc| {
7438        crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
7439        Value::Status(0)
7440    });
7441
7442    vm.register_builtin(BUILTIN_EXEC_DYNAMIC, |vm, argc| {
7443        let raw = pop_args(vm, argc);
7444        // Flatten Array entries into argv slots (matches fusevm
7445        // Op::Exec's flatten at vm.rs:1660-1665) so `${arr[@]}` /
7446        // splice expansions produce one argv slot per element.
7447        let args: Vec<String> = raw.into_iter().collect();
7448        // c:Src/subst.c paramsubst — when `${var:?msg}` or
7449        // `${var?msg}` set errflag, the expansion may produce empty
7450        // argv[0] which would fall into the EACCES/permission-denied
7451        // path below, masking the real paramsubst diagnostic with a
7452        // spurious "permission denied:" line and rc=126. Honour
7453        // errflag so the simple command ends with the paramsubst
7454        // error as the sole diagnostic, rc=1. Bug #86.
7455        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
7456            & crate::ported::zsh_h::ERRFLAG_ERROR)
7457            != 0
7458        {
7459            return Value::Status(1);
7460        }
7461        if args.is_empty() {
7462            // c:Src/exec.c:3442 — a command whose words expand to ZERO
7463            // words is a NULL command: `cmdoutval = use_cmdoutval ?
7464            // lastval : 0`. `use_cmdoutval` is set (below, in
7465            // BUILTIN_CMD_SUBST_TEXT) only when a command substitution
7466            // ran during this command's word expansion, so:
7467            //   `false; $(exit 5)`  → keep the subst status (5)
7468            //   `false; $nonexistent` → reset to 0 (null command).
7469            // The previous port unconditionally kept `$?`, so
7470            // `false; $unset` wrongly stayed 1 (A01grammar.ztst:5).
7471            let keep =
7472                crate::ported::exec::use_cmdoutval.load(std::sync::atomic::Ordering::Relaxed) != 0;
7473            let status = if keep { vm.last_status } else { 0 };
7474            crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
7475            return Value::Status(status);
7476        }
7477        if args[0].is_empty() {
7478            // Explicit empty command word — exec returns EACCES.
7479            let script_name =
7480                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
7481            let lineno: u64 = with_executor(|exec| {
7482                exec.scalar("LINENO")
7483                    .and_then(|s| s.parse::<u64>().ok())
7484                    .unwrap_or(1)
7485            });
7486            eprintln!("{}:{}: permission denied: ", script_name, lineno);
7487            return Value::Status(126);
7488        }
7489        // AOP intercepts (zshrs extension, no C counterpart) — same
7490        // gate as host_exec_external (the static-head path): dynamic
7491        // command names (`cmd=/bin/echo; $cmd payload`) must consult
7492        // registered intercepts before dispatch, else `intercept
7493        // before /bin/echo ...` fires for the literal spelling but
7494        // not the variable one. run_intercepts runs before-advice
7495        // in-place and returns None to continue; Some(status) means
7496        // an around/after advice fully handled the command.
7497        let intercepted = with_executor(|exec| {
7498            if exec.intercepts.is_empty() {
7499                return None;
7500            }
7501            let full_cmd = if args.len() == 1 {
7502                args[0].clone()
7503            } else {
7504                args.join(" ")
7505            };
7506            let rest: Vec<String> = args[1..].to_vec();
7507            exec.run_intercepts(&args[0], &full_cmd, &rest)
7508        });
7509        if let Some(result) = intercepted {
7510            return Value::Status(result.unwrap_or(127));
7511        }
7512        // c:Src/exec.c:2900 execcmd_exec — canonical simple-command
7513        // dispatcher. Runs precmd-modifier walk (c:3013-3091), then
7514        // dispatches to execbuiltin (c:4233) / runshfunc (c:3431+) /
7515        // execute (c:4314) per the resolved head. zshrs's bytecode VM
7516        // expanded the args before reaching here; we feed them in via
7517        // eparams.args and let execcmd_exec do the rest exactly as C
7518        // does for static heads. Without this, `c=builtin; $c source X`
7519        // skipped the precmd walk and emitted "command not found:
7520        // builtin".
7521        let mut state = crate::ported::zsh_h::estate {
7522            prog: Box::<crate::ported::zsh_h::eprog>::default(),
7523            pc: 0,
7524            strs: None,
7525            strs_offset: 0,
7526        };
7527        let mut eparams = crate::ported::zsh_h::execcmd_params {
7528            args: Some(args),
7529            redir: None,
7530            beg: 0,
7531            varspc: None,
7532            assignspc: None,
7533            typ: crate::ported::zsh_h::WC_SIMPLE as i32,
7534            postassigns: 0,
7535            htok: 0,
7536        };
7537        // input/output=0 → no pipe redirection (use shell stdio
7538        // directly); `output != 0` at c:2988 forks immediately. last1=2
7539        // (c:Src/exec.c:2014 `last1 ? 1 : 2`): terminal pipe stage but
7540        // the shell IS needed afterward — the VM keeps executing
7541        // bytecode after this op. last1=1 would arm the fake-exec
7542        // optimization (c:3646-3651, gate at c:3662 `last1 != 1`),
7543        // making `execute()` execve THIS process for external heads:
7544        // `p=/bin/echo; $p hi; echo after` replaced the shell and
7545        // `after` never ran (D04parameter chunk 11 shell-killer).
7546        // c:Src/exec.c:1690-1700 — execpline's job frame: save thisjob
7547        // (`pj = thisjob`) and allocate the jobtab slot that
7548        // execcmd_fork's addproc (c:2853) hangs the child pid off.
7549        // Without a live thisjob, the fork at c:3662 (last1 != 1 →
7550        // external must fork) registers no proc, nothing waits, and
7551        // the child races the rest of the script.
7552        let pj = {
7553            use crate::ported::jobs;
7554            *jobs::THISJOB
7555                .get_or_init(|| std::sync::Mutex::new(-1))
7556                .lock()
7557                .unwrap_or_else(|e| e.into_inner())
7558        };
7559        let newjob = {
7560            use crate::ported::jobs;
7561            let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
7562            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
7563            jobs::initjob(&mut tab) // c:1700 `thisjob = newjob = initjob()`
7564        };
7565        {
7566            use crate::ported::jobs;
7567            *jobs::THISJOB
7568                .get_or_init(|| std::sync::Mutex::new(-1))
7569                .lock()
7570                .unwrap_or_else(|e| e.into_inner()) = newjob as i32;
7571        }
7572        crate::ported::exec::execcmd_exec(
7573            &mut state,
7574            &mut eparams,
7575            0,                                   // input  (c:2989)
7576            0,                                   // output (c:2988)
7577            crate::ported::zsh_h::Z_SYNC as i32, // how
7578            2,                                   // last1=2 — shell continues (c:2014)
7579            -1,                                  // close_if_forked
7580        );
7581        // c:Src/exec.c:1828-1835 — execpline's Z_SYNC tail: waitjobs()
7582        // reaps the forked external. c:Src/jobs.c:487-495 + 551-552 —
7583        // the job's LAST proc sets lastval (0200|sig when signalled,
7584        // else WEXITSTATUS). Builtin/shfunc heads never forked (job
7585        // has no procs) — LASTVAL was already set by execbuiltin /
7586        // doshfunc inside execcmd_exec; skip the wait.
7587        {
7588            use crate::ported::jobs;
7589            let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
7590            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
7591            if jobs::hasprocs(&tab, newjob) {
7592                jobs::waitjobs(&mut tab, newjob); // c:1835
7593                if let Some(p) = tab[newjob].procs.last() {
7594                    let val = if p.is_signaled() {
7595                        0o200 | p.term_sig() // c:Src/jobs.c:489-490
7596                    } else {
7597                        p.exit_status() // c:Src/jobs.c:494
7598                    };
7599                    crate::ported::builtin::LASTVAL
7600                        .store(val, std::sync::atomic::Ordering::Relaxed);
7601                }
7602            }
7603            // c:1977-1979 — `deletejob(jn, 0)` once done; c:1981
7604            // `thisjob = pj` restores the caller's job.
7605            if newjob < tab.len() {
7606                jobs::deletejob(&mut tab[newjob], false);
7607            }
7608            *jobs::THISJOB
7609                .get_or_init(|| std::sync::Mutex::new(-1))
7610                .lock()
7611                .unwrap_or_else(|e| e.into_inner()) = pj;
7612        }
7613        let status = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
7614        let mut synth = crate::ported::zsh_h::job::default();
7615        crate::ported::jobs::waitonejob(&mut synth);
7616        Value::Status(status)
7617    });
7618    // c:Src/exec.c:3340-3364 — `< file` / `> file` with no command
7619    // word. Resolves NULLCMD/READNULLCMD at runtime then routes
7620    // through host_exec_external. Redirects are already applied by
7621    // the surrounding WithRedirectsBegin scope.
7622    vm.register_builtin(BUILTIN_NULLCMD_EXEC, |vm, argc| {
7623        let args = pop_args(vm, argc);
7624        let is_single_read = args
7625            .first()
7626            .map(|s| s != "0" && !s.is_empty())
7627            .unwrap_or(false);
7628        // c:Src/exec.c — when the surrounding redir-open failed
7629        // (e.g. `< /nonexistent`), zerr already printed the diag
7630        // and set redirect_failed. Don't invoke NULLCMD — return
7631        // status 1 like the wordcode path does.
7632        let redir_failed = with_executor(|exec| {
7633            let f = exec.redirect_failed;
7634            exec.redirect_failed = false;
7635            f
7636        });
7637        if redir_failed {
7638            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
7639            return Value::Status(1);
7640        }
7641        let nullcmd = crate::ported::params::getsparam("NULLCMD");
7642        let nc_str = nullcmd.as_deref().unwrap_or("");
7643        let nc_empty = nc_str.is_empty();
7644        // c:3340-3344 — CSHNULLCMD or no NULLCMD set → diagnostic.
7645        if nc_empty || crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLCMD) {
7646            let script_name =
7647                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
7648            let lineno: u64 = with_executor(|exec| {
7649                exec.scalar("LINENO")
7650                    .and_then(|s| s.parse::<u64>().ok())
7651                    .unwrap_or(1)
7652            });
7653            eprintln!("{}:{}: redirection with no command", script_name, lineno);
7654            return Value::Status(1);
7655        }
7656        // c:3350 — SHNULLCMD → run `:`.
7657        let cmd: String = if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHNULLCMD) {
7658            ":".to_string()
7659        } else if is_single_read {
7660            // c:3354-3359 — single REDIR_READ + READNULLCMD set → readnullcmd.
7661            let rnc = crate::ported::params::getsparam("READNULLCMD");
7662            let rnc_str = rnc.as_deref().unwrap_or("");
7663            if !rnc_str.is_empty() {
7664                rnc_str.to_string()
7665            } else {
7666                nc_str.to_string() // c:3360-3363 fallback
7667            }
7668        } else {
7669            nc_str.to_string() // c:3360-3363
7670        };
7671        let status = with_executor(|exec| exec.host_exec_external(&[cmd]));
7672        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
7673        Value::Status(status)
7674    });
7675    // c:Src/exec.c:3342 — `zerr("redirection with no command")`.
7676    // Bare prefix-keyword (`builtin`, `command`, `exec`, `noglob`,
7677    // `nocorrect`) with a redirect but no command word. Emits the
7678    // canonical diagnostic via zerr (which sets errflag) and
7679    // returns Status(1). Bug #534.
7680    vm.register_builtin(BUILTIN_REDIR_NO_CMD, |_vm, _argc| {
7681        crate::ported::utils::zerr("redirection with no command");
7682        Value::Status(1)
7683    });
7684    vm.register_builtin(BUILTIN_DEBUG_TRAP, |vm, _argc| {
7685        // c:Src/signals.c:1245 dotrap(SIGDEBUG) — fires the DEBUG
7686        // trap body once per statement. The body sees the parent
7687        // shell's $? (LASTVAL). Guard against re-entry: commands
7688        // inside the DEBUG trap body would otherwise trigger
7689        // DEBUG_TRAP recursively → stack overflow. zsh guards via
7690        // its in_trap counter; we mirror with a thread-local Cell.
7691        //
7692        // c:Src/exec.c::trapcmd — before dotrap, the C source sets
7693        // `ZSH_DEBUG_CMD` to the about-to-run command text via
7694        // `dupstring(text)`. The trap body reads the parameter;
7695        // C unsets it after the trap returns. compile_list emits
7696        // the rendered statement text as the single arg here so the
7697        // shell-visible parameter reflects the command. Bug #263 in
7698        // docs/BUGS.md.
7699        let cmd_text = vm.pop().to_str();
7700        DEBUG_TRAP_REENTRY.with(|c| {
7701            if c.get() {
7702                return Value::Status(0);
7703            }
7704            // c:Src/exec.c:1423 — `if (sigtrapped[SIGDEBUG] &&
7705            // isset(DEBUGBEFORECMD) && !intrap)`. Bug #573: without
7706            // this gate, every sublist boundary called
7707            // setsparam("ZSH_DEBUG_CMD", ...) even when no DEBUG trap
7708            // was set, polluting the param table and (under
7709            // WARN_CREATE_GLOBAL) emitting a spurious
7710            // `scalar parameter ZSH_DEBUG_CMD created globally`
7711            // warning at every function call.
7712            //
7713            // Two trap registries exist (per signals.rs:1481-1511 dotrap):
7714            //   - settrap path → sigtrapped[SIGDEBUG] bits set
7715            //   - bin_trap path → traps_table["DEBUG"] populated, sigtrapped untouched
7716            // Mirror the dotrap dispatch decision: skip only when BOTH
7717            // are absent.
7718            let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
7719            let debug_trapped = crate::ported::signals::sigtrapped
7720                .lock()
7721                .map(|v| v.get(sig_debug).copied().unwrap_or(0))
7722                .unwrap_or(0);
7723            let debug_in_table = crate::ported::builtin::traps_table()
7724                .lock()
7725                .map(|t| t.contains_key("DEBUG"))
7726                .unwrap_or(false);
7727            if debug_trapped == 0 && !debug_in_table {
7728                return Value::Status(0);
7729            }
7730            c.set(true);
7731            // c:Src/exec.c — set ZSH_DEBUG_CMD scalar (PM_READONLY
7732            // is NOT set on ZSH_DEBUG_CMD, so the canonical
7733            // setsparam path is fine here — no direct paramtab
7734            // mutation needed).
7735            crate::ported::params::setsparam("ZSH_DEBUG_CMD", &cmd_text);
7736            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGDEBUG);
7737            // c:Src/exec.c::trapcmd — `unsetparam("ZSH_DEBUG_CMD")`
7738            // after the trap returns. Mirror that.
7739            crate::ported::params::unsetparam("ZSH_DEBUG_CMD");
7740            c.set(false);
7741            Value::Status(0)
7742        })
7743    });
7744
7745    // Fatal-only abort check emitted between the pipes of an `&&` / `||`
7746    // chain, where the full errexit check is suppressed. Mirrors ONLY the
7747    // errflag arm of BUILTIN_ERREXIT_CHECK below: an errflag abandons the
7748    // list in zsh, and no connector can consume it.
7749    vm.register_builtin(BUILTIN_FATAL_ABORT_CHECK, |vm, _argc| {
7750        use std::sync::atomic::Ordering;
7751        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
7752            & crate::ported::zsh_h::ERRFLAG_ERROR)
7753            != 0;
7754        if !errflag_set || isset(crate::ported::zsh_h::INTERACTIVE) {
7755            return Value::Int(0);
7756        }
7757        // CONTINUE_ON_ERROR: clear and keep going, as the full check does.
7758        if isset(crate::ported::zsh_h::CONTINUEONERROR) {
7759            crate::ported::utils::errflag
7760                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
7761            return Value::Int(0);
7762        }
7763        // Abort the chain with the failing command's own status intact —
7764        // a cond syntax error left lastval=2 (c:Src/exec.c:5216-5221), and
7765        // that 2 is what zsh exits with. Reading the executor's live
7766        // lastval (not forcing 1) is the same rule the full check uses.
7767        vm.last_status = with_executor(|exec| exec.last_status());
7768        Value::Int(1)
7769    });
7770    vm.register_builtin(BUILTIN_ERREXIT_CHECK, |vm, _argc| {
7771        // Returns Value::Int(1) when the caller should jump to the
7772        // current scope's return-patch landing (subshell-end / func-
7773        // end / chunk-end). Returns Value::Int(0) otherwise. Emit
7774        // side at `emit_errexit_check` pairs this with a JumpIfTrue
7775        // → return_patches pattern so the caller can short-circuit.
7776        //
7777        // Four triggers:
7778        //   1. RETFLAG set by a nested `return` / `exit` (eval,
7779        //      sourced file, called function). Unwind THIS scope so
7780        //      the flag propagates outward until something clears it.
7781        //   2. EXIT_PENDING set (mostly subshell-context exits). Same
7782        //      propagation logic.
7783        //   3. `set -e` + nonzero status — the classic errexit path.
7784        //   4. errflag set in non-interactive mode — readonly
7785        //      reassign, bad redirect, parse error mid-expansion etc.
7786        //      Aborts the script (c:Src/init.c loop()).
7787        use std::sync::atomic::Ordering;
7788        let retflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
7789        let exit_pending = crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed);
7790        if retflag != 0 || exit_pending != 0 {
7791            if exit_pending != 0 {
7792                // c:Src/builtin.c zexit — the deferred exit carries its
7793                // status in EXIT_VAL; sync it into the VM counter so
7794                // the top-level unwind reports it as the script's exit
7795                // (run_chunk returns vm.last_status). Without this, a
7796                // POSIX-fatal `.` failure exited 127 (bin_dot's return)
7797                // instead of C's exit(1) at Src/exec.c:4383.
7798                vm.last_status = crate::ported::builtin::EXIT_VAL.load(Ordering::Relaxed) & 0xFF;
7799            }
7800            return Value::Int(1);
7801        }
7802        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
7803            & crate::ported::zsh_h::ERRFLAG_ERROR)
7804            != 0;
7805        // c:Src/init.c:1931 — `if (errflag && !interact &&
7806        // !isset(CONTINUEONERROR)) { errexit = 1; break; }` — with
7807        // CONTINUE_ON_ERROR set, the top-level do-while re-enters
7808        // loop() and the NEXT list runs instead of the shell exiting.
7809        // Clear the flag so the next statement starts clean (the
7810        // failed statement's lastval is already in place).
7811        if errflag_set
7812            && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
7813            && crate::ported::zsh_h::isset(crate::ported::zsh_h::CONTINUEONERROR)
7814        {
7815            crate::ported::utils::errflag
7816                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
7817            return Value::Int(0);
7818        }
7819        if errflag_set && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE) {
7820            // c:Src/exec.c execlist — every enclosing list loop runs
7821            // `while (... && !errflag)`, so a set errflag breaks the
7822            // CURRENT scope and the check in the enclosing scope
7823            // breaks THAT one, all the way out. Leave errflag SET —
7824            // do NOT convert it to EXIT_PENDING: a process-exit
7825            // signal tunnels through the containment boundaries C
7826            // has, namely eval (Src/builtin.c:6221 `errflag &=
7827            // ~ERRFLAG_ERROR`), source (Src/init.c:1663 same), fork
7828            // boundaries (subshell/cmdsubst — child's errflag dies
7829            // with the child), and the interactive toplevel
7830            // (Src/init.c:139). Those boundaries clear errflag
7831            // themselves and execution continues past them; with
7832            // EXIT_PENDING armed here, `eval 'assoc=(odd)'; echo
7833            // after` aborted the whole script where zsh 5.9 prints
7834            // `after` (eval status 1). Bug #74's function case
7835            // (`f() { local -r x=5; x=10; }; f; echo after`) still
7836            // aborts: the function scope unwinds on THIS check, and
7837            // the caller's next ERREXIT_CHECK sees the still-set
7838            // errflag and unwinds too — exactly C's propagation.
7839            //
7840            // c:Src/init.c:234 — loop() BREAKS on errflag and
7841            // zsh_main exits with the UNTOUCHED lastval, NOT a
7842            // forced 1: `typeset -i x=3#8` (math error during the
7843            // assignment, before typeset sets a status) exits 0 in
7844            // zsh; a cond syntax error set lastval=2 (exec.c:5216-
7845            // 5221) and zsh exits 2; the readonly-reassign case
7846            // exits 1 because ITS lastval is 1. Sync the VM counter
7847            // from the executor's live lastval instead of
7848            // overwriting.
7849            vm.last_status = with_executor(|exec| exec.last_status());
7850            return Value::Int(1);
7851        }
7852        let last = vm.last_status;
7853        if last == 0 {
7854            return Value::Int(0);
7855        }
7856        // c:Src/exec.c:1598 `if (!this_noerrexit && !donetrap &&
7857        // !this_donetrap)` — gate the ZERR trap fire on DONETRAP so
7858        // an inner sublist (e.g. `false` inside a function) that
7859        // already fired ZERR doesn't fire it AGAIN at the outer
7860        // sublist's post-command check (after the function
7861        // returned non-zero). Bug #303 in docs/BUGS.md. DONETRAP
7862        // is reset at top-level statement boundaries via
7863        // BUILTIN_DONETRAP_RESET (compile_list emit at
7864        // compile_zsh.rs).
7865        let already_done = crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
7866        if !already_done {
7867            // c:Src/signals.c:1245 dotrap(SIGZERR) — canonical ZERR
7868            // trap dispatch. Fires whenever a command exits
7869            // non-zero.
7870            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR);
7871            // c:1602 — `donetrap = 1;` after firing.
7872            crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed);
7873        }
7874        // c:Src/exec.c:1605-1610 — compute errreturn / errexit.
7875        //   errreturn = ERRRETURN && (INTERACTIVE || locallevel || sourcelevel)
7876        //               && !(noerrexit & NOERREXIT_RETURN)
7877        //   errexit   = (ERREXIT || (ERRRETURN && !errreturn))
7878        //               && !(noerrexit & NOERREXIT_EXIT)
7879        let no_err = crate::ported::exec::noerrexit.load(Ordering::Relaxed);
7880        let locallvl = crate::ported::params::locallevel.load(Ordering::Relaxed);
7881        let sourcelvl = crate::ported::init::sourcelevel.load(Ordering::Relaxed);
7882        let errreturn_opt = isset(crate::ported::zsh_h::ERRRETURN);
7883        let in_unwindable_scope =
7884            isset(crate::ported::zsh_h::INTERACTIVE) || locallvl != 0 || sourcelvl != 0;
7885        let errreturn = errreturn_opt
7886            && in_unwindable_scope
7887            && (no_err & crate::ported::zsh_h::NOERREXIT_RETURN) == 0;
7888        if errreturn {
7889            // c:1620-1623 — `retflag = 1; breaks = loops;` — unwind to
7890            // function boundary without exiting the shell.
7891            crate::ported::builtin::RETFLAG.store(1, Ordering::Relaxed);
7892            let loops = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
7893            crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed);
7894            return Value::Int(1);
7895        }
7896        let (errexit_on, in_subshell) = with_executor(|exec| {
7897            let on_canonical = isset(ERREXIT) || (errreturn_opt && !errreturn); // c:1608-1609
7898            let on_legacy = opt_state_get("errexit").unwrap_or(false);
7899            (
7900                (on_canonical || on_legacy) && (no_err & crate::ported::zsh_h::NOERREXIT_EXIT) == 0,
7901                !exec.subshell_snapshots.is_empty(),
7902            )
7903        });
7904        if !errexit_on {
7905            return Value::Int(0);
7906        }
7907        // c:Src/exec.c — `set -e` fires shell exit, NOT a function-only
7908        // unwind. When LOCAL_OPTIONS restores the option mid-fn, the
7909        // restoration would otherwise mask the trigger and let the
7910        // outer scope continue. Setting EXIT_PENDING + EXIT_VAL here
7911        // (for ALL scope kinds, not just subshells) makes the fn-exit
7912        // path propagate to the shell-exit boundary at c:6135-6155.
7913        crate::ported::builtin::EXIT_VAL.store(last, Ordering::Relaxed);
7914        crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
7915        let _ = in_subshell;
7916        // Function scope and top-level scope both branch to their
7917        // respective return_patches; top-level lands at chunk-end,
7918        // so execute_script returns `last` as the script's exit
7919        // status (same observable behavior as a process::exit).
7920        Value::Int(1)
7921    });
7922
7923    // BUILTIN_ASSIGN_ONLY_STATUS — status of an assignment-only
7924    // simple command. c:Src/exec.c:3393-3396 (execcmd_exec, no
7925    // command word + varspc): `if (errflag) lastval = 1; else
7926    // lastval = cmdoutval;`; same shape at c:1322 (execsimple
7927    // WC_ASSIGN: `lv = (errflag ? errflag : cmdoutval)`) and
7928    // c:3977 (nullexec=2 redir variant). cmdoutval is the exit of
7929    // a `$()` that ran in an RHS (already in vm.last_status via
7930    // compile_assign's per-assign SetStatus), 0 otherwise. The
7931    // store goes to the canonical LASTVAL too — that IS C's single
7932    // `lastval` global; without it the errflag-abort path
7933    // (BUILTIN_ERREXIT_CHECK trigger 4) syncs vm.last_status from
7934    // a stale LASTVAL and `readonly r=1; r=2` exited 0, not 1.
7935    vm.register_builtin(BUILTIN_ASSIGN_ONLY_STATUS, |vm, _argc| {
7936        use std::sync::atomic::Ordering;
7937        let had_cmd_subst = vm.pop().to_int() != 0;
7938        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
7939            & crate::ported::zsh_h::ERRFLAG_ERROR)
7940            != 0;
7941        // c:Src/exec.c addvars — `if (!pm) { lastval = 1; if
7942        // (!cmdoutval) cmdoutval = 1; }` (assignment-failed cheat).
7943        let assign_failed = ASSIGN_FAILED_FLAG.swap(false, std::sync::atomic::Ordering::Relaxed);
7944        let status = if errflag_set || assign_failed {
7945            1 // c:Src/exec.c:3394 `lastval = 1` / addvars cmdoutval=1
7946        } else if had_cmd_subst {
7947            vm.last_status // c:3396 `lastval = cmdoutval` (subst exit)
7948        } else {
7949            0 // c:3396 `lastval = cmdoutval` (cmdoutval = 0)
7950        };
7951        with_executor(|exec| exec.set_last_status(status));
7952        Value::Status(status)
7953    });
7954
7955    // `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
7956    // Pops [name, op_byte, rhs] (rhs popped first). Returns the modified
7957    // value as Value::Str. Handles unset/empty distinction (`:-` etc.
7958    // treat empty same as unset, matching POSIX).
7959    // BUILTIN_PARAM_DEFAULT_FAMILY — `${var-x}` / `${var:-x}` / `${var=x}` /
7960    // `${var:=x}` / `${var?x}` / `${var:?x}` / `${var+x}` / `${var:+x}`.
7961    // PURE PASSTHRU: pop name + op + rhs, reconstruct the canonical
7962    // brace expression, hand to `subst::paramsubst` (C port of
7963    // `Src/subst.c::paramsubst`). All "missing vs empty" gating,
7964    // nounset suppression, default-evaluation, and elide-empty-words
7965    // semantics live inside paramsubst.
7966    vm.register_builtin(BUILTIN_PARAM_DEFAULT_FAMILY, |vm, _argc| {
7967        let rhs = vm.pop().to_str();
7968        let op = vm.pop().to_int() as u8;
7969        let name = vm.pop().to_str();
7970        // op=8 is the `${+name}` set-test prefix form (distinct from the
7971        // `${name+rhs}` substitute-if-set suffix form which is op=7).
7972        // Per compile_zsh.rs::parse_param_modifier: the `+` is emitted as
7973        // a leading sigil and `rhs` is empty.
7974        let body = if op == 8 {
7975            format!("${{+{}}}", name)
7976        } else {
7977            let op_str = match op {
7978                0 => ":-",
7979                1 => ":=",
7980                2 => ":?",
7981                3 => ":+",
7982                4 => "-",
7983                5 => "=",
7984                6 => "?",
7985                7 => "+",
7986                _ => "-",
7987            };
7988            format!("${{{}{}{}}}", name, op_str, rhs)
7989        };
7990        paramsubst_to_value(&body)
7991    });
7992
7993    // `${var:offset[:length]}` — substring. Pops [name, offset, length].
7994    // length == -1 means "rest of string". Negative offset counts from end.
7995    // BUILTIN_PARAM_SUBSTRING — `${var:offset:length}` literal-int form.
7996    // PURE PASSTHRU: reconstruct `${name:offset:length}` and route
7997    // through `subst::paramsubst`. Length sentinel `i64::MIN` =
7998    // "no length given" (omit the `:length` portion).
7999    //
8000    // c:Src/subst.c:1571,3781 — `${name:-N}` is the colon-default
8001    // operator, NOT a substring with negative offset. zsh's lexical
8002    // rule disambiguates via a literal space: `${name: -N}` (space
8003    // before `-`) is the substring form. The reconstructed body MUST
8004    // preserve that space when offset < 0; otherwise paramsubst's
8005    // `:-` dispatch fires on the synthesized `${name:-N}` body and
8006    // returns N as the unset-default instead of slicing the last N
8007    // chars. Length-form `${name:-N:M}` has the same trap.
8008    vm.register_builtin(BUILTIN_PARAM_SUBSTRING, |vm, _argc| {
8009        let length = vm.pop().to_int();
8010        let offset = vm.pop().to_int();
8011        let name = vm.pop().to_str();
8012        let off_sep = if offset < 0 { " " } else { "" };
8013        let body = if length == i64::MIN {
8014            format!("${{{}:{}{}}}", name, off_sep, offset)
8015        } else {
8016            format!("${{{}:{}{}:{}}}", name, off_sep, offset, length)
8017        };
8018        paramsubst_to_value(&body)
8019    });
8020
8021    // BUILTIN_PARAM_SUBSTRING_EXPR — `${var:offset_expr[:length_expr]}` form.
8022    // PURE PASSTHRU: rebuild `${name:offset:length}` using the
8023    // expression text verbatim (paramsubst's offset/length
8024    // parser evaluates arith / param refs itself).
8025    //
8026    // c:Src/subst.c:1571,3781 — same `:-` disambiguation trap as
8027    // BUILTIN_PARAM_SUBSTRING. The expression text may itself start
8028    // with `-` (e.g. `${VAR:$((-1))}` arith resolves at the body-
8029    // assembly layer in some upstream paths, leaving `-1` in
8030    // off_expr). Insert a leading space when off_expr starts with
8031    // `-` so paramsubst's check_colon_subscript (subst.c:1571)
8032    // accepts the operand as a math expression instead of the
8033    // `:-` operator catching it.
8034    vm.register_builtin(BUILTIN_PARAM_SUBSTRING_EXPR, |vm, _argc| {
8035        let has_len = vm.pop().to_int() != 0;
8036        let len_expr = vm.pop().to_str();
8037        let off_expr = vm.pop().to_str();
8038        let name = vm.pop().to_str();
8039        let off_sep = if off_expr.starts_with('-') { " " } else { "" };
8040        let body = if has_len {
8041            format!("${{{}:{}{}:{}}}", name, off_sep, off_expr, len_expr)
8042        } else {
8043            format!("${{{}:{}{}}}", name, off_sep, off_expr)
8044        };
8045        paramsubst_to_value(&body)
8046    });
8047
8048    // `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}`
8049    // Pops [name, pattern, op_byte]. op: 0=`#` short-prefix, 1=`##` long,
8050    // 2=`%` short-suffix, 3=`%%` long. Glob-pattern matching via the
8051    // existing glob_match_static helper.
8052    // BUILTIN_PARAM_STRIP — `${var#pat}` / `${var##pat}` / `${var%pat}` /
8053    // `${var%%pat}`. PURE PASSTHRU: reconstruct the brace expression
8054    // and route through `subst::paramsubst`. (M)/(S) flags arrive
8055    // through SUB_FLAGS (already inside paramsubst's scope), so we
8056    // just clear the bridge-side cached read.
8057    vm.register_builtin(BUILTIN_PARAM_STRIP, |vm, _argc| {
8058        let _dq_flag = vm.pop().to_int() != 0;
8059        let op = vm.pop().to_int() as u8;
8060        let pattern = vm.pop().to_str();
8061        let name = vm.pop().to_str();
8062        let op_str = match op {
8063            0 => "#",
8064            1 => "##",
8065            2 => "%",
8066            3 => "%%",
8067            _ => "#",
8068        };
8069        let body = format!("${{{}{}{}}}", name, op_str, pattern);
8070        paramsubst_to_value(&body)
8071    });
8072
8073    // `$((expr))` — pops [expr_string], evaluates via MathEval which
8074    // honors integer-vs-float distinction (zsh-compatible). Returns
8075    // the result as Value::Str so it can be Concat'd into surrounding
8076    // word context.
8077    vm.register_builtin(BUILTIN_ARITH_EVAL, |vm, _argc| {
8078        // Pure path: evaluate expr, return string. errflag may be
8079        // set by arithsubst on math error; the caller decides
8080        // whether to clear it. For `(( ... ))` (math command) the
8081        // compile_arith path clears via BUILTIN_ARITH_CMD_FINISH;
8082        // for `$((... ))` (substitution inside another command)
8083        // errflag stays set so the surrounding command aborts —
8084        // matches c:Src/math.c "math errors propagate as errflag
8085        // through the containing word expansion".
8086        let expr = vm.pop().to_str();
8087        let result = crate::ported::subst::arithsubst(&expr, "", "");
8088        let _ = vm; // silence unused warning when no math error path mutates
8089        Value::str(result)
8090    });
8091
8092    // After-call hook used by compile_arith's `(( ... ))` path: when
8093    // arithsubst set errflag (math error), clear it and signal
8094    // status=2 in vm.last_status — matches zsh's c:exec.c arith-
8095    // failure: the math command exits 2 and the script continues.
8096    vm.register_builtin(BUILTIN_ARITH_CMD_FINISH, |vm, _argc| {
8097        use std::sync::atomic::Ordering;
8098        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
8099        let err = live & crate::ported::zsh_h::ERRFLAG_ERROR;
8100        let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
8101        if err != 0 {
8102            // c:Src/subst.c:3344 — when `${var:?msg}` fires, errflag
8103            // is OR'd with ERRFLAG_HARD to signal a script-abort
8104            // error (vs a recoverable math error like `$((1/0))`).
8105            // Clear only the ERRFLAG_ERROR bit; preserve
8106            // ERRFLAG_HARD so the next ERREXIT_CHECK aborts the
8107            // script. Bug #193 in docs/BUGS.md.
8108            if hard != 0 {
8109                // Keep ERRFLAG_HARD AND ERRFLAG_ERROR set so the
8110                // script-abort gate downstream still fires.
8111                vm.last_status = 2;
8112                Value::Status(2)
8113            } else {
8114                crate::ported::utils::errflag
8115                    .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
8116                vm.last_status = 2;
8117                Value::Status(2)
8118            }
8119        } else {
8120            Value::Status(vm.last_status)
8121        }
8122    });
8123
8124    // `$(cmd)` — pops [cmd_string], routes through
8125    // run_command_substitution which performs an in-process pipe-capture.
8126    // Avoids the Op::CmdSubst sub-chunk word-emit bug
8127    // (`printf "a\nb"` produced "anb" via that path). Returns trimmed
8128    // output (trailing newlines stripped per POSIX cmd-sub semantics).
8129    vm.register_builtin(BUILTIN_CMD_SUBST_TEXT, |vm, _argc| {
8130        let cmd = vm.pop().to_str();
8131        // Inherit live $? into the inner shell so cmd-subst sees the
8132        // parent's most recent exit. Same rationale as the mode-3
8133        // backtick path above.
8134        let live_status = vm.last_status;
8135        let result = with_executor(|exec| {
8136            exec.set_last_status(live_status);
8137            exec.run_command_substitution(&cmd)
8138        });
8139        // Mirror run_command_substitution's exec.last_status side
8140        // effect into the VM's live counter so a containing
8141        // assignment's BUILTIN_SET_VAR — which reads vm.last_status
8142        // — sees the cmd-subst's exit. Without this, `a=$(false);
8143        // echo $?` reads stale 0 (vm.last_status was zeroed by
8144        // compile_assign's prelude SetStatus, and run_cmd_subst only
8145        // updated exec.last_status). Pull the value back through
8146        // exec since it owns the canonical post-subst record.
8147        let cs_status = with_executor(|exec| exec.last_status());
8148        vm.last_status = cs_status;
8149        // c:Src/exec.c — a command substitution running during a
8150        // command's word expansion makes its exit the status of an
8151        // otherwise-empty command (`$(exit 5)` → 5). Flag it so
8152        // BUILTIN_EXEC_DYNAMIC's null-command branch keeps `$?` instead
8153        // of resetting to 0.
8154        crate::ported::exec::use_cmdoutval.store(1, std::sync::atomic::Ordering::Relaxed);
8155        Value::str(result)
8156    });
8157
8158    // Text-based word expansion. Pops [preserved_text, mode_byte].
8159    // mode_byte:
8160    //   0 = Default — expand_string + xpandbraces + expand_glob
8161    //   1 = DoubleQuoted — strip outer `"…"`, expand_string only
8162    //         (no brace, no glob — DQ semantics)
8163    //   2 = SingleQuoted — strip outer `'…'`, no expansion
8164    //         (kept for symmetry; Snull early-return covers most SQ)
8165    //   3 = AltBackquote — strip backticks, run as cmd-sub
8166    //   7 = RedirTarget — same as Default but glob gated on MULTIOS
8167    //         (c:Src/glob.c:2161-2167 xpandredir)
8168    // Single result → Value::str; multi → Value::Array.
8169    vm.register_builtin(BUILTIN_EXPAND_TEXT, |vm, _argc| {
8170        let mode = vm.pop().to_int() as u8;
8171        let text = vm.pop().to_str();
8172        // Sync vm.last_status → exec.last_status so cmd-subst (mode 3)
8173        // and any nested $? reads inside singsub see the live `$?`
8174        // from the most recent VM op. Without this, cmd-subst inside
8175        // arg-eval saw a stale exec.last_status that was zeroed at
8176        // the start of the current statement. Direct port of zsh's
8177        // pre-cmdsubst lastval propagation per Src/exec.c:4770.
8178        let live_status = vm.last_status;
8179        with_executor(|exec| exec.set_last_status(live_status));
8180        let result_value = with_executor(|exec| match mode {
8181            // Mode 1 = DoubleQuoted (argument context).
8182            // Mode 5 = DoubleQuoted in scalar-assignment context.
8183            // Both share the same DQ unescape pre-processing; mode 5
8184            // additionally bumps `in_scalar_assign` so subst_port's
8185            // paramsubst sees ssub=true and suppresses split flags
8186            // `(f)` / `(s:STR:)` / `(0)` per Src/subst.c:1759 +
8187            // Src/exec.c::addvars line 2546 (the PREFORK_SINGLE bit
8188            // C zsh sets when prefork-ing the assignment RHS).
8189            1 | 5 => {
8190                // DoubleQuoted: strip outer `"…"` if present. In DQ
8191                // context, `\` escapes the DQ-special chars `$`, `` ` ``,
8192                // `"`, `\`. zsh's expand_string expects the lexer's
8193                // `\0X` literal-marker for an already-escaped char, so
8194                // we pre-process: `\$` → `\0$`, `\\` → `\0\`, etc. Then
8195                // expand_string handles the rest.
8196                let inner = if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
8197                    &text[1..text.len() - 1]
8198                } else {
8199                    text.as_str()
8200                };
8201                // The lexer's dquote_parse (Src/lex.c) already tokenized
8202                // DQ contents: `$` → Qstring (\u{8c}), `\$`/`\\`/`\"`/
8203                // `` \` `` → Bnull (\u{9f}) + literal. Stringsubst /
8204                // multsub recognize these markers natively. We pass
8205                // `inner` through verbatim — no re-tokenization needed.
8206                let prepped: String = inner.to_string();
8207                // Tell parameter-flag application that we're inside
8208                // double quotes — array-only flags ((o), (O), (n),
8209                // (i), (M), (u)) must be no-ops here per zsh.
8210                exec.in_dq_context += 1;
8211                if mode == 5 {
8212                    exec.in_scalar_assign += 1;
8213                }
8214                // Mode 1 = argv DQ word; mode 5 = scalar-assign RHS.
8215                // In C zsh, the corresponding prefork-on-list paths
8216                // are: argv → `prefork(argv_list, 0)` returns multi-
8217                // word LinkList (Src/exec.c::execcmd), assignment →
8218                // `prefork(rhs_list, PREFORK_SINGLE|PREFORK_ASSIGN)`
8219                // returns single-word (Src/exec.c::addvars line
8220                // 2546). zshrs's `multsub` (Src/subst.c:544) is the
8221                // multi-result variant; `singsub` (Src/subst.c:514)
8222                // asserts ≤1 node. Mode 5 keeps singsub; mode 1
8223                // switches to multsub so `"${(@)arr}"`/`"$@"`/
8224                // `"${arr[@]}"` in argv context emit multiple words
8225                // as the C path would.
8226                // c:Src/lex.c untokenize — the final argv pass C runs
8227                // on every expanded word (glob.c:1862 / exec.c) drops
8228                // the Nularg empty-word sentinel remnulargs left in
8229                // place and folds any remaining token chars. Without
8230                // it, quoted splits with empty pieces
8231                // ("${(s:|:)x}" on "|a|b|") leak U+00A1 into argv.
8232                let result_value = if mode == 5 {
8233                    let out = crate::ported::subst::singsub(&prepped);
8234                    Value::str(crate::ported::lex::untokenize(&out))
8235                } else {
8236                    let (_first, nodes, _ms_ws, _ret) = crate::ported::subst::multsub(&prepped, 0);
8237                    // c:Src/subst.c:655 — multsub returns Vec::new()
8238                    // for zero-word results (quoted array splat that
8239                    // resolved to empty array). Surface as
8240                    // Value::Array(vec![]) so the downstream array
8241                    // assignment / argv flattening sees ZERO args.
8242                    // Previous Rust port returned Value::str("") which
8243                    // surfaced as ONE empty arg. Bug #120 in
8244                    // docs/BUGS.md.
8245                    if nodes.is_empty() {
8246                        Value::Array(Vec::new())
8247                    } else if nodes.len() == 1 {
8248                        Value::str(crate::ported::lex::untokenize(
8249                            &nodes.into_iter().next().unwrap(),
8250                        ))
8251                    } else {
8252                        Value::Array(
8253                            nodes
8254                                .into_iter()
8255                                .map(|n| Value::str(crate::ported::lex::untokenize(&n)))
8256                                .collect(),
8257                        )
8258                    }
8259                };
8260                if mode == 5 {
8261                    exec.in_scalar_assign -= 1;
8262                }
8263                exec.in_dq_context -= 1;
8264                result_value
8265            }
8266            2 => {
8267                // SingleQuoted: pure literal, strip outer `'…'`.
8268                let inner = if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
8269                    &text[1..text.len() - 1]
8270                } else {
8271                    text.as_str()
8272                };
8273                Value::str(inner.to_string())
8274            }
8275            3 => {
8276                // Backquote command sub: strip outer backticks.
8277                // Word-split the result on IFS when the surrounding
8278                // word is unquoted — zsh: `print -l \`echo a b c\``
8279                // emits one arg per word. The $(…) path applies the
8280                // same split via BUILTIN_WORD_SPLIT after capture; do
8281                // the equivalent here for the `…` form.
8282                let inner = if text.len() >= 2 && text.starts_with('`') && text.ends_with('`') {
8283                    &text[1..text.len() - 1]
8284                } else {
8285                    text.as_str()
8286                };
8287                // Apply the live VM status before running the inner
8288                // shell so the inherited $? matches zsh's lastval
8289                // propagation.
8290                exec.set_last_status(live_status);
8291                let captured = exec.run_command_substitution(inner);
8292                let trimmed = captured.trim_end_matches('\n');
8293                if exec.in_dq_context > 0 {
8294                    Value::str(trimmed.to_string())
8295                } else {
8296                    let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
8297                    let parts: Vec<Value> = trimmed
8298                        .split(|c: char| ifs.contains(c))
8299                        .filter(|s| !s.is_empty())
8300                        .map(|s| Value::str(s.to_string()))
8301                        .collect();
8302                    if parts.is_empty() {
8303                        Value::str(String::new())
8304                    } else if parts.len() == 1 {
8305                        parts.into_iter().next().unwrap()
8306                    } else {
8307                        Value::Array(parts)
8308                    }
8309                }
8310            }
8311            4 => {
8312                // HeredocBody: expand variables / command-subst / arith
8313                // but NOT glob or brace. Heredoc lines like `[42]` must
8314                // pass through verbatim — running them through the
8315                // default pipeline triggers NOMATCH on the literal.
8316                Value::str(crate::ported::subst::singsub(&text))
8317            }
8318            _ => {
8319                // Default (unquoted): the lexer's gettokstr already
8320                // tokenized backslash-escapes (`\$` → Bnull+$, etc).
8321                // Pass `text` through verbatim — multsub/stringsubst
8322                // recognize the markers natively. No bridge-side
8323                // re-tokenization needed.
8324                //
8325                // Mode 6 = unquoted RHS in scalar-assign context.
8326                // Pass PREFORK_ASSIGN so prefork's filesub colon-walk
8327                // fires per c:Src/exec.c:2546.
8328                let prepped: String = text.clone();
8329                if std::env::var("ZSHRS_TRACE_DEFP").is_ok() {
8330                    eprintln!(
8331                        "[TRACE_DEFP] text={:?} prepped={:?} mode={}",
8332                        text, prepped, mode
8333                    );
8334                }
8335                let pf_flags = if mode == 6 {
8336                    crate::ported::zsh_h::PREFORK_ASSIGN
8337                } else {
8338                    0
8339                };
8340                // c:Src/subst.c:544+ — `multsub(&prepped, 0)` is the
8341                // unquoted-argv equivalent of zsh's `prefork(list,
8342                // 0, NULL)` for a single-element list. Returns the
8343                // post-expansion node list (Vec<String>) so array-
8344                // shape results (e.g. `${a:e}`, `${a[@]}`,
8345                // `${(s::)str}`) splat into multiple argv words.
8346                // singsub() collapses to one string and discards the
8347                // splat — parity bug #28 (whole-array modifier).
8348                let (_first, nodes, _ms_ws, _ret) =
8349                    crate::ported::subst::multsub(&prepped, pf_flags);
8350                if std::env::var("ZSHRS_TRACE_MULTSUB").is_ok() {
8351                    eprintln!("[TRACE_MULTSUB] prepped={:?} nodes={:?}", prepped, nodes);
8352                }
8353                // c:Src/subst.c:166 — xpandbraces runs AFTER prefork's
8354                // substitution pass and BEFORE untokenize/glob. Per
8355                // word, scan for Inbrace TOKEN and expand. Words that
8356                // don't contain Inbrace TOKEN pass through unchanged.
8357                // Brace expansion is done here (inside the bridge
8358                // default arm) instead of via a post-EXPAND_TEXT
8359                // BRACE_EXPAND emit because untokenize (line below)
8360                // strips TOKEN bytes, after which the strict-TOKEN
8361                // xpandbraces gate would no longer match.
8362                let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
8363                // c:Src/options.c — `no_brace_expand` (negated
8364                // `braceexpand`) gates brace expansion entirely.
8365                // When off, `{a,b}` stays literal.
8366                let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
8367                let pre_brace: Vec<String> = if nodes.is_empty() {
8368                    vec![String::new()]
8369                } else {
8370                    nodes
8371                };
8372                let brace_expanded: Vec<String> = pre_brace
8373                    .into_iter()
8374                    .flat_map(|w| {
8375                        if brace_expand && w.contains('\u{8f}') {
8376                            crate::ported::glob::xpandbraces(&w, brace_ccl)
8377                        } else {
8378                            vec![w]
8379                        }
8380                    })
8381                    .collect();
8382                // zsh stores the option as `glob` (default ON);
8383                // `setopt noglob` writes `glob=false`. Honor either
8384                // form so the dispatcher behaves the same as zsh.
8385                // Mode 7 = redirect-target word: glob only under
8386                // MULTIOS (c:Src/glob.c:2161-2167 xpandredir,
8387                // "Globbing is only done for multios.").
8388                let noglob = opt_state_get("noglob").unwrap_or(false)
8389                    || opt_state_get("GLOB").map(|v| !v).unwrap_or(false)
8390                    || !opt_state_get("glob").unwrap_or(true)
8391                    || (mode == 7 && !opt_state_get("multios").unwrap_or(true));
8392                let parts: Vec<String> = brace_expanded
8393                    .into_iter()
8394                    .flat_map(|s| {
8395                        // The lexer leaves glob metacharacters in their
8396                        // META-encoded form: `*` → `\u{87}`, `?` →
8397                        // `\u{86}`, `[` → `\u{91}`, etc. expand_string
8398                        // doesn't untokenize them, so the literal-char
8399                        // checks below (`s.contains('*')`) would miss
8400                        // every real glob and skip expand_glob — that
8401                        // bug let `echo *.toml` print the literal
8402                        // `*.toml` because the META `\u{87}` never
8403                        // matched the literal `*`. Untokenize once so
8404                        // the metacharacter checks see the canonical
8405                        // form. zsh's pattern.c expects `*` etc. as
8406                        // bare chars at the glob layer.
8407                        // c:Src/pattern.c:4306 haswilds on the still-
8408                        // TOKENIZED word (pre-untokenize), matching C's
8409                        // zglob entry gate (Src/glob.c:1230) which runs
8410                        // on the lexer-tokenized string. haswilds
8411                        // matches ONLY token codes: source-level
8412                        // `*.toml` carries Star and fires; bare literal
8413                        // `[`/`*`/`?` from `$'...'` decode, `:-`
8414                        // default values, or nested-substitution
8415                        // results were never shtokenize'd (C
8416                        // subst.c:3231 sets globsubst=0 in the `:-`
8417                        // arm) and stay literal — bug #625. Plain
8418                        // multibyte text (`↔`) never matches a token
8419                        // codepoint — bug #627.
8420                        let is_glob_pre = !noglob && crate::ported::pattern::haswilds(&s);
8421                        let s = crate::lex::untokenize(&s);
8422                        // Skip glob expansion for assignment-shaped
8423                        // words (`NAME=value`). zsh doesn't expand the
8424                        // RHS of an assignment as a path glob unless
8425                        // `setopt globassign` is set, and feeding such
8426                        // words through expand_glob makes NOMATCH
8427                        // (default ON) fire spuriously on
8428                        // `integer i=2*3+1`, `path=*.rs`, etc.
8429                        let is_assignment_shape = {
8430                            let bytes = s.as_bytes();
8431                            let mut i = 0;
8432                            if !bytes.is_empty()
8433                                && (bytes[0] == b'_' || bytes[0].is_ascii_alphabetic())
8434                            {
8435                                i += 1;
8436                                while i < bytes.len()
8437                                    && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric())
8438                                {
8439                                    i += 1;
8440                                }
8441                                i < bytes.len() && bytes[i] == b'='
8442                            } else {
8443                                false
8444                            }
8445                        };
8446                        // Glob-trigger decision: pre-untokenize
8447                        // haswilds_tokens_only result (computed above
8448                        // before the untokenize that collapses META
8449                        // tokens to their ASCII forms). The TOKEN-only
8450                        // gate matches C `Src/pattern.c:4306-4376`
8451                        // exactly — only Inbrack/Star/Quest/Inpar/Bar/
8452                        // Inang/Pound/Hat token codes count as wild,
8453                        // not their literal ASCII counterparts. Source-
8454                        // level `*.toml` carries Star token so globs;
8455                        // `$'…'`-decoded `[abc]` carries bare `[` so
8456                        // stays literal. Bug #625.
8457                        if is_glob_pre && !is_assignment_shape {
8458                            exec.expand_glob(&s)
8459                        } else if is_assignment_shape
8460                            && crate::ported::zsh_h::isset(crate::ported::zsh_h::MAGICEQUALSUBST)
8461                        {
8462                            // c:Src/exec.c:3353 — when MAGIC_EQUAL_SUBST is set
8463                            // on a non-typeset command, esprefork = PREFORK_TYPESET,
8464                            // so every NAME=value arg runs through
8465                            // filesub(PREFORK_TYPESET): the `~`/`=` after the
8466                            // first `=` (and after each `:`) undergo filename
8467                            // expansion. `print foo=~/bar` → `foo=$HOME/bar`.
8468                            // filesubstr (subst.c:741) keys on the Tilde TOKEN,
8469                            // not literal `~`; this `s` was already untokenized
8470                            // above, so re-tokenize (as BUILTIN_MAGIC_EQUALS_PREFORK
8471                            // does) before filesub, then untokenize the result.
8472                            let mut tokd = s.clone();
8473                            crate::ported::glob::shtokenize(&mut tokd);
8474                            let exp = crate::ported::subst::filesub(
8475                                &tokd,
8476                                crate::ported::zsh_h::PREFORK_TYPESET,
8477                            );
8478                            vec![crate::lex::untokenize(&exp).to_string()]
8479                        } else {
8480                            vec![s]
8481                        }
8482                    })
8483                    .collect();
8484                if parts.len() == 1 {
8485                    let only = parts.into_iter().next().unwrap_or_default();
8486                    // Empty unquoted expansion → drop the arg entirely
8487                    // (zsh "remove empty unquoted words" rule). Returning
8488                    // an empty Value::Array makes pop_args contribute zero
8489                    // items. Direct port of subst.c's empty-elide pass at
8490                    // the end of multsub which removes empty linknodes
8491                    // from unquoted contexts. Quoted DQ/SQ paths (modes
8492                    // 1/2/5) take separate arms above and always emit
8493                    // Value::Str so the empty arg survives.
8494                    if only.is_empty() {
8495                        Value::Array(Vec::new())
8496                    } else {
8497                        Value::str(only)
8498                    }
8499                } else {
8500                    Value::Array(parts.into_iter().map(Value::str).collect())
8501                }
8502            }
8503        });
8504        // Pull any inner cmd-subst (`` `cmd` `` via mode 3 or via
8505        // mode 0/6 multsub → getoutput, `$(cmd)` via the default
8506        // arm's multsub path, nested `$()`s reached through
8507        // stringsubst) back into vm.last_status so a containing
8508        // assignment's BUILTIN_SET_VAR — which reads vm.last_status —
8509        // sees the cmd-subst's exit. Without this, backtick
8510        // assignments (`a=\`false\`; echo $?`) reported 0 because the
8511        // ported LASTVAL update never reached the VM-side counter.
8512        let cs_status = with_executor(|exec| exec.last_status());
8513        vm.last_status = cs_status;
8514        result_value
8515    });
8516
8517    // `${#name}` — pops [name]. Returns the value's element count for
8518    // arrays (indexed and assoc) or character length for scalars.
8519    // BUILTIN_PARAM_LENGTH — `${#name}`. PURE PASSTHRU.
8520    vm.register_builtin(BUILTIN_PARAM_LENGTH, |vm, _argc| {
8521        let name = vm.pop().to_str();
8522        // PARAM_LENGTH's empty-result semantics differ from
8523        // paramsubst_to_value: 0 nodes → "0" (numeric length), not
8524        // empty array. paramsubst on `${#X}` always returns at least
8525        // one node in practice (the length string); the empty case
8526        // is defensive.
8527        let mut ret_flags: i32 = 0;
8528        let (_full, _pos, nodes) = crate::ported::subst::paramsubst(
8529            &format!("${{#{}}}", name),
8530            0,
8531            false,
8532            0i32,
8533            &mut ret_flags,
8534        );
8535        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
8536            with_executor(|exec| exec.set_last_status(1));
8537        }
8538        if nodes.is_empty() {
8539            Value::str("0")
8540        } else {
8541            nodes_to_value(nodes)
8542        }
8543    });
8544
8545    // `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
8546    // `${var/%pat/repl}` — Pops [name, pattern, replacement, op_byte].
8547    // op: 0=first, 1=all, 2=anchor-prefix (`/#`), 3=anchor-suffix (`/%`).
8548    // BUILTIN_PARAM_REPLACE — `${var/pat/repl}` / `${var//pat/repl}` /
8549    // `${var/#pat/repl}` / `${var/%pat/repl}`. PURE PASSTHRU.
8550    vm.register_builtin(BUILTIN_PARAM_REPLACE, |vm, _argc| {
8551        let dq_flag = vm.pop().to_int() != 0;
8552        let op = vm.pop().to_int() as u8;
8553        let repl = vm.pop().to_str();
8554        let pattern = vm.pop().to_str();
8555        let name = vm.pop().to_str();
8556        // DQ context: C's lexer marks every `$` inside double quotes
8557        // as the Qstring token (Src/lex.c dquote_parse) and keeps `'`
8558        // a plain char — so a DQ replacement's `$'…'` is LITERAL in
8559        // C (Src/subst.c:301 decodes only the tokenized Snull form;
8560        // `"${a/X/$'\0'}"` keeps the five chars `$'\0'`). The body
8561        // rebuilt below re-enters stringsubst as raw text, which
8562        // would mis-decode `$'…'` as ANSI-C; stamp the Qstring
8563        // marker on the repl's `$` so stringsubst sees the same DQ
8564        // signal C's tokens carry. The PATTERN side keeps decoding
8565        // (matches observed zsh: the pattern's `$'\0'` matches a
8566        // real NUL while the repl's stays literal).
8567        let repl = if dq_flag {
8568            repl.replace('$', "\u{8c}")
8569        } else {
8570            repl
8571        };
8572        // op encoding: 0 = first `/`, 1 = all `//`, 2 = anchor-prefix
8573        // `/#`, 3 = anchor-suffix `/%`. The brace form distinguishes
8574        // first-vs-all by single vs doubled slash, and anchored by
8575        // a `#` or `%` immediately after the slash(es).
8576        let body = match op {
8577            0 => format!("${{{}/{}/{}}}", name, pattern, repl),
8578            1 => format!("${{{}//{}/{}}}", name, pattern, repl),
8579            2 => format!("${{{}/#{}/{}}}", name, pattern, repl),
8580            3 => format!("${{{}/%{}/{}}}", name, pattern, repl),
8581            _ => format!("${{{}/{}/{}}}", name, pattern, repl),
8582        };
8583        // c:Src/subst.c:1625 — paramsubst's qt flag. The compiler
8584        // threads the word's DQ context onto the stack; dropping it
8585        // (the old `let _dq_flag`) ran the rebuilt body with qt=false
8586        // whenever the opcode fired outside an EXPAND_TEXT scope, so
8587        // DQ-only semantics inside the replacement (e.g. `$'` staying
8588        // literal per Src/subst.c:301 — `"${a/x/$'\t'q}"`) were lost.
8589        // Bump in_dq_context exactly like EXPAND_TEXT mode 1 so
8590        // paramsubst_to_value's qt probe sees the right context.
8591        if dq_flag {
8592            with_executor(|exec| exec.in_dq_context += 1);
8593        }
8594        let ret = paramsubst_to_value(&body);
8595        if dq_flag {
8596            with_executor(|exec| exec.in_dq_context -= 1);
8597        }
8598        ret
8599    });
8600
8601    vm.register_builtin(BUILTIN_REGISTER_COMPILED_FN, |vm, argc| {
8602        let args = pop_args(vm, argc);
8603        let mut iter = args.into_iter();
8604        let name = iter.next().unwrap_or_default();
8605        let body_b64 = iter.next().unwrap_or_default();
8606        let body_source = iter.next().unwrap_or_default();
8607        let line_base_str = iter.next().unwrap_or_default();
8608        let line_base: i64 = line_base_str.parse().unwrap_or(0);
8609        let bytes = base64_decode(&body_b64);
8610        let status = match bincode::deserialize::<fusevm::Chunk>(&bytes) {
8611            Ok(chunk) => with_executor(|exec| {
8612                // c:Src/exec.c:5383 — `shf->filename =
8613                // ztrdup(scriptfilename);` — the function's
8614                // definition-file is read from the canonical
8615                // file-scope `scriptfilename` global at compile
8616                // time, NOT from a per-executor struct field.
8617                // exec.scriptfilename is seeded once at
8618                // bins/zshrs.rs:1717 to the bin basename ("zsh")
8619                // and never updates on source/dot, so reading from
8620                // it left every user function's def_file as "zsh".
8621                // Route through scriptfilename_get() so source /
8622                // dot's set_scriptfilename calls propagate.
8623                let def_file = crate::ported::utils::scriptfilename_get()
8624                    .or_else(|| exec.scriptfilename.clone());
8625                if !body_source.is_empty() {
8626                    exec.function_source
8627                        .insert(name.clone(), body_source.clone());
8628                }
8629                exec.function_line_base.insert(name.clone(), line_base);
8630                exec.function_def_file.insert(name.clone(), def_file);
8631                // PFA-SMR aspect: every `name() {}` / `function name { }`
8632                // funnels through here at compile time. Emit one record
8633                // with the function name + raw body source.
8634                #[cfg(feature = "recorder")]
8635                if crate::recorder::is_enabled() {
8636                    let ctx = exec.recorder_ctx();
8637                    let body = if body_source.is_empty() {
8638                        None
8639                    } else {
8640                        Some(body_source.as_str())
8641                    };
8642                    crate::recorder::emit_function(&name, body, ctx);
8643                }
8644                // Mirror into canonical shfunctab so scanfunctions /
8645                // ${(k)functions} / functions builtin see user defs.
8646                // C: exec.c:funcdef → shfunctab->addnode(ztrdup(name),shf).
8647                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
8648                    let mut shf = crate::ported::hashtable::shfunc_with_body(&name, &body_source);
8649                    // c:Src/exec.c:5409 — `shf->lineno = lineno;`. Use
8650                    // the same max(1, line_base) clamp as the synth_shf
8651                    // in vm_helper::dispatch_function_call. Bug #396.
8652                    shf.lineno = std::cmp::max(1, line_base);
8653                    tab.add(shf);
8654                }
8655                // c:Src/exec.c:5460-5475 — `TRAP<SIG>() { ... }` is the
8656                // function-named trap install. zsh detects the `TRAP`
8657                // prefix at func-def time and calls
8658                // `settrap(signum, NULL, ZSIG_FUNC)` so the next
8659                // dispatch of that signal routes to the named shfunc.
8660                // Bug #157 in docs/BUGS.md — fusevm_bridge's funcdef
8661                // opcode skipped this dispatch entirely, so TRAPEXIT /
8662                // TRAPUSR1 / TRAPZERR / TRAPDEBUG never fired.
8663                if name.len() > 4 && name.starts_with("TRAP") {
8664                    if let Some(sn) = crate::ported::jobs::getsigidx(&name[4..]) {
8665                        let _ = crate::ported::signals::settrap(
8666                            sn,
8667                            None,
8668                            crate::ported::zsh_h::ZSIG_FUNC as i32,
8669                        );
8670                    }
8671                }
8672                exec.functions_compiled.insert(name, chunk);
8673                0
8674            }),
8675            Err(_) => 1,
8676        };
8677        Value::Status(status)
8678    });
8679
8680    // Wire the ShellHost so direct shell ops (Op::Glob, Op::TildeExpand,
8681    // Op::ExpandParam, Op::CmdSubst, Op::CallFunction, etc.) route through
8682    // ZshrsHost back into the executor.
8683    vm.set_shell_host(Box::new(ZshrsHost));
8684}
8685
8686impl ZshrsHost {
8687    /// True iff `c` can be a `(j:…:)` / `(s:…:)` delimiter — non-alphanumeric,
8688    /// non-underscore. Restricting to punctuation avoids `(jL)` consuming `L`
8689    /// as a delim instead of as the next flag.
8690    fn is_zsh_flag_delim(c: char) -> bool {
8691        !c.is_ascii_alphanumeric() && c != '_'
8692    }
8693}
8694
8695/// Shared `${name[idx]}` subscript dispatch for BUILTIN_ARRAY_INDEX
8696/// and the KSHARRAYS-unset arm of BUILTIN_ARRAY_INDEX_UNBRACED.
8697///
8698/// c:Src/subst.c subscript parsing — when paramsubst re-parses the
8699/// synthesized `${name[idx]}` body, characters like `'` `"` `\` `$`
8700/// etc. are LEXER-active inside the `[…]` and get reinterpreted
8701/// (quote-strip, paramsubst recursion, …). For PRE-EVALUATED key
8702/// strings (the dynamic-key fast path at compile_zsh.rs:3234 already
8703/// expanded `$k` via EXPAND_TEXT), the idx is a literal string that
8704/// must match the stored key byte-for-byte — no further
8705/// reinterpretation. Direct assoc lookup bypasses the lexer for this
8706/// case, avoiding the quote-strip bug where `h[a'b]` failed to
8707/// resolve because paramsubst's subscript lexer treated the `'` as a
8708/// quote. Bug #338. Only fires for simple assoc-name + non-flag idx
8709/// (no outer-flag sentinels, no `(…)` flag prefix on idx, no splat
8710/// operator). Other paths (slice, splat, flag-based search,
8711/// magic-assoc) still flow through paramsubst.
8712fn array_index_lookup(name: &str, idx: &str) -> Value {
8713    let idx_is_simple = !idx.starts_with('(') && idx != "@" && idx != "*" && !idx.contains(',');
8714    if idx_is_simple {
8715        if let Some(v) = with_executor(|exec| exec.assoc(name).and_then(|m| m.get(idx).cloned())) {
8716            return Value::str(v);
8717        }
8718    }
8719    let body = format!("${{{}[{}]}}", name, idx);
8720    paramsubst_to_value(&body)
8721}
8722
8723/// KSHARRAYS bare-`$name` expansion words for the unbraced
8724/// no-subscript form (BUILTIN_ARRAY_INDEX_UNBRACED's KSHARRAYS arm).
8725///
8726/// - `@` / `*` stay the full positional list (the c:Src/params.c:
8727///   2293-2296 first-element collapse is gated on
8728///   `itype_end(t, IIDENT, 1) != t` — an identifier-shaped name —
8729///   which `@`/`*` are not). The literal `[idx]` then joins the LAST
8730///   word, matching zsh 5.9: `setopt ksharrays; set -- p q;
8731///   print -- $@[0]` → `zsh:1: no matches found: q[0]`.
8732/// - Identifier-named arrays collapse to the FIRST element
8733///   (c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`).
8734/// - Assocs collapse to the first value in scan order; the `options`
8735///   magic assoc's scan order is `OPTIONTAB` bucket order (first key
8736///   `posixargzero`), matching zsh 5.9: `emulate sh -L;
8737///   print $options[posixargzero]` → `off[posixargzero]`.
8738/// - Scalars / unset names expand to their value / empty (zsh 5.9:
8739///   `setopt ksharrays; print -- $unsetvar[0]` →
8740///   `zsh:1: no matches found: [0]`).
8741fn ksharrays_bare_words(name: &str) -> Vec<String> {
8742    if name == "@" || name == "*" {
8743        return with_executor(|exec| exec.pparams());
8744    }
8745    // Magic special-parameter lookups first — mirrors the
8746    // BUILTIN_GET_VAR precedence (partab before executor tables).
8747    if let Some(vals) = crate::vm_helper::partab_array_get(name) {
8748        return vec![vals.into_iter().next().unwrap_or_default()];
8749    }
8750    if let Some(keys) = crate::vm_helper::partab_scan_keys(name) {
8751        let v = keys
8752            .first()
8753            .and_then(|k| crate::vm_helper::partab_get(name, k))
8754            .unwrap_or_default();
8755        return vec![v];
8756    }
8757    let arr_or_assoc = with_executor(|exec| {
8758        if let Some(arr) = exec.array(name) {
8759            // c:Src/params.c:2293-2296 — first element only.
8760            return Some(arr.first().cloned().unwrap_or_default());
8761        }
8762        if let Some(map) = exec.assoc(name) {
8763            // Mirrors BUILTIN_GET_VAR's bare-assoc ordering
8764            // (sorted keys, first value).
8765            let mut keys: Vec<&String> = map.keys().collect();
8766            keys.sort();
8767            return Some(
8768                keys.first()
8769                    .and_then(|k| map.get(*k).cloned())
8770                    .unwrap_or_default(),
8771            );
8772        }
8773        None
8774    });
8775    if let Some(v) = arr_or_assoc {
8776        return vec![v];
8777    }
8778    vec![with_executor(|exec| exec.get_variable(name))]
8779}
8780
8781/// Run `body` through `crate::ported::subst::paramsubst` and convert
8782/// the resulting node list into a fusevm `Value`. Centralises the
8783/// pattern duplicated across ~10 BUILTIN_* handlers:
8784///   - build a `${...}` body string from opcode operands
8785///   - paramsubst the body
8786///   - propagate errflag to `exec.last_status`
8787///   - delegate the LinkList → Value conversion to `nodes_to_value`
8788///
8789/// **Extension** — Rust-only helper. No direct C analog because C
8790/// zsh uses LinkList everywhere; the conversion happens at the
8791/// boundary back into the VM's stack.
8792fn paramsubst_to_value(body: &str) -> Value {
8793    // c:Src/subst.c:1625 paramsubst's `qt` flag is the C signal that
8794    // the current expansion is inside `"…"`. The fast-path bridges
8795    // (BUILTIN_PARAM_*, BUILTIN_BRIDGE_BRACE_ARRAY) used to hardcode
8796    // qt=false, which silently broke DQ-only semantics inside
8797    // `${arr:^other}` / `${arr:^^other}` (Src/subst.c:3456-3520).
8798    // The executor's `in_dq_context` counter is bumped by EXPAND_TEXT
8799    // mode 1 / mode 5 before the bridge fires, so reading it here
8800    // propagates the DQ flag without changing every bridge call site.
8801    let qt = with_executor(|exec| exec.in_dq_context > 0);
8802    let mut ret_flags: i32 = 0;
8803    let (_full, _pos, nodes) = crate::ported::subst::paramsubst(body, 0, qt, 0i32, &mut ret_flags);
8804    if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
8805        with_executor(|exec| exec.set_last_status(1));
8806    }
8807    // c:Src/lex.c untokenize — the final argv pass C runs on every
8808    // expanded word (glob.c:1862 / exec.c) DROPS the Nularg
8809    // empty-word sentinel (c:2089 `if (c != Nularg)`) that
8810    // remnulargs faithfully leaves in place (glob.c:3673 re-adds it
8811    // for all-empty results). These fast-path bridges are terminal —
8812    // their output lands directly in argv slots — so apply it here.
8813    // Without it, quoted splits with empty pieces ("${(s:|:)x}" on
8814    // "|a|b|") leak U+00A1 into argv.
8815    let nodes: Vec<String> = nodes
8816        .into_iter()
8817        .map(|n| crate::ported::lex::untokenize(&n))
8818        .collect();
8819    nodes_to_value(nodes)
8820}
8821
8822/// Wrap a `Vec<String>` (e.g. paramsubst nodes, multsub parts,
8823/// xpandbraces output) into a fusevm `Value`: 0 → empty Array, 1 →
8824/// Str, >1 → Array. Same unwrap idiom every handler that calls a
8825/// canonical Vec-returning fn does.
8826fn nodes_to_value(nodes: Vec<String>) -> Value {
8827    // c:Src/glob.c:3649 remnulargs — strip the Nularg (`\u{a1}`)
8828    //   sentinel and other INULL bytes that paramsubst's splat block
8829    //   emits for empty array elements (so prefork's empty-node-delete
8830    //   pass doesn't drop them). Downstream consumers (cond `-z`/`-n`,
8831    //   command args, etc.) must see the post-remnulargs strings. Bug
8832    //   #185 in docs/BUGS.md: `[[ -z "${b[@]}" ]]` for b=("") returned
8833    //   false because the leftover `\u{a1}` had StringLen=1.
8834    let stripped: Vec<String> = nodes
8835        .into_iter()
8836        .map(|mut s| {
8837            crate::ported::glob::remnulargs(&mut s);
8838            s
8839        })
8840        .collect();
8841    if stripped.is_empty() {
8842        Value::Array(Vec::new())
8843    } else if stripped.len() == 1 {
8844        let only = stripped.into_iter().next().unwrap();
8845        // c:Src/subst.c:183-186 — `else if (!(flags & PREFORK_SINGLE)
8846        // && !(*ret_flags & PREFORK_KEY_VALUE) && !keep)
8847        //   uremnode(list, node);`
8848        // C zsh's prefork removes empty linknodes from the result
8849        // list when in non-SINGLE (argv-context) mode. The ported
8850        // prefork at subst.rs:388-396 honors the same delete-empty
8851        // pass, but some paramsubst paths land here with a single-
8852        // empty-string Vec instead of an empty Vec (paramsubst's
8853        // slice / substring / parameter-flag branches allocate a
8854        // result before checking emptiness). Mirror the prefork
8855        // drop at this layer: single-empty under !in_dq_context
8856        // collapses to Value::Array(empty), and pop_args (line 6243)
8857        // splats the empty Array → zero argv words. DQ context
8858        // (in_dq_context > 0) keeps the empty string so
8859        // `echo "${UNSET}"` still produces an empty arg per zsh's
8860        // quoting rules (c:Src/subst.c:1650-1656 isarr comment).
8861        if only.is_empty() {
8862            let in_dq = with_executor(|exec| exec.in_dq_context > 0);
8863            if !in_dq {
8864                return Value::Array(Vec::new());
8865            }
8866        }
8867        Value::str(only)
8868    } else {
8869        Value::Array(stripped.into_iter().map(Value::str).collect())
8870    }
8871}
8872
8873fn pop_args(vm: &mut fusevm::VM, argc: u8) -> Vec<String> {
8874    let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
8875    for _ in 0..argc {
8876        popped.push(vm.pop());
8877    }
8878    popped.reverse();
8879    let mut args: Vec<String> = Vec::with_capacity(popped.len());
8880    for v in popped {
8881        match v {
8882            Value::Array(items) => {
8883                for item in items {
8884                    args.push(item.to_str());
8885                }
8886            }
8887            other => args.push(other.to_str()),
8888        }
8889    }
8890    // `expand_glob` set the glob-failed cell when a no-match glob
8891    // triggered nomatch (c:Src/glob.c:1877). Signal the failure via
8892    // last_status + the per-command glob_failed cell; the dispatcher
8893    // (`host_exec_external`) consumes + clears it and returns status 1
8894    // without running the command body.
8895    if with_executor(|exec| exec.current_command_glob_failed.get()) {
8896        with_executor(|exec| exec.set_last_status(1));
8897    }
8898    // `$_` tracks the last argument of the PREVIOUSLY executed
8899    // command (zsh / bash convention). Promote the deferred value
8900    // into `$_` BEFORE this command runs (so `echo $_` reads the
8901    // prior command's last arg) then stash THIS command's last arg
8902    // for the next dispatch.
8903    let new_last = args.last().cloned();
8904    with_executor(|exec| {
8905        if let Some(prev) = exec.pending_underscore.take() {
8906            exec.set_scalar("_".to_string(), prev);
8907        }
8908        if let Some(last) = new_last {
8909            exec.pending_underscore = Some(last);
8910        }
8911    });
8912    args
8913}
8914
8915/// zsh dispatch order is alias → function → builtin → external. The
8916/// compiler emits direct CallBuiltin ops for known builtin names for
8917/// perf, which silently skips a user function that shadows the same
8918/// name (e.g. `echo() { ... }; echo hi` would run the C builtin
8919/// without this check). Returns Some(status) when the call is routed
8920/// to the user function; the builtin handler should fall through to
8921/// its native impl when None.
8922/// Fork+exec a system binary by name. Used by `reg_overridable!` as
8923/// the fall-through path when `[builtins].coreutils_shadows = off`
8924/// (the default) — runs the canonical `/bin/X` instead of zshrs's
8925/// in-process shadow so old scripts hit zero behavioral divergence.
8926///
8927/// Inherits stdin/stdout/stderr from the parent so pipelines work
8928/// transparently. Resolves the binary via PATH; mirrors what zsh's
8929/// own external-command dispatch would do. Returns the child's exit
8930/// status (or 127 if PATH lookup fails — the standard "command not
8931/// found" code).
8932fn exec_system_command(name: &str, args: &[String]) -> i32 {
8933    // c:Src/jobs.c — count the fork so `time` reports for an
8934    // overridable coreutils shadow run as an external (`time sleep 0`,
8935    // `time cat …`). This is a distinct spawn path from
8936    // execute_external_bg; without the bump BUILTIN_TIME_SUBLIST saw no
8937    // job and stayed silent. (Builtins that don't reach a spawn never
8938    // hit this fn.)
8939    crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
8940    let status = std::process::Command::new(name)
8941        .args(args)
8942        .stdin(std::process::Stdio::inherit())
8943        .stdout(std::process::Stdio::inherit())
8944        .stderr(std::process::Stdio::inherit())
8945        .status();
8946    match status {
8947        Ok(s) => s.code().unwrap_or(if s.success() { 0 } else { 1 }),
8948        Err(e) => {
8949            eprintln!("zshrs: {}: {}", name, e);
8950            127
8951        }
8952    }
8953}
8954
8955fn try_user_fn_override(name: &str, args: &[String]) -> Option<i32> {
8956    let has_fn = with_executor(|exec| {
8957        exec.functions_compiled.contains_key(name) || exec.function_exists(name)
8958    });
8959    if !has_fn {
8960        return None;
8961    }
8962    Some(with_executor(|exec| {
8963        exec.dispatch_function_call(name, args).unwrap_or(127)
8964    }))
8965}
8966
8967/// Builtin ID for `${name}` reads — routes through canonical
8968/// `getsparam` (Src/params.c:3076) via paramtab + env walk so nested
8969/// VMs (function calls) see the same storage.
8970pub const BUILTIN_GET_VAR: u16 = 283;
8971
8972/// Like `BUILTIN_GET_VAR` but forces double-quoted (DQ) semantics on
8973/// the read regardless of the runtime `in_dq_context`. The compiler
8974/// emits this for a QUOTED simple-var read (`"$name"`) — those compile
8975/// to a direct GET_VAR with no EXPAND_TEXT wrapper, so `in_dq_context`
8976/// is 0 and the plain GET_VAR would wrongly word-elide an array's empty
8977/// elements (`a=(1 "" 3); "$a"` must keep the empty → `1  3`, not
8978/// `1 3`). With force_dq the array joins via sepjoin keeping empties and
8979/// a scalar is returned verbatim (no empty-drop, no SH_WORD_SPLIT).
8980pub const BUILTIN_GET_VAR_DQ: u16 = 639;
8981
8982/// Builtin ID for `name=value` assignments — pops [name, value] and
8983/// routes through canonical `setsparam` (Src/params.c:3350).
8984pub const BUILTIN_SET_VAR: u16 = 284;
8985
8986/// Builtin ID that sets the thread-local [`SET_VAR_GLOB_ELIGIBLE`] flag true.
8987/// Emitted by the compiler immediately before a `BUILTIN_SET_VAR` whose scalar
8988/// RHS carried an UNQUOTED glob token, so the runtime knows the RHS is a literal
8989/// glob pattern eligible for GLOB_ASSIGN. Takes no stack args, pushes nothing.
8990pub const BUILTIN_MARK_GLOB_ELIGIBLE: u16 = 640;
8991
8992/// Builtin ID for pipeline execution. Pops N sub-chunk indices from the stack;
8993/// each index points into `vm.chunk.sub_chunks` (compiled stage bodies). Forks
8994/// N children, wires stdin/stdout between them via pipes, runs each stage's
8995/// bytecode on a fresh VM in its child, parent waits for all and pushes the
8996/// last stage's exit status. This is bytecode-native pipeline execution —
8997/// no tree-walker delegation.
8998pub const BUILTIN_RUN_PIPELINE: u16 = 285;
8999
9000/// Builtin ID for `Array → String` joining. Pops one value: if it's an Array,
9001/// joins its string-coerced elements with a single space; otherwise passes
9002/// through. Used after `Op::Glob` to convert the pattern's matched paths into
9003/// the single argv-token form the bytecode word model expects (no per-word
9004/// splitting yet — that's a future phase).
9005pub const BUILTIN_ARRAY_JOIN: u16 = 286;
9006
9007/// Builtin ID for `cmd &` background execution. IDs 287/288/289 are reserved
9008/// for the planned array work in Phase G1 (SET_ARRAY/SET_ASSOC/ARRAY_INDEX),
9009/// so this lands at 290. Pops the sub-chunk index then the job text; forks;
9010/// child detaches (`setsid`), runs the sub-chunk on a fresh VM, exits with
9011/// last_status; parent registers the job in the canonical JOBTAB
9012/// (initjob/addproc/spawnjob per c:Src/exec.c:1700-1758) so `jobs` / `wait
9013/// %N` / `kill %N` / `disown` and the zsh/parameter assocs all see it, then
9014/// returns Status(0) immediately.
9015pub const BUILTIN_RUN_BG: u16 = 290;
9016
9017/// Indexed-array assignment: `arr=(a b c)`. Compile_simple emits N element
9018/// pushes followed by name push, then `CallBuiltin(BUILTIN_SET_ARRAY, N+1)`.
9019/// The handler pops args (last popped = name in our pushing order) and stores
9020/// `Vec<String>` into `executor.arrays`. Tree-walker callers see the same
9021/// storage. Any prior scalar binding in `executor.variables` for `name` is
9022/// removed so `${name}` (scalar context) consistently reflects the array's
9023/// first element via `get_variable`.
9024pub const BUILTIN_SET_ARRAY: u16 = 287;
9025
9026/// Single-key set on an associative array: `foo[key]=val`. Stack (top-down):
9027/// [name, key, value]. Stores `value` into `executor.assoc_arrays[name][key]`,
9028/// creating the outer entry if missing. compile_simple detects `var[...]=...`
9029/// in assignments and emits this builtin.
9030pub const BUILTIN_SET_ASSOC: u16 = 288;
9031
9032/// `${arr[idx]}` — single-element array index. Pops two args:
9033///   stack: [name, idx_str]
9034/// Returns the indexed element as Value::str. Indexing semantics: zsh is
9035/// 1-based by default; bash is 0-based. We follow zsh.
9036/// Special idx values: `@` and `*` return the whole array as Value::Array
9037/// (which fuses correctly via the Op::Exec splice for argv splice).
9038pub const BUILTIN_ARRAY_INDEX: u16 = 289;
9039
9040/// `${#arr[@]}` and `${#arr}` (when arr is an array name) — array length.
9041/// Pops one arg: name. Returns Value::str of len.
9042
9043/// `${arr[@]}` — splice all elements as a Value::Array. Pops one arg: name.
9044/// The Array gets flattened by Op::Exec/ExecBg/CallFunction into argv.
9045pub const BUILTIN_ARRAY_ALL: u16 = 292;
9046
9047/// Flatten one level of Value::Array nesting. Pops N values; for each, if it's
9048/// a Value::Array, its elements are appended directly; otherwise the value is
9049/// appended as-is. Pushes a single Value::Array of the flattened result. Used
9050/// by the for-loop word-list compile path: when a word like `${arr[@]}`
9051/// produces a nested Array, this lets `for i in ${arr[@]}` iterate over the
9052/// inner elements rather than the outer single-element array.
9053pub const BUILTIN_ARRAY_FLATTEN: u16 = 293;
9054
9055/// `coproc [name] { body }` — bidirectional pipe to async child. Pops a name
9056/// (optional, "" for default) and a sub-chunk index. Creates two pipes, forks,
9057/// child redirects its fd 0/1 to the inner ends and runs the body, parent
9058/// stores [write_fd, read_fd] into the named array (default `COPROC`). Caller
9059/// closes the fds and `wait`s when done. Job-table integration deferred to
9060/// Phase G6 alongside the bg `&` work.
9061pub const BUILTIN_RUN_COPROC: u16 = 294;
9062
9063/// `arr+=(d e f)` — append N elements to an existing indexed array. Compile
9064/// emits N element pushes + name push, then `CallBuiltin(295, N+1)`. Handler
9065/// drains args (last popped = name), extends `executor.arrays[name]` (creates
9066/// the entry if missing). Mirrors zsh's `+=` semantics for indexed arrays.
9067pub const BUILTIN_APPEND_ARRAY: u16 = 295;
9068
9069/// `name[@]=(...)` / `name[*]=(...)` whole-array SET. Identical to
9070/// BUILTIN_SET_ARRAY for an indexed array / scalar (whole replace), but
9071/// rejects an associative target with "attempt to set slice of
9072/// associative array" (c:Src/params.c:3324-3327).
9073pub const BUILTIN_SET_ARRAY_AT: u16 = 633;
9074
9075/// `name[@]+=(...)` / `name[*]+=(...)` whole-array APPEND. Indexed
9076/// append (push), assoc target → same slice-of-assoc error as 633.
9077pub const BUILTIN_APPEND_ARRAY_AT: u16 = 634;
9078
9079/// `select var in words; do body; done` — interactive numbered-menu loop.
9080/// Compile emits N word pushes + var-name push + sub-chunk index push, then
9081/// `CallBuiltin(296, N+2)`. Handler prints `1) word1\n2) word2\n...` to
9082/// stderr, prints `$PROMPT3` (default `?# `) to stderr, reads a line from
9083/// stdin. On EOF returns 0. On a valid 1-based number, sets `var` to the
9084/// chosen word, runs the sub-chunk, then redisplays the menu and loops. On
9085/// invalid input redraws the menu without running the body. `break` from
9086/// inside the body exits the loop (handled by the body's own bytecode).
9087pub const BUILTIN_RUN_SELECT: u16 = 296;
9088
9089/// `m[k]+=value` — append onto an existing assoc-array value (string concat).
9090/// If the key doesn't exist, behaves like SET_ASSOC. Stack: [name, key, value].
9091
9092/// `break` from inside a body that runs on a sub-VM (select, future
9093/// loop-via-builtin constructs). Writes the canonical
9094/// `crate::ported::builtin::BREAKS` atomic (port of `Src/loop.c:46
9095/// breaks`). Outer-loop builtins drain BREAKS/CONTFLAG after each
9096/// body run, matching the loop.c:529-534 drain pattern.
9097pub const BUILTIN_SET_BREAK: u16 = 299;
9098
9099/// `continue` from inside a sub-VM body. Sets CONTFLAG=1 + bumps
9100/// BREAKS, matching `bin_break`'s WC_CONTINUE arm at Src/builtin.c
9101/// c:5836 `contflag = 1; FALLTHROUGH; breaks++;`.
9102pub const BUILTIN_SET_CONTINUE: u16 = 300;
9103
9104/// Brace expansion: `{a,b,c}` → 3 values, `{1..5}` → 5 values, `{01..05}` →
9105/// zero-padded numerics, `{a..e}` → letter range. Pops one string, returns
9106/// Value::Array of expansions (empty array → original string preserved).
9107pub const BUILTIN_BRACE_EXPAND: u16 = 301;
9108
9109/// Glob qualifier filter: `*(qualifier)` filters glob results by predicate.
9110/// Pops [pattern, qualifier_string]. Returns Value::Array of matching paths.
9111
9112/// Re-export the regex_match host method as a builtin so `[[ s =~ pat ]]`
9113/// works even when fusevm's Op::RegexMatch isn't routed (compat fallback).
9114
9115/// Word-split a string on IFS (default: whitespace). Pops one string,
9116/// returns Value::Array of fields. Used in array-literal context where
9117/// `arr=($(cmd))` should expand cmd's stdout into multiple elements.
9118pub const BUILTIN_WORD_SPLIT: u16 = 304;
9119
9120/// Register a pre-compiled fusevm chunk as a function. Stack: [name,
9121/// base64-bincode-of-Chunk]. Used by compile_zsh's compile_funcdef to
9122/// register functions parsed via parse_init+parse without going through the
9123/// ShellCommand JSON serialization path.
9124pub const BUILTIN_REGISTER_COMPILED_FN: u16 = 305;
9125/// `BUILTIN_VAR_EXISTS` constant.
9126pub const BUILTIN_VAR_EXISTS: u16 = 306;
9127/// Native param-modifier builtins. Each takes a fixed argv shape and
9128/// returns the modified value as Value::Str.
9129///
9130/// `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
9131/// — pop [name, op_byte, rhs]. op_byte: 0=`:-`, 1=`:=`, 2=`:?`, 3=`:+`.
9132pub const BUILTIN_PARAM_DEFAULT_FAMILY: u16 = 307;
9133/// `${var:offset[:length]}` — pop [name, offset, length] (length=-1 means
9134/// "rest of value"; negative offset counts from end).
9135pub const BUILTIN_PARAM_SUBSTRING: u16 = 308;
9136/// `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}` — pop
9137/// [name, pattern, op_byte]. op_byte: 0=`#`, 1=`##`, 2=`%`, 3=`%%`.
9138pub const BUILTIN_PARAM_STRIP: u16 = 309;
9139/// `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
9140/// `${var/%pat/repl}` — pop [name, pattern, replacement, op_byte].
9141/// op_byte: 0=first, 1=all, 2=anchor-prefix, 3=anchor-suffix.
9142pub const BUILTIN_PARAM_REPLACE: u16 = 310;
9143/// `${#name}` — character length of a scalar value, or element count
9144/// of an indexed/assoc array. Pops \[name\], returns count as Value::Str.
9145pub const BUILTIN_PARAM_LENGTH: u16 = 311;
9146/// `$((expr))` arithmetic substitution. Pops \[expr_string\], evaluates
9147/// via the executor's MathEval (integer-aware), returns result as
9148/// Value::Str. Bypasses ArithCompiler's float-only Op::Div path so
9149/// `$((10/3))` returns "3" not "3.333...".
9150pub const BUILTIN_ARITH_EVAL: u16 = 312;
9151/// `(( ... ))` math command post-eval status hook. Pops nothing,
9152/// pushes Value::Status. If errflag is set (math error in the
9153/// preceding BUILTIN_ARITH_EVAL call), clears it and emits status=2
9154/// matching c:Src/math.c arith-failure semantics. Otherwise emits
9155/// the current vm.last_status. Used by compile_arith's `(( ... ))`
9156/// path so the math command swallows errors without halting the
9157/// script — `$((... ))` substitutions skip this hook so their
9158/// errflag propagates up to the containing command.
9159pub const BUILTIN_ARITH_CMD_FINISH: u16 = 527;
9160/// `$(cmd)` command substitution. Pops \[cmd_string\], runs through
9161/// `run_command_substitution` which compiles via parse_init+parse + ZshCompiler
9162/// and captures stdout via an in-process pipe. Returns trimmed output
9163/// as Value::Str. Avoids the sub-chunk word-emit quoting bug in the
9164/// raw Op::CmdSubst path.
9165pub const BUILTIN_CMD_SUBST_TEXT: u16 = 313;
9166/// Text-based word expansion. Pops \[preserved_text\]: the word with
9167/// quotes preserved (Dnull→`"`, Snull→`'`, Bnull→`\`), runs
9168/// `expand_string` (variable + cmd-sub + arith) then `xpandbraces`
9169/// then `expand_glob`. Returns Value::str (single match) or
9170/// Value::Array (multi-match brace/glob).
9171pub const BUILTIN_EXPAND_TEXT: u16 = 314;
9172
9173/// `[[ a -ef b ]]` — same-inode test. Stack: [a, b]. Pushes Bool true iff
9174/// both paths resolve to the same `(dev, inode)` pair (zsh + bash semantics).
9175pub const BUILTIN_SAME_FILE: u16 = 315;
9176
9177/// `[[ a -nt b ]]` — file `a` newer than file `b` (mtime strict).
9178/// Stack: [path_a, path_b]. Pushes Bool. zsh-compatible "missing"
9179/// rules: if both exist, compare mtime; if only `a` exists → true;
9180/// otherwise false.
9181pub const BUILTIN_FILE_NEWER: u16 = 324;
9182
9183/// `[[ a -ot b ]]` — mirror of `-nt`. If both exist, compare mtime;
9184/// if only `b` exists → true; otherwise false.
9185pub const BUILTIN_FILE_OLDER: u16 = 325;
9186
9187/// `[[ -k path ]]` — sticky bit (S_ISVTX) set on path.
9188pub const BUILTIN_HAS_STICKY: u16 = 326;
9189/// `[[ -u path ]]` — setuid bit (S_ISUID).
9190pub const BUILTIN_HAS_SETUID: u16 = 327;
9191/// `[[ -g path ]]` — setgid bit (S_ISGID).
9192pub const BUILTIN_HAS_SETGID: u16 = 328;
9193/// `[[ -O path ]]` — owned by effective UID.
9194pub const BUILTIN_OWNED_BY_USER: u16 = 329;
9195/// `[[ -G path ]]` — owned by effective GID.
9196pub const BUILTIN_OWNED_BY_GROUP: u16 = 330;
9197/// `[[ -N path ]]` — file modified since last accessed (atime <= mtime).
9198pub const BUILTIN_FILE_MODIFIED_SINCE_ACCESS: u16 = 341;
9199
9200/// `name+=val` (no parens) — runtime-dispatched append.
9201/// If name is an indexed array → push val as element.
9202/// If name is an assoc array → error (zsh requires `(k v)` form).
9203/// Else → scalar concat (existing SET_VAR behavior).
9204pub const BUILTIN_APPEND_SCALAR_OR_PUSH: u16 = 331;
9205
9206/// `[[ -c path ]]` — character device.
9207pub const BUILTIN_IS_CHARDEV: u16 = 332;
9208/// `[[ -b path ]]` — block device.
9209pub const BUILTIN_IS_BLOCKDEV: u16 = 333;
9210/// `[[ -p path ]]` — FIFO / named pipe.
9211pub const BUILTIN_IS_FIFO: u16 = 334;
9212/// `[[ -S path ]]` — socket.
9213pub const BUILTIN_IS_SOCKET: u16 = 335;
9214/// `BUILTIN_ERREXIT_CHECK` constant.
9215pub const BUILTIN_ERREXIT_CHECK: u16 = 336;
9216/// Fatal-error-only abort check, for use INSIDE an `&&` / `||` chain.
9217///
9218/// A chain suppresses the errexit check (a non-zero status is "consumed"
9219/// by the connector — `false && x` must not fire ERREXIT or the ZERR
9220/// trap). But an errflag — a *fatal* error such as a `[[ ]]` bad pattern
9221/// — is not a status the connector can consume: zsh abandons the whole
9222/// list. Without this, zshrs ran the `||` right-hand side after the
9223/// error (`[[ x = [a- ]] || touch f` created `f`; zsh does not) and the
9224/// aborted builtin then overwrote the cond's status 2 with 1.
9225///
9226/// Same errflag arm as `BUILTIN_ERREXIT_CHECK`, with the errexit/ZERR
9227/// half omitted.
9228pub const BUILTIN_FATAL_ABORT_CHECK: u16 = 641;
9229/// Post-`always`-arm checks for the canonical RETFLAG / BREAKS /
9230/// CONTFLAG atomics that mark try-block escapes. Each returns
9231/// Value::Int(1) when the corresponding atomic is set (and consumes
9232/// it so the next escape doesn't re-fire) and Value::Int(0) otherwise.
9233/// Paired with JumpIfFalse + Jump to outer return_patches /
9234/// break_patches / continue_patches by compile_zsh's `Try` arm.
9235pub const BUILTIN_RETFLAG_CHECK: u16 = 600;
9236/// `BUILTIN_BREAKS_CHECK` constant.
9237pub const BUILTIN_BREAKS_CHECK: u16 = 601;
9238/// `BUILTIN_CONTFLAG_CHECK` constant.
9239pub const BUILTIN_CONTFLAG_CHECK: u16 = 602;
9240/// Fire the DEBUG trap (SIGDEBUG) before each statement.
9241/// c:Src/exec.c:1357-1500 DEBUGBEFORECMD — when a "DEBUG" entry is
9242/// installed via `trap '...' DEBUG`, run the body just before the
9243/// next command. Cheap when no DEBUG trap is set (one hashmap lookup
9244/// returns None and we early-out).
9245pub const BUILTIN_DEBUG_TRAP: u16 = 603;
9246/// `set -n` / `set -o noexec` — parse but don't execute. Returns
9247/// Value::Int(1) when the noexec option is set so the caller's
9248/// JumpIfTrue skips the statement body. c:Src/exec.c:1390 main loop
9249/// check.
9250pub const BUILTIN_NOEXEC_CHECK: u16 = 604;
9251/// Block-level redirect-failure gate. Reads exec.redirect_failed
9252/// (set by host.redirect when a redirect open fails); returns
9253/// Value::Int(1) AND clears the flag if set, else 0. Emit-side at
9254/// compile_zsh.rs::compile_command's Redirected arm pairs with a
9255/// JumpIfTrue → WithRedirectsEnd to abandon the body. Without this,
9256/// a multi-statement block after a failed redir kept running every
9257/// statement after the first (the first builtin consumed the flag,
9258/// subsequent statements ran unimpeded).
9259pub const BUILTIN_REDIRECT_FAILED_CHECK: u16 = 605;
9260/// Drop-in replacement for fusevm's Op::Exec for the dynamic-first-
9261/// word path (`$cmd`, `$(cmd)`, `~/bin/foo`). Returns
9262/// Value::Status(vm.last_status) when post-expansion argv is empty
9263/// (preserves the inner cmd-subst's exit), Value::Status(126) with
9264/// "permission denied" when `argv[0]` is empty, otherwise routes
9265/// through executor.host_exec_external like Op::Exec did.
9266pub const BUILTIN_EXEC_DYNAMIC: u16 = 606;
9267/// Reset `use_cmdoutval` to 0 at the START of a dynamic command (before
9268/// its words expand), so a command substitution from a PREVIOUS command
9269/// can't leak into this command's null-command status decision
9270/// (c:Src/exec.c:3009 `use_cmdoutval = !args`). See BUILTIN_EXEC_DYNAMIC.
9271pub const BUILTIN_USE_CMDOUTVAL_RESET: u16 = 637;
9272/// `[[ lhs == pat ]]` / `!=` glob compare — cond-specific so the
9273/// bad-pattern diagnostic follows Src/cond.c:308-316: zwarnnam
9274/// "bad pattern: %s" WITHOUT errflag (the script continues) and the
9275/// cond statement exits 2. Stack: [lhs, pat] → Bool. On compile
9276/// failure pushes Bool(false) and arms COND_BAD_PATTERN so
9277/// BUILTIN_COND_STATUS_FROM_BOOL reports 2.
9278pub const BUILTIN_COND_STRMATCH: u16 = 624;
9279/// Pops the cond result Bool → Int status per Src/cond.c: true→0,
9280/// false→1, but 2 when COND_BAD_PATTERN was armed during this cond
9281/// (covers `!=` where LogNot flips the Bool before status time).
9282pub const BUILTIN_COND_STATUS_FROM_BOOL: u16 = 625;
9283/// `[[ ]]` unknown condition. Pops \[op_name\], emits `zerr("unknown
9284/// condition: %s")` and sets ERRFLAG_ERROR so the next BUILTIN_ERREXIT_CHECK
9285/// (trigger 4) aborts the input — matching zsh's COND_MODI "unknown condition"
9286/// path (Src/cond.c:150-188) for a `-X` op with no matching loadable module.
9287/// Returns Bool(false). Replaces a compile-time `eprintln!` hack that printed
9288/// the message but never set errflag (so the line didn't abort).
9289pub const BUILTIN_COND_UNKNOWN: u16 = 632;
9290/// Bare-`exec` redirect epilogue. Consumes `exec.redirect_failed` and
9291/// applies the C `done:` tail of execcmd_exec:
9292///   - c:Src/exec.c:252-259 execerr — `redir_err = lastval = 1` (the
9293///     failed redirect makes the exec statement exit 1, NOT fatal by
9294///     itself);
9295///   - c:Src/exec.c:4367-4386 — `if (isset(POSIXBUILTINS) && (cflags
9296///     & (BINF_PSPECIAL|BINF_EXEC)) ...) { if (redir_err || errflag)
9297///     { if (!isset(INTERACTIVE)) exit(1); } }` — POSIX_BUILTINS makes
9298///     a failed exec redirect fatal in a non-interactive shell.
9299/// Returns Value::Status(0|1) for the trailing SetStatus.
9300pub const BUILTIN_EXEC_REDIR_DONE: u16 = 626;
9301/// Assignment-prefix epilogue for bare `exec` redirects
9302/// (`x=$(cmd) exec >file`). c:Src/exec.c:3969-3976 — nullexec==1
9303/// runs addvars THEN, without POSIX_BUILTINS, restores the params
9304/// (`save_params` / `restore_params`): the RHS side effects fire but
9305/// the values don't persist. With POSIX_BUILTINS the assignments
9306/// stick. Pops the BEGIN_INLINE_ENV frame either way.
9307pub const BUILTIN_EXEC_INLINE_ENV_DONE: u16 = 627;
9308
9309/// `< file` / `> file` with no command word (NULLCMD path).
9310/// Resolves NULLCMD (default "cat") / READNULLCMD (default "more")
9311/// at runtime per Src/exec.c:3340-3364 and exec's it through
9312/// host_exec_external. Argc is 1: the int (0 or 1) on the stack
9313/// indicates whether this is a single REDIR_READ redirect
9314/// (selects READNULLCMD when set + non-empty).
9315pub const BUILTIN_NULLCMD_EXEC: u16 = 607;
9316/// `.` (dot) — alias of source/bin_dot but dispatches with the
9317/// literal name "." so the diagnostic prefix matches zsh's
9318/// (`zsh:.:1: …` vs source's `zsh:source:1: …`).
9319/// c:Src/builtin.c:9308 — `BUILTIN(".", BINF_PSPECIAL, bin_dot, …)`.
9320pub const BUILTIN_DOT: u16 = 608;
9321/// `logout` — fusevm maps this to BUILTIN_EXIT alongside `exit`/`bye`,
9322/// which drops the name and dispatches with BIN_EXIT funcid. zsh's
9323/// `logout` outside a login shell must emit "not login shell" + exit 1,
9324/// which only fires when bin_break sees BIN_LOGOUT funcid. Dedicated
9325/// opcode dispatches via BUILTINS table by literal name "logout".
9326pub const BUILTIN_LOGOUT: u16 = 610;
9327/// `BUILTIN_PARAM_SUBSTRING_EXPR` constant.
9328pub const BUILTIN_PARAM_SUBSTRING_EXPR: u16 = 337;
9329/// `BUILTIN_XTRACE_LINE` constant.
9330pub const BUILTIN_XTRACE_LINE: u16 = 338;
9331/// `BUILTIN_ARRAY_JOIN_STAR` constant.
9332pub const BUILTIN_ARRAY_JOIN_STAR: u16 = 339;
9333/// `BUILTIN_SET_RAW_OPT` constant.
9334pub const BUILTIN_SET_RAW_OPT: u16 = 340;
9335
9336/// `time { compound; ... }` — wall-clock-time the sub-chunk and print
9337/// elapsed seconds. Stack: [sub_chunk_idx as Int]. Runs the sub-chunk
9338/// on the current VM (so positional/local state is shared) and prints
9339/// the timing summary to stderr in zsh's format. Pushes Status.
9340pub const BUILTIN_TIME_SUBLIST: u16 = 316;
9341
9342/// `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocation.
9343/// Stack: [path, varid, op_byte]. Opens `path` per `op_byte`, gets the
9344/// new fd (≥10 in zsh; we use libc::open with O_CLOEXEC bit cleared so
9345/// the inherited fd survives Command::new spawns), stores the fd number
9346/// as a string in `$varid`. Pushes Status (0 success, 1 error).
9347pub const BUILTIN_OPEN_NAMED_FD: u16 = 317;
9348
9349/// Word-segment concat that does cartesian-product distribution over
9350/// arrays. Stack: [lhs, rhs]. Used for RC_EXPAND_PARAM `${arr}` and
9351/// explicit-distribute forms (`${^arr}`, `${(@)…}`).
9352///
9353/// - both scalar: `Value::str(a + b)` (fast path, identical to Op::Concat)
9354/// - lhs Array, rhs scalar: `Value::Array([a + rhs for a in lhs])`
9355/// - lhs scalar, rhs Array: `Value::Array([lhs + b for b in rhs])`
9356/// - both Array: cartesian product `[a + b for a in lhs for b in rhs]`
9357pub const BUILTIN_CONCAT_DISTRIBUTE: u16 = 318;
9358
9359/// Forced-distribute concat — like `BUILTIN_CONCAT_DISTRIBUTE` but
9360/// always distributes cartesian regardless of the `rcexpandparam`
9361/// option. Emitted by the segments fast-path when an
9362/// `is_distribute_expansion` segment is present (`${^arr}`,
9363/// `${(@)arr}`, `${(s.…)arr}` etc.) per zsh: the source-level
9364/// distribution flag overrides the option default.
9365/// Direct port of Src/subst.c:1875 `case Hat: nojoin = 1` and the
9366/// `rcexpandparam` test bypass for the explicit-distribute flags.
9367pub const BUILTIN_CONCAT_DISTRIBUTE_FORCED: u16 = 522;
9368
9369/// Capture current `last_status` into the `TRY_BLOCK_ERROR` variable.
9370/// Emitted between the try block and the always block of `{ … } always
9371/// { … }` so the finally arm can read $TRY_BLOCK_ERROR.
9372pub const BUILTIN_SET_TRY_BLOCK_ERROR: u16 = 320;
9373/// `BUILTIN_RESTORE_TRY_BLOCK_STATUS` constant.
9374pub const BUILTIN_RESTORE_TRY_BLOCK_STATUS: u16 = 432;
9375/// `BUILTIN_BEGIN_INLINE_ENV` constant.
9376pub const BUILTIN_BEGIN_INLINE_ENV: u16 = 433;
9377/// `BUILTIN_END_INLINE_ENV` constant.
9378pub const BUILTIN_END_INLINE_ENV: u16 = 434;
9379
9380/// `[[ -o option ]]` — shell-option-set test. Stack: \[option_name\].
9381/// Normalizes the name (strip underscores, lowercase) and reads
9382/// `exec.options`. Pushes Bool.
9383pub const BUILTIN_OPTION_SET: u16 = 321;
9384/// Tri-state `[[ -o NAME ]]` — same lookup as BUILTIN_OPTION_SET
9385/// but returns a Value::Int (0=set, 1=unset, 3=invalid-name). The
9386/// 3-state code matches zsh's `[[ -o invalid ]]` exit (Src/cond.c
9387/// :502 `optison()`). Used by compile_cond's `-o` arm to skip the
9388/// generic bool→status conversion and preserve the invalid-name
9389/// signal in `$?`.
9390pub const BUILTIN_OPTION_CHECK_TRISTATE: u16 = 609;
9391
9392/// `${var:#pattern}` — array filter: remove elements matching `pattern`.
9393/// Stack: [name, pattern]. For scalar `var`, returns empty if it matches
9394/// the pattern, else the value. For array `var`, returns Array of
9395/// non-matching elements.
9396pub const BUILTIN_PARAM_FILTER: u16 = 322;
9397
9398/// `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()` —
9399/// subscripted-array assign with array-literal RHS. Stack:
9400/// [...elements, name, key]. Empty elements + single-int key `a[i]=()`
9401/// removes that element. Comma-key `a[i,j]=(...)` splices.
9402pub const BUILTIN_SET_SUBSCRIPT_RANGE: u16 = 323;
9403
9404/// `[[ -X file ]]` for unknown unary test op `-X`. Stack: \[op_name\].
9405/// Emits zsh's `unknown condition: -X` diagnostic to stderr and
9406/// pushes Bool(false). Without this, unknown conditions silently
9407/// returned false matching neither zsh's error format nor the
9408/// expected status code (zsh returns 2 for parse error).
9409
9410/// `[[ -t fd ]]` — fd-is-a-tty check. Stack: \[fd_string\].
9411/// Routes through libc::isatty. Pushes Bool.
9412pub const BUILTIN_IS_TTY: u16 = 325;
9413/// `[[ -r/-w/-x file ]]` via access(2) (doaccess) — see handler.
9414pub const BUILTIN_COND_ACCESS: u16 = 638;
9415
9416/// Update `$LINENO` to track the source line of the next statement.
9417/// Stack: \[n\] (the line number from `ZshPipe.lineno`). Direct port
9418/// of zsh's `lineno` global tracking (Src/input.c:330) — the
9419/// compiler emits one of these per top-level pipe so `$LINENO`
9420/// reflects the source position at runtime. ID 342 picked because
9421/// the previous `326` collided with `BUILTIN_HAS_STICKY` (the file
9422/// has several other duplicate IDs — 325 has two as well — but
9423/// fixing those is out of scope for this port).
9424pub const BUILTIN_SET_LINENO: u16 = 342;
9425
9426/// Pop a scalar from the VM stack, run expand_glob on it, push the
9427/// result as Value::Array. Used by the segment-concat compile path
9428/// when var refs concatenate with glob meta literals (`$D/*`,
9429/// `${prefix}*`, etc.) — those skip the bridge's pathname-expansion
9430/// pass and would otherwise leak the glob meta to argv as a literal.
9431pub const BUILTIN_GLOB_EXPAND: u16 = 343;
9432
9433/// MULTIOS-gated glob expansion for redirect-target words
9434/// (c:Src/glob.c:2161-2167 xpandredir: "Globbing is only done for
9435/// multios."). Same stack shape as BUILTIN_GLOB_EXPAND; additionally
9436/// passes the word through literally when `unsetopt multios`.
9437// 624 is BUILTIN_COND_STRMATCH — the VM's builtin table is
9438// last-registration-wins, so a duplicate id silently shadows the
9439// earlier handler.
9440pub const BUILTIN_REDIR_GLOB_EXPAND: u16 = 628;
9441
9442/// Reset the default-word glob-pending carrier at the START of a word
9443/// whose source contains a glob metachar (so the flag never leaks from a
9444/// prior word/statement). Paired with BUILTIN_DEFAULT_WORD_GLOB.
9445pub const BUILTIN_DEFAULT_WORD_GLOB_RESET: u16 = 635;
9446
9447/// Filename-generate the ASSEMBLED word ONLY when the default/alternate
9448/// paramsubst arm took a SOURCE word carrying glob metachars
9449/// (subst::DEFAULT_WORD_GLOB_PENDING). A parameter VALUE never sets the
9450/// flag, so `x='*file'; ${x:-d}` stays literal while `${x:-*file}` /
9451/// `${x:-a*}bar` glob. Reads+clears the flag. c:Src/subst.c → globlist.
9452pub const BUILTIN_DEFAULT_WORD_GLOB: u16 = 636;
9453/// `BUILTIN_SET_LOOP_VAR` constant — for-loop variable binding via
9454/// `setloopvar` (Src/params.c:6362): a PM_NAMEREF loop var REBINDS
9455/// to each word (SETREFNAME + setscope) instead of assigning
9456/// through the chain. Returns Bool(false) when zerr fired
9457/// (read-only reference / invalid self reference) so the loop
9458/// driver aborts, mirroring C execfor's errflag check.
9459pub const BUILTIN_SET_LOOP_VAR: u16 = 629;
9460
9461/// EXTEND step of typeset paren-init packing. Pops `argc` values:
9462/// [base, e1, …, eN] — base is either the opener (`name=(` /
9463/// `name+=(`) or a previous EXTEND result. Pushes base with
9464/// `\u{1f}` + element appended per element. Array values SPLICE
9465/// their items as separate elements (`typeset b=( x $arr )` splat);
9466/// an empty Array contributes nothing (unquoted-empty elision).
9467/// CallBuiltin's argc is u8, so the compiler emits one EXTEND per
9468/// ≤200-element chunk — p10k's 408-element `__p9k_colors=( … )`
9469/// overflowed a single-shot pack (argc wrapped mod 256 and the
9470/// stack spilled into the arg list: "not an identifier: 173…").
9471/// BUILTIN_TYPESET_PAREN_CLOSE appends the final `\u{1f})`,
9472/// yielding the exact REJOIN_SEP-delimited one-arg form
9473/// bin_typeset's single-arg splitter consumes (builtin.rs ~4891,
9474/// empties preserved, leading/trailing sentinel-empties trimmed
9475/// once). One arg in → one arg out: bin_typeset's multi-arg rejoin
9476/// (paren-depth scan, unsafe on EXPANDED paren-literal elements
9477/// like p10k's `')' ''`) never runs.
9478pub const BUILTIN_TYPESET_PAREN_PACK: u16 = 630;
9479
9480/// CLOSE step — pops the EXTEND chain's result, pushes it with
9481/// `\u{1f})` appended. See BUILTIN_TYPESET_PAREN_PACK.
9482pub const BUILTIN_TYPESET_PAREN_CLOSE: u16 = 631;
9483
9484/// Shared body of BUILTIN_GLOB_EXPAND / BUILTIN_REDIR_GLOB_EXPAND.
9485/// c:Src/glob.c:1872 — `zglob` runs per-word in the argv pipeline.
9486/// When the upstream EXPAND_TEXT returned an array (e.g. `${a:e}`
9487/// splat → ["txt","md"]), glob each element separately, not a
9488/// sepjoin'd scalar. `skip_glob` short-circuits to a literal
9489/// pass-through (noglob, or a redirect target under
9490/// `unsetopt multios`).
9491fn glob_expand_word_value(raw: Value, skip_glob: bool) -> Value {
9492    let patterns: Vec<String> = match raw {
9493        Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
9494        other => vec![other.to_str()],
9495    };
9496    if skip_glob {
9497        return if patterns.is_empty() {
9498            Value::Array(Vec::new())
9499        } else if patterns.len() == 1 {
9500            Value::str(patterns.into_iter().next().unwrap())
9501        } else {
9502            Value::Array(patterns.into_iter().map(Value::str).collect())
9503        };
9504    }
9505    let mut out: Vec<String> = Vec::with_capacity(patterns.len());
9506    for pattern in &patterns {
9507        // c:Src/subst.c — filename generation runs `filesub` (tilde/`=`
9508        // expansion) BEFORE globbing. A `~`/`=` reaching this word-glob op
9509        // comes from `${~spec}` / GLOB_SUBST marking a substituted VALUE:
9510        // literal and quoted `~` words are filesub'd (or skip glob) upstream
9511        // and never arrive here. filesubstr matches the Tilde TOKEN, so
9512        // shtokenize first (raw `~`->Tilde; already-Tilde `${~a[@]}` results
9513        // pass through), run filesub, then untokenize surviving glob metas
9514        // for expand_glob. Gated on `~`/`=` (raw or token) so ordinary
9515        // substituted words skip the roundtrip. Fixes `${~x}` x="~/foo".
9516        let filesubbed = if pattern.contains('~')
9517            || pattern.contains('=')
9518            || pattern.contains(crate::ported::zsh_h::Tilde)
9519            || pattern.contains(crate::ported::zsh_h::Equals)
9520        {
9521            let mut tok = pattern.clone();
9522            crate::ported::glob::shtokenize(&mut tok);
9523            crate::ported::lex::untokenize(&crate::ported::subst::filesub(&tok, 0))
9524        } else {
9525            pattern.clone()
9526        };
9527        let matches = with_executor(|exec| exec.expand_glob(&filesubbed));
9528        if matches.is_empty() {
9529            // c:1872 nullglob — drop this word, don't emit a hole
9530            continue;
9531        }
9532        for m in matches {
9533            out.push(m);
9534        }
9535    }
9536    if out.is_empty() {
9537        return Value::Array(Vec::new());
9538    }
9539    if patterns.len() == 1 && out.len() == 1 && out[0] == patterns[0] {
9540        // No real matches; expand_glob returned the literal. Pass
9541        // back as scalar so downstream ops don't re-flatten.
9542        return Value::str(out.into_iter().next().unwrap());
9543    }
9544    Value::Array(out.into_iter().map(Value::str).collect())
9545}
9546
9547/// Push a `CmdState` token onto the command-context stack. Direct
9548/// port of zsh's `cmdpush(int cmdtok)` (Src/prompt.c:1623). The
9549/// stack is consulted by `%_` in PS4/prompt expansion to produce
9550/// the cumulative control-flow-context labels (`if`, `then`,
9551/// `cmdand`, `cmdor`, `cmdsubst`, …) that `zsh -x` xtrace shows
9552/// in the trace prefix. Compile_zsh emits push/pop pairs around
9553/// each compound command (if/while/[[…]]/((…))/$(…) etc.).
9554/// Token is a `CmdState as u8`.
9555pub const BUILTIN_CMD_PUSH: u16 = 344;
9556
9557/// Pop the top of the command-context stack. Direct port of zsh's
9558/// `cmdpop(void)` (Src/prompt.c:1631).
9559pub const BUILTIN_CMD_POP: u16 = 345;
9560
9561/// Emit an xtrace line built from the top `argc` values on the VM
9562/// stack, peeked WITHOUT consuming. Used to trace simple commands
9563/// AFTER expansion, so `echo for $i` shows as `echo for a` / `echo
9564/// for b`. Direct port of Src/exec.c:2055-2066.
9565pub const BUILTIN_XTRACE_ARGS: u16 = 346;
9566
9567/// Trace one assignment: emits `name=<quoted-value> ` (no newline)
9568/// to xtrerr if XTRACE is on. Coalesces with subsequent
9569/// XTRACE_ASSIGN / XTRACE_ARGS calls onto the SAME line via the
9570/// `XTRACE_DONE_PS4` flag so `a=1 b=2 echo $a $b` produces:
9571///   `<PS4>a=1 b=2 echo 1 2\n`
9572/// matching C zsh's `execcmd_exec` body (Src/exec.c:2517-2582):
9573///   xtr = isset(XTRACE);
9574///   if (xtr) { printprompt4(); doneps4 = 1; }
9575///   while (assign) {
9576///       if (xtr) fprintf(xtrerr, "%s=", name);
9577///       ... eval value ...
9578///       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
9579///   }
9580///
9581/// Stack contract on entry: [..., name, value]. Both peeked, NOT
9582/// consumed (the matching SET_VAR call pops them after). argc = 2.
9583pub const BUILTIN_XTRACE_ASSIGN: u16 = 525;
9584
9585/// Emit a trailing `\n` + flush iff XTRACE is on AND PS4 was
9586/// emitted by an earlier XTRACE_ASSIGN this line. Used at the end
9587/// of compile_simple's assignment-only path so the trace line gets
9588/// terminated. Mirrors C's exec.c:3397-3399 (the assign-only return
9589/// path through execcmd_exec which does `fputc('\n', xtrerr);
9590/// fflush(xtrerr)`).
9591///
9592/// Stack: untouched. argc = 0.
9593pub const BUILTIN_XTRACE_NEWLINE: u16 = 526;
9594
9595/// Push the live `xtrace` opt-state as `Value::Int(1)` (on) or
9596/// `Value::Int(0)` (off). Used by `compile_cond` to gate the
9597/// trace-string-building block on xtrace state at runtime — without
9598/// this the trace path's `compile_word_str` on each operand re-
9599/// evaluates side-effectful expressions (`$((i++))`) once for the
9600/// trace string and once for the actual condition, doubling the
9601/// effective increment. Bug #159 in docs/BUGS.md.
9602///
9603/// Stack: pushes Int(0|1). argc = 0.
9604pub const BUILTIN_XTRACE_IS_ON: u16 = 611;
9605
9606/// Reset the `DONETRAP` flag at the start of each top-level statement
9607/// (sublist boundary). Mirrors C `Src/exec.c:1455` — `donetrap = 0`.
9608/// Stack: untouched. argc = 0. Bug #303 in docs/BUGS.md.
9609pub const BUILTIN_DONETRAP_RESET: u16 = 612;
9610
9611/// `[[ -z X ]]` / `[[ -n X ]]` operand-empty test that honours zsh's
9612/// array-splice semantics. C zsh evaluates `[[ -z X ]]` per
9613/// `Src/cond.c:347` (case 'z'): `s` is the SCALAR operand passed
9614/// through `cond_str`'s singsub. For `"${arr[@]}"` zsh expands per
9615/// `Src/subst.c:multsub` which yields each element as its own word
9616/// list node; cond.c then sees the joined-or-single-element form.
9617///
9618/// The compile-side `-z` shortcut at `compile_zsh.rs:5371` used
9619/// `Op::StringLen` which calls `Value::len` — for `Value::Array`
9620/// that returns ARRAY LENGTH, not string length. `b=("")` produced
9621/// `Value::Array([""])` → `len = 1` → `-z` returned false.
9622///
9623/// This builtin pops one `Value` and pushes `1` (empty) or `0`
9624/// (non-empty) per the cond context:
9625///   - `Value::Str(s)` → s.is_empty()
9626///   - `Value::Array([])` → true (zero words → vacuous-empty)
9627///   - `Value::Array([s])` → s.is_empty() (single-word case)
9628///   - `Value::Array([_; n>=2])` → false (multiple non-empty
9629///     words; zsh would raise "unknown condition" but the
9630///     observable test result is non-empty/false)
9631///
9632/// Companion to BUILTIN_COND_STR_NONEMPTY (#185 in docs/BUGS.md).
9633pub const BUILTIN_COND_STR_EMPTY: u16 = 613;
9634
9635/// `[[ -n X ]]` operand-non-empty test (logical complement of
9636/// BUILTIN_COND_STR_EMPTY).
9637pub const BUILTIN_COND_STR_NONEMPTY: u16 = 614;
9638
9639/// `exec N<<<"str"` — herestring redirect to explicit fd, applied
9640/// permanently to the shell (no scope restoration). Pops `[content,
9641/// fd]` from the stack; creates a temp file, writes
9642/// `content + "\n"`, reopens read-only, dup2's to `fd`, unlinks the
9643/// temp path so it disappears on close. Mirrors C `Src/exec.c:4655
9644/// getherestr` + `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)`
9645/// at c:3766-3780 for the bare-exec-redir code path (nullexec=1).
9646/// Bug #205 in docs/BUGS.md.
9647///
9648/// Stack: pushes `Value::Status(0)` on success, `Status(1)` on
9649/// failure. argc = 2.
9650pub const BUILTIN_EXEC_HERESTR_FD: u16 = 615;
9651
9652/// MULTIOS write/append fan-out for `cmd > a > b` / `cmd > a >> b`
9653/// style redirects (Bug #36 in docs/BUGS.md). zsh's MULTIOS option
9654/// (Src/exec.c:2418 `mfds[fd1]` check + addfd splice) creates a
9655/// pipe at fd1, spawns an internal "tee" process that copies
9656/// stdin → every collected target file. Without this, only the
9657/// LAST redirect target survives because each dup2 overwrites the
9658/// previous binding.
9659///
9660/// Stack layout (pushed by compile_zsh's compile_redirs coalescing
9661/// pass): `[target_1, op_byte_1, target_2, op_byte_2, …, target_N,
9662/// op_byte_N, fd]`. Pops 2N+1 elements; `argc = 2*N + 1`. A target
9663/// may be a Value::Array of glob matches (spliced into one member
9664/// per match, c:Src/glob.c:2195-2203); an op may be DUP_WRITE for a
9665/// numeric `>&N` member (c:Src/exec.c:3895-3917).
9666///
9667/// Runtime (MULTIOS set):
9668///   1. Seed the member list with `dup(1)` when this command's
9669///      stdout is the pipeline output (c:Src/exec.c:3722-3724).
9670///   2. Open/dup all targets per their op_byte in redirect order
9671///      (WRITE truncate + noclobber gate / APPEND / DUP_WRITE live
9672///      dup); the first member replaces the fd (c:2448-2450).
9673///   3. Save `dup(fd)` onto the active redirect_scope_stack so
9674///      `host_redirect_scope_end` restores the original fd.
9675///   4. Create a pipe; spawn a thread that reads from the pipe
9676///      read-end and writes every chunk to every opened target.
9677///   5. dup2 the pipe write-end onto `fd` so the command's writes
9678///      go through the splitter.
9679///   6. Track `(pipe_write_fd, JoinHandle)` so scope-end can close
9680///      the pipe (draining the thread) and join before restoring.
9681///
9682/// MULTIOS unset (c:2418 `unset(MULTIOS)` replace arm): each entry
9683/// is applied as a plain sequential replace via host_apply_redirect
9684/// — every file still opened/truncated, last one wins.
9685pub const BUILTIN_MULTIOS_REDIRECT: u16 = 617;
9686
9687/// MULTIOS input-side concatenation for `cmd < a < b` shapes
9688/// (Bug #36 input arm). C zsh's `Src/exec.c:2418` mfds dispatch
9689/// also covers the read direction — when multiple `<` redirects
9690/// target the same fd, mfds[fd] grows and addfd splices a
9691/// concatenating cat into the pipe.
9692///
9693/// Stack layout (mirrors the write side): `[source_1, op_1,
9694/// source_2, op_2, …, source_N, op_N, fd]`. Pops 2N + 1 elements
9695/// (argc = 2N + 1). op is READ for file sources, DUP_READ for
9696/// numeric `<&N` members; a source may be a Value::Array of glob
9697/// matches (spliced, c:Src/glob.c:2195-2203).
9698///
9699/// Runtime (MULTIOS set):
9700///   1. Open/dup every source in redirect order; first member
9701///      replaces the fd (c:Src/exec.c:2448-2450).
9702///   2. Save `dup(fd)` onto the redirect_scope_stack.
9703///   3. Create a pipe; spawn a thread that reads each source in
9704///      order and writes every chunk to the pipe write-end. Close
9705///      write-end when done so the consumer sees EOF.
9706///   4. dup2 the pipe read-end onto `fd`.
9707///   5. Track the JoinHandle so scope-end joins (no fd-close needed
9708///      here — the producer thread closes its own pipe write-end
9709///      on exit).
9710///
9711/// MULTIOS unset: sequential replace via host_apply_redirect — last
9712/// source wins (c:2418).
9713pub const BUILTIN_MULTIOS_READ: u16 = 618;
9714
9715/// Toggle `ShellExecutor::exec_redirs_permanent`. Emitted by
9716/// compile_zsh's bare-`exec`-with-redirects arm tightly around each
9717/// `Op::Redirect`: `LoadInt(1); CallBuiltin; …Redirect…; LoadInt(0);
9718/// CallBuiltin`. While set, `host_apply_redirect` skips pushing the
9719/// saved fd into the enclosing redirect scope, making the fd change
9720/// permanent.
9721///
9722/// c:Src/exec.c:3978-3986 — nullexec==1 (`exec` carrying only
9723/// redirections): "If nullexec is 1 we specifically *don't* restore
9724/// the original fd's before returning" — the per-execcmd `save[]`
9725/// dups are closed unrestored. An ENCLOSING group's own saves are a
9726/// different execcmd's `save[]` and still restore (verified:
9727/// `{ exec 2>/dev/null; } 2>&1; ls /nope` prints the ls error in zsh).
9728pub const BUILTIN_EXEC_PERM_REDIRS: u16 = 619;
9729
9730/// Set `ShellExecutor::pipe_output_pending`. Emitted by compile_pipe
9731/// at the head of a NON-LAST pipeline-stage sub-chunk when that
9732/// stage's top-level command carries redirects (`Simple` with redirs
9733/// or `Redirected` compound). The forked stage child runs the chunk
9734/// with stdout already dup2'd onto the pipe; the first
9735/// `host_redirect_scope_begin` (the stage command's own redirect
9736/// list) consumes the flag into `pipe_output_scope`, enabling the
9737/// MULTIOS stream-split for fd-1 write redirects in that list.
9738///
9739/// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output, 1,
9740/// NULL)` registers the pipe in mfds[1] in the SAME execcmd that
9741/// walks the stage command's redirect list; mfds is per-execcmd, so
9742/// nested body commands (`{ echo a > f; } | cat`) never see it.
9743pub const BUILTIN_PIPE_OUTPUT_MARK: u16 = 620;
9744
9745/// Magic-equals prefork for a single arg word of a
9746/// `BINF_MAGICEQUALS` builtin head (`alias`). Direct port of
9747/// c:Src/exec.c:3298-3304 — `esprefork = PREFORK_TYPESET;
9748/// prefork(args, esprefork, NULL)` runs on the argv BEFORE the addfd
9749/// redirect loop at c:3720, so an expansion zerr (`alias bad===` →
9750/// equalsubstr "= not found" at Src/subst.c:726) prints to the
9751/// command's UN-redirected stderr. argc=1: pops the just-pushed
9752/// word value, runs shtokenize → prefork(PREFORK_TYPESET) →
9753/// untokenize on it (each element for Array splices), pushes the
9754/// result back. Emitted by compile_simple per arg word when the
9755/// dispatch head is `alias`; BUILTIN_ALIAS itself no longer
9756/// preforks (it would double-fire the diagnostic).
9757pub const BUILTIN_MAGIC_EQUALS_PREFORK: u16 = 621;
9758
9759/// Bare (unbraced) `$name[idx]` subscript — same dispatch as
9760/// `BUILTIN_ARRAY_INDEX` while KSHARRAYS is unset, but under
9761/// KSHARRAYS the unbraced form does NOT subscript (c:Src/subst.c:
9762/// 2800-2802 + 2867): `$name` expands bare and `[idx]` stays literal
9763/// trailing text that undergoes filename generation. Operands:
9764/// [name, idx, suffix, quoted].
9765pub const BUILTIN_ARRAY_INDEX_UNBRACED: u16 = 622;
9766
9767/// Assignment-only simple-command exit status. Direct port of
9768/// `lv = (errflag ? errflag : cmdoutval)` (c:Src/exec.c:1322,
9769/// execsimple's WC_ASSIGN arm) / `if (errflag) lastval = 1; else
9770/// lastval = cmdoutval;` (c:Src/exec.c:3393-3396, execcmd_exec's
9771/// no-command-word varspc path; redir variant at c:3977). Pops
9772/// [had_cmd_subst]; cmdoutval is the live vm.last_status when a
9773/// `$()` ran in any RHS of the chain, 0 otherwise. Writes the
9774/// canonical LASTVAL (C's single `lastval` global) so the
9775/// non-interactive errflag abort exits with this value per
9776/// Src/init.c:234. Caller pairs with SetStatus.
9777pub const BUILTIN_ASSIGN_ONLY_STATUS: u16 = 623;
9778
9779/// c:Src/exec.c addvars — `if (!pm) { lastval = 1; if (!cmdoutval)
9780/// cmdoutval = 1; }`. Set by BUILTIN_SET_VAR on assignsparam
9781/// failure, consumed by BUILTIN_ASSIGN_ONLY_STATUS so the
9782/// assignment-only command reports status 1. Process-global like
9783/// C's `cmdoutval` (function bodies may run on a different thread
9784/// than the opcode that reads the status back).
9785pub static ASSIGN_FAILED_FLAG: std::sync::atomic::AtomicBool =
9786    std::sync::atomic::AtomicBool::new(false);
9787
9788/// `redirection with no command` parse-time error for bare
9789/// `builtin 2>&1` / `command < file` / `exec >&-` precmd-keyword
9790/// shapes with a redirect but no following command. Direct port
9791/// of `Src/exec.c:3342 zerr("redirection with no command")`.
9792/// argc=0; pushes Value::Status(1).
9793pub const BUILTIN_REDIR_NO_CMD: u16 = 616;
9794
9795/// GLOB_SUBST guard for `[[ x == $pat ]]` pattern RHS coming from
9796/// parameter / command substitution. C-zsh's `[[ == ]]` semantics
9797/// (Src/options.c GLOB_SUBST default OFF + Src/cond.c:552
9798/// `cond_match` + Src/pattern.c patcompile tokenization-based
9799/// meta detection) treat chars from substitution as LITERAL
9800/// unless GLOB_SUBST is on. The Rust patcompile accepts both
9801/// tokenized and raw-ASCII meta chars, losing the distinction,
9802/// so `pat="h*"; [[ hello == $pat ]]` matched in zshrs but not
9803/// in zsh. Bug #116 in docs/BUGS.md.
9804///
9805/// Compile-time signal: emitted by `compile_cond_expr` ONLY when
9806/// the RHS contains `$` or backtick. Runtime checks the live
9807/// option state. If GLOB_SUBST is OFF, the popped string has
9808/// its glob metachars escaped with `\` so the downstream StrMatch
9809/// → patcompile treats them as literals. If GLOB_SUBST is ON,
9810/// the value passes through unchanged so `setopt glob_subst`
9811/// restores zsh's pattern-on-expansion behavior.
9812///
9813/// Stack: pops one string, pushes the (possibly escaped) result.
9814/// argc = 1.
9815pub const BUILTIN_GLOB_SUBST_GUARD: u16 = 528;
9816
9817/// Coerce a string parameter value to a math number (Int or Float)
9818/// for arithmetic-context reads, mirroring C-zsh's `getmathparam`
9819/// (Src/math.c:337). When the variable holds a string like "hello"
9820/// that isn't numeric, C falls back to recursively evaluating the
9821/// raw string as an arith expression; if that fails too, returns 0.
9822///
9823/// Used by the ArithCompiler pre-load path so `(( y = x ))` with
9824/// `x="hello"` reads `x` as integer 0, then assigns y as integer 0
9825/// — matching zsh's behaviour. The previous Rust port used
9826/// BUILTIN_GET_VAR which returned the raw string "hello"; the
9827/// ArithCompiler stored it verbatim in y's slot, and the post-sync
9828/// BUILTIN_SET_VAR wrote y="hello" as scalar instead of y=0 as
9829/// integer. Bug #118 in docs/BUGS.md.
9830///
9831/// Stack: pops `name` (string), pushes coerced numeric Value.
9832/// argc = 1.
9833pub const BUILTIN_GET_MATH_VAR: u16 = 529;
9834
9835/// GLOB_SUBST runtime gate for words containing parameter / command
9836/// substitution. C-zsh's `prefork` (Src/subst.c) runs `shtokenize`
9837/// on the substituted value when `GLOB_SUBST` is set, making the
9838/// substituted chars eligible for filename generation. With the
9839/// option off, substituted chars stay literal.
9840///
9841/// The Rust port's compile_zsh emits `compile_word_str` for words
9842/// like `/tmp/X/$pat`, which returns the post-expansion string but
9843/// never runs glob expansion (no path here triggers
9844/// BUILTIN_GLOB_EXPAND). Bug #119 in docs/BUGS.md: with `setopt
9845/// glob_subst`, `for f in /tmp/X/$pat` (pat="*.txt") never matched
9846/// `*.txt` files.
9847///
9848/// This opcode wraps the substitution result and dispatches at
9849/// runtime: when GLOB_SUBST is OFF, return unchanged; when ON,
9850/// pass the value through `expand_glob` so glob metas become
9851/// active. Emitted by `compile_for_words` (and similar sites)
9852/// after WORD_SPLIT for words with unquoted expansion.
9853///
9854/// Stack: pops a Value (Str or Array of Str), pushes the glob-
9855/// expanded result (still Str or Array depending on input shape).
9856/// argc = 1.
9857pub const BUILTIN_GLOB_SUBST_EXPAND: u16 = 530;
9858/// `BUILTIN_ASSOC_HAS_KEY` constant — `${(k)assoc[name]}` key-existence
9859/// query. Returns the key text on hit, empty string on miss. Bug #145.
9860pub const BUILTIN_ASSOC_HAS_KEY: u16 = 531;
9861/// `BUILTIN_ARRAY_DROP_EMPTY` constant — filter empty elements from
9862/// an Array on the stack. Used by `for x in $@` / `for x in $*`
9863/// unquoted forms. Bug #166.
9864pub const BUILTIN_ARRAY_DROP_EMPTY: u16 = 532;
9865/// `BUILTIN_QUOTEDZPUTS` constant — run top-of-stack value through
9866/// `crate::ported::utils::quotedzputs` and push the quoted result.
9867/// Used by the cond xtrace path so non-printable bytes (e.g.
9868/// `$'\C-[OP'` expanded ESC+OP) get re-wrapped in `$'…'` form for
9869/// the trace prefix line, matching zsh's `Src/exec.c` cond trace
9870/// which calls `quotedzputs(operand, xtrerr)` on each side. Bug
9871/// surfaced when `[[ -n $'\C-[OP' ]]` traced as `[[ -n OP ]]`
9872/// (raw bytes leaked through the terminal) vs zsh's
9873/// `[[ -n $'\C-[OP' ]]` source-form preservation.
9874pub const BUILTIN_QUOTEDZPUTS: u16 = 533;
9875/// `BUILTIN_QUOTE_TOKENIZED_OUTPUT` — port of
9876/// `crate::ported::exec::quote_tokenized_output` (Src/exec.c:2114)
9877/// applied to top-of-stack scalar. Used by cond xtrace for the RHS
9878/// of pattern-context comparisons (`=` / `==` / `!=`) where C zsh
9879/// emits the SOURCE form: untokenize lexer tokens (Star → `*`,
9880/// Inpar → `(`, …) and backslash-escape special chars, but
9881/// preserve literal ASCII unchanged. Distinct from quotedzputs
9882/// which wraps the whole string in `'…'` / `$'…'` based on
9883/// non-printability — that's wrong for `[[ x = a* ]]` which must
9884/// render as `[[ x = a* ]]`, not `'a*'`.
9885pub const BUILTIN_QUOTE_TOKENIZED_OUTPUT: u16 = 534;
9886
9887/// Bridge into subst_port::substitute_brace_array for nested forms
9888/// that need to PRESERVE array shape across the expand_string
9889/// boundary. Stack: `[content_string]`. Returns Value::Array of the
9890/// per-element words. Used by the compile path for
9891/// `${(@)<nested>...##pat}` shapes — the standard substitute_brace
9892/// returns String which collapses array→scalar; this builtin
9893/// preserves the multi-word output via paramsubst's third return
9894/// (`nodes` vec, the C source's `aval` thread).
9895pub const BUILTIN_BRIDGE_BRACE_ARRAY: u16 = 347;
9896
9897/// Word-segment concat with FIRST/LAST sticking. Stack: [lhs, rhs].
9898/// Used for default unquoted splice forms (`${arr[@]}`, `$@`, `$*`)
9899/// where prefix sticks to first element only and suffix to last only.
9900///
9901/// Distribution table:
9902/// - both scalar: `Value::str(a + b)` (fast path)
9903/// - lhs scalar, rhs Array(b₀..bₙ): `Value::Array([lhs+b₀, b₁, …, bₙ])`
9904/// - lhs Array(a₀..aₙ), rhs scalar: `Value::Array([a₀, …, aₙ₋₁, aₙ+rhs])`
9905/// - both Array: `Value::Array([a₀, …, aₙ₋₁, aₙ+b₀, b₁, …, bₙ])`
9906///   (last of lhs merges with first of rhs; the rest stay separate)
9907///
9908/// This is the default zsh semantics for `print -l X${arr[@]}Y` →
9909/// "Xa", "b", "cY" — three distinct args, surrounding text only on ends.
9910pub const BUILTIN_CONCAT_SPLICE: u16 = 319;
9911
9912/// `${(flags)name}` — zsh parameter expansion flags. Stack: [name, flags].
9913/// Flags applied left-to-right. Supported subset (high-value, used by zpwr):
9914///
9915///   `L` — lowercase the value (scalar; or each element if array)
9916///   `U` — uppercase
9917///   `j:sep:` — join array with `sep` (delim is the char after `j`)
9918///   `s:sep:` — split scalar on `sep` (returns Value::Array)
9919///   `f` — split on newlines (shorthand for `s.\n.`)
9920///   `o` — sort array ascending
9921///   `O` — sort array descending
9922///   `P` — indirect: read name's value as another var name, return that's value
9923///   `@` — keep as array (returns Value::Array — useful before `j` etc.)
9924///   `k` — keys of assoc array
9925///   `v` — values of assoc array
9926///   `#` — word count (array length as scalar)
9927///
9928/// Flags can stack: `(jL)` joins then lowercases; `(s.,.U)` splits on `,`
9929/// then uppercases each element. The long-tail flags (`q`, `qq`, `qqq` for
9930/// quoting, `A` for assoc, `%` for prompt expansion, `e`/`g` for re-eval,
9931/// `n`/`p` for numeric, `t` for type, etc.) are deferred — they hit the
9932/// runtime fallback via the catch-all expansion path.
9933pub const BUILTIN_PARAM_FLAG: u16 = 297;
9934
9935/// `ShellHost` implementation that delegates to the current `ShellExecutor`
9936/// via the `with_executor` thread-local.
9937///
9938/// Construct fresh on each VM run (it carries no state itself). The VM
9939/// dispatches host method calls during `vm.run()`, and `with_executor`
9940/// resolves to the executor pointer set by `ExecutorContext::enter`.
9941/// fusevm-host implementation tying bytecode ops to the
9942/// shell executor.
9943/// zshrs-original — no C counterpart. C zsh has no bytecode VM
9944/// to host; everything runs through `execlist()`/`execpline()`
9945/// directly (Src/exec.c lines 1349/1668).
9946pub struct ZshrsHost;
9947
9948impl fusevm::ShellHost for ZshrsHost {
9949    fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> {
9950        with_executor(|exec| exec.expand_glob(pattern))
9951    }
9952
9953    fn tilde_expand(&mut self, s: &str) -> String {
9954        with_executor(|exec| s.to_string())
9955    }
9956
9957    fn brace_expand(&mut self, s: &str) -> Vec<String> {
9958        // Direct call to the canonical brace expander
9959        // (Src/glob.c::xpandbraces port at glob.rs:1678). Was
9960        // routing through singsub which uses PREFORK_SINGLE — that
9961        // flag explicitly suppresses brace expansion in subst.c:166,
9962        // so `print X{1,2,3}Y` returned the literal string.
9963        //
9964        // brace_ccl: respect the BRACE_CCL option which the bracket-
9965        // class form `{a-z}` requires. Pull from executor options.
9966        let brace_ccl = with_executor(|exec| opt_state_get("braceccl").unwrap_or(false));
9967        crate::ported::glob::xpandbraces(s, brace_ccl)
9968    }
9969
9970    fn str_match(&mut self, s: &str, pattern: &str) -> bool {
9971        // Shell glob match — `*`, `?`, `[...]`, alternation. After the
9972        // cond path moved to BUILTIN_COND_STRMATCH, the consumer here
9973        // is the `case` arm dispatch, whose bad-pattern semantics are
9974        // Src/loop.c:663-667: `if (!(pprog = patcompile(pat, ...)))
9975        // zerr("bad pattern: %s", pat);` — errflag set, the arm
9976        // doesn't match, and the script aborts at the next command
9977        // boundary (matching `zsh -fc 'case x in [a-) ...'` printing
9978        // the diagnostic with exit 0 = untouched lastval).
9979        let mut pat_tok = pattern.to_string();
9980        crate::ported::glob::tokenize(&mut pat_tok);
9981        if crate::ported::pattern::patcompile(
9982            &pat_tok,
9983            crate::ported::zsh_h::PAT_STATIC as i32,
9984            None,
9985        )
9986        .is_none()
9987        {
9988            crate::ported::utils::zerr(&format!("bad pattern: {}", pattern)); // c:667
9989            return false;
9990        }
9991        glob_match_static(s, pattern)
9992    }
9993
9994    fn expand_param(&mut self, name: &str, _modifier: u8, _args: &[Value]) -> Value {
9995        // Sole funnel: route through `getsparam` matching C zsh's
9996        // `getsparam(name)` → `getvalue` → `getstrvalue` →
9997        // `Param.gsu->getfn` dispatch (Src/params.c:3076 / 2335).
9998        //
9999        // The lookup chain (GSU dispatch + variables + env + array-
10000        // join) lives in `params::getsparam`; subst.rs and this
10001        // bridge both call into it so the logic is in exactly one
10002        // place — mirroring C's "every read goes through getsparam"
10003        // architecture. fuseVM bytecode triggers this bridge when
10004        // the VM hits a PARAM opcode, equivalent to C's wordcode VM
10005        // resolving a parameter read during `exec.c` execution.
10006        //
10007        // Modifier handling: the `_modifier` / `_args` parameters
10008        // are populated by the bytecode compiler but applied by
10009        // separate VM opcodes (LENGTH/STRIP/SUBST/etc.) downstream
10010        // of this fetch — matching C's split between getsparam
10011        // (value fetch) and paramsubst's modifier-walk loop. This
10012        // bridge is the value-fetch step only.
10013        let val_str = crate::ported::params::getsparam(name).unwrap_or_default();
10014        Value::str(val_str)
10015    }
10016
10017    fn process_sub_in(&mut self, sub: &fusevm::Chunk) -> String {
10018        // c:Src/exec.c::getproc — `<(cmd)` uses pipe + fork + the
10019        // `/dev/fd/N` filesystem entry (where N is the read end of
10020        // the pipe held open in the parent). Consumer opens
10021        // `/dev/fd/N`, reads the cmd's stdout through the pipe.
10022        // Both macOS and Linux expose `/dev/fd` for held-open file
10023        // descriptors. Previous Rust port captured stdout into
10024        // `/tmp/zshrs_psub_*` tempfiles synchronously — works for
10025        // `diff <(a) <(b)` style readers that scan once but diverges
10026        // from zsh's observable path string and breaks any consumer
10027        // that introspects the path or expects a non-seekable pipe.
10028        let mut fds: [libc::c_int; 2] = [-1, -1];
10029        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
10030            // Pipe creation failed — fall back to tempfile so we at
10031            // least return SOMETHING.
10032            let fifo_path = format!(
10033                "/tmp/zshrs_psub_fallback_{}_{}",
10034                std::process::id(),
10035                with_executor(|e| {
10036                    let n = e.process_sub_counter;
10037                    e.process_sub_counter += 1;
10038                    n
10039                })
10040            );
10041            let _ = fs::remove_file(&fifo_path);
10042            return fifo_path;
10043        }
10044        let (read_end, write_end) = (fds[0], fds[1]);
10045        let sub_for_child = sub.clone();
10046        match unsafe { libc::fork() } {
10047            -1 => {
10048                unsafe {
10049                    libc::close(read_end);
10050                    libc::close(write_end);
10051                }
10052                return String::from("/dev/null");
10053            }
10054            0 => {
10055                // Child: close read end, dup write end to stdout,
10056                // run the sub-chunk, exit. The exit closes the
10057                // write end automatically, so the parent's reader
10058                // gets EOF when the cmd finishes.
10059                unsafe {
10060                    libc::close(read_end);
10061                    libc::dup2(write_end, libc::STDOUT_FILENO);
10062                    libc::close(write_end);
10063                }
10064                crate::fusevm_disasm::maybe_print_stdout("process_subst_in", &sub_for_child);
10065                let mut vm = fusevm::VM::new(sub_for_child);
10066                register_builtins(&mut vm);
10067                vm.set_shell_host(Box::new(ZshrsHost));
10068                let _ = vm.run();
10069                let _ = std::io::stdout().flush();
10070                unsafe { libc::_exit(0) };
10071            }
10072            child_pid => {
10073                // c:Src/exec.c:5092 `procsubstpid = pid;` — record the
10074                // forked child's PID so `${sysparams[procsubstpid]}`
10075                // returns it (was reading the never-updated atomic, so it
10076                // always came back 0). p10k's gitstatus daemon reads
10077                // `sysparams[procsubstpid]` right after `sysopen <(cmd)`
10078                // to track its worker PID; with 0 the daemon's self-check
10079                // failed and gitstatus fell back to re-downloading
10080                // gitstatusd — surfacing as "no prebuilt gitstatusd".
10081                crate::ported::exec::procsubstpid
10082                    .store(child_pid, std::sync::atomic::Ordering::Relaxed);
10083                // Parent: close write end, keep read end open under
10084                // the same fd value so `/dev/fd/N` resolves to the
10085                // pipe's read side. NOTE: FD_CLOEXEC must STAY clear
10086                // — consumers like `cat <(cmd)` and `diff <(a) <(b)`
10087                // discover the fd via exec inheritance, so closing
10088                // on exec defeats the whole point. C zsh's getproc
10089                // (Src/exec.c:5045+) leaves the fd open across exec.
10090                unsafe {
10091                    libc::close(write_end);
10092                }
10093                // Park read_end for close-after-consuming-command,
10094                // exactly like process_sub_out does for its write_end
10095                // (c:Src/exec.c addfilelist(NULL, fd) → deletefilelist).
10096                // WITHOUT this the parent's read_end stayed open for
10097                // the whole shell lifetime: p10k's async worker /
10098                // realtime clock do `exec {fd}< <(cmd)` on every prompt,
10099                // so each keystroke/redraw leaked a pipe fd until the
10100                // ~256-fd limit was hit and the shell locked up
10101                // (107 leaked pipes + 107 unreaped children observed).
10102                let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
10103                PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, read_end)));
10104                // Reap the forked child so it doesn't linger as a
10105                // zombie. `<(cmd)` children are fire-and-forget (their
10106                // output flows through the pipe); C reaps them via the
10107                // job machinery. A non-blocking reap here is scheduled;
10108                // do a best-effort WNOHANG now and the rest drain on
10109                // subsequent proc-subs / prompt cycles.
10110                crate::fusevm_bridge::note_psub_child(child_pid);
10111            }
10112        }
10113        format!("/dev/fd/{}", read_end)
10114    }
10115
10116    fn process_sub_out(&mut self, sub: &fusevm::Chunk) -> String {
10117        // c:Src/exec.c:5025 getproc, PATH_DEV_FD branch — `>(cmd)`
10118        // (out == 0): `mpipe(pipes)`, fork; the CHILD `redup(pipes[0],
10119        // 0)` (pipe read end onto stdin) and `closem` drops the write
10120        // end; the PARENT closes pipes[0] and hands the consumer
10121        // `/dev/fd/<pipes[1]>` (the write end). The previous Rust port
10122        // used mkfifo + a child that BLOCKED in open(FIFO, O_RDONLY)
10123        // before running cmd — with no writer the child never started,
10124        // never exited, and kept its inherited stdout (e.g. a `$()`
10125        // capture pipe) open forever: `a=$(print -r -- >(true))` hung.
10126        // With the pipe shape the child runs immediately and exits,
10127        // releasing inherited fds exactly like zsh (verified: zsh
10128        // blocks ~2s on `a=$(print -r -- >(sleep 2))`, then EOFs).
10129        let mut fds: [libc::c_int; 2] = [-1, -1];
10130        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
10131            // Pipe creation failed — fall back to a plain temp file so
10132            // the consumer at least has a writable path.
10133            let fallback = format!(
10134                "/tmp/zshrs_psub_out_{}_{}",
10135                std::process::id(),
10136                with_executor(|e| {
10137                    let n = e.process_sub_counter;
10138                    e.process_sub_counter += 1;
10139                    n
10140                })
10141            );
10142            let _ = fs::write(&fallback, "");
10143            return fallback;
10144        }
10145        let (read_end, write_end) = (fds[0], fds[1]);
10146        let sub_for_child = sub.clone();
10147        match unsafe { libc::fork() } {
10148            -1 => {
10149                unsafe {
10150                    libc::close(read_end);
10151                    libc::close(write_end);
10152                }
10153                String::from("/dev/null")
10154            }
10155            0 => {
10156                // Child: close the write end (c: closem after redup),
10157                // dup the read end onto stdin (c: redup(pipes[0], 0)),
10158                // run the sub-chunk, exit. Other std fds stay
10159                // inherited — zsh's child keeps the surrounding
10160                // command's stdout/stderr.
10161                unsafe {
10162                    libc::close(write_end);
10163                    libc::dup2(read_end, libc::STDIN_FILENO);
10164                    libc::close(read_end);
10165                }
10166                crate::fusevm_disasm::maybe_print_stdout("process_subst_out:child", &sub_for_child);
10167                let mut vm = fusevm::VM::new(sub_for_child);
10168                register_builtins(&mut vm);
10169                vm.set_shell_host(Box::new(ZshrsHost));
10170                let _ = vm.run();
10171                unsafe { libc::_exit(0) };
10172            }
10173            child_pid => {
10174                // c:Src/exec.c:5143 `procsubstpid = pid;` — same fix as
10175                // the `<(cmd)` in-path above: record the forked child's
10176                // PID for `${sysparams[procsubstpid]}` (was always 0).
10177                crate::ported::exec::procsubstpid
10178                    .store(child_pid, std::sync::atomic::Ordering::Relaxed);
10179                // Parent: close the read end, keep the write end open
10180                // under its fd value so `/dev/fd/N` resolves to the
10181                // pipe's write side. FD_CLOEXEC must STAY clear —
10182                // consumers (`tee >(cmd)`) discover the fd via exec
10183                // inheritance, matching process_sub_in above and C's
10184                // fdtable[fd] = FDT_PROC_SUBST bookkeeping. Park the
10185                // fd for close-after-consuming-command (c: addfilelist
10186                // (NULL, fd) → deletefilelist) so the child's reader
10187                // EOFs — without this `tee >(wc -c) </dev/null` left
10188                // wc blocked until shell exit.
10189                unsafe {
10190                    libc::close(read_end);
10191                }
10192                let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
10193                PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, write_end)));
10194                format!("/dev/fd/{}", write_end)
10195            }
10196        }
10197    }
10198
10199    fn subshell_begin(&mut self) {
10200        with_executor(|exec| {
10201            // libc::umask returns the previous mask AND sets the new
10202            // one; call with current value to read without changing.
10203            let cur_umask = unsafe {
10204                let m = libc::umask(0o022);
10205                libc::umask(m);
10206                m as u32
10207            };
10208            // Snapshot paramtab + hashed-storage too (step 1 of the
10209            // store unification mirrors writes there; restoring only
10210            // the HashMaps leaks subshell-scoped writes to the parent
10211            // via paramtab readers like `paramsubst → vars_get`).
10212            let paramtab_snap = crate::ported::params::paramtab()
10213                .read()
10214                .ok()
10215                .map(|t| t.clone())
10216                .unwrap_or_default();
10217            let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
10218                .lock()
10219                .ok()
10220                .map(|m| m.clone())
10221                .unwrap_or_default();
10222            exec.subshell_snapshots.push(SubshellSnapshot {
10223                paramtab: paramtab_snap,
10224                paramtab_hashed_storage: paramtab_hashed_snap,
10225                positional_params: exec.pparams(),
10226                env_vars: env::vars().collect(),
10227                // Save the LOGICAL pwd ($PWD env), not `current_dir()`'s
10228                // symlink-resolved path. zsh's subshell isolation per
10229                // Src/exec.c at the `entersubsh` path treats `pwd` (the
10230                // shell-tracked logical PWD) as the carrier — see
10231                // `Src/builtin.c:1239-1242` where cd writes the logical
10232                // dest into `pwd`. Falling back to current_dir() only
10233                // when PWD is unset matches `setupvals` at
10234                // `Src/init.c:1100+`.
10235                cwd: env::var("PWD")
10236                    .ok()
10237                    .map(PathBuf::from)
10238                    .or_else(|| env::current_dir().ok()),
10239                umask: cur_umask,
10240                // Snapshot canonical `traps_table` — bin_trap writes
10241                // there (`Src/builtin.c`).
10242                traps: crate::ported::builtin::traps_table()
10243                    .lock()
10244                    .map(|t| t.clone())
10245                    .unwrap_or_default(),
10246                // Snapshot option store so `(set -e)` /
10247                // `(setopt extendedglob)` don't leak to parent.
10248                opts: crate::ported::options::opt_state_snapshot(),
10249                // c:Src/exec.c — fork() copies the alias table to
10250                // the subshell. `(alias x=y)` inside the subshell
10251                // dies with the child; the parent doesn't see x.
10252                // Snapshot here so subshell_end can restore.
10253                // Bug #209 in docs/BUGS.md.
10254                aliases: crate::ported::hashtable::aliastab_lock()
10255                    .read()
10256                    .ok()
10257                    .map(|t| t.iter().map(|(k, v)| (k.clone(), v.text.clone())).collect())
10258                    .unwrap_or_default(),
10259                // c:Src/exec.c::entersubsh — same fork-copy
10260                //   semantics for shfunctab. `(f() { ... })` defined
10261                //   inside the subshell dies with the child; parent's
10262                //   `type f` reports "not found". Bug #208 in
10263                //   docs/BUGS.md.
10264                shfuncs: crate::ported::hashtable::shfunctab_lock()
10265                    .read()
10266                    .ok()
10267                    .map(|t| t.snapshot())
10268                    .unwrap_or_default(),
10269                functions_compiled: exec.functions_compiled.clone(),
10270                function_source: exec.function_source.clone(),
10271                // c:Src/exec.c::entersubsh — subshell forks its own
10272                // modulestab. A `(zmodload zsh/X)` inside the
10273                // subshell flips MOD_INIT_B on the CHILD's
10274                // modulestab; when the child exits the change
10275                // dies with it. zshrs's in-process subshell would
10276                // otherwise leak the load to the parent.
10277                // Bug #210 in docs/BUGS.md. Snapshot just the
10278                // (name → flags) pairs since the only mutating
10279                // field is the flags bitmask (MOD_INIT_B for
10280                // loaded, MOD_UNLOAD for unloaded).
10281                modules: crate::ported::module::MODULESTAB
10282                    .lock()
10283                    .ok()
10284                    .map(|t| {
10285                        t.modules
10286                            .iter()
10287                            .map(|(k, v)| (k.clone(), v.node.flags))
10288                            .collect()
10289                    })
10290                    .unwrap_or_default(),
10291                // c:Src/exec.c::entersubsh — fork-copy semantics for
10292                // THINGYTAB (ZLE widget registry). A subshell `zle -N`
10293                // / `zle -D` mutation dies with the child in C zsh;
10294                // mirror via in-process snapshot. Bug #453.
10295                thingytab: crate::ported::zle::zle_thingy::thingytab()
10296                    .lock()
10297                    .ok()
10298                    .map(|t| t.clone())
10299                    .unwrap_or_default(),
10300                // c:Src/exec.c::entersubsh — same fork-copy for the
10301                // KEYMAPNAMTAB (named keymap registry). `bindkey -N km`
10302                // / `bindkey -D km` inside a subshell dies with the
10303                // child. Bug #454.
10304                keymapnamtab: crate::ported::zle::zle_keymap::keymapnamtab()
10305                    .lock()
10306                    .ok()
10307                    .map(|t| t.clone())
10308                    .unwrap_or_default(),
10309                // c:Src/exec.c::entersubsh fork semantics — `$!`
10310                // (clone::lastpid) set by a `&` INSIDE the subshell
10311                // dies with the child: `( : & ); echo $!` -> 0.
10312                lastpid: crate::ported::modules::clone::lastpid
10313                    .load(std::sync::atomic::Ordering::Relaxed),
10314                // c:Src/exec.c::entersubsh fork semantics — the
10315                // subshell gets a COPY of the job table; its disown/
10316                // wait/`&` mutations die with it. Bug #462.
10317                jobtab: crate::ported::jobs::JOBTAB
10318                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
10319                    .lock()
10320                    .map(|t| t.clone())
10321                    .unwrap_or_default(),
10322                curjob: *crate::ported::jobs::CURJOB
10323                    .get_or_init(|| std::sync::Mutex::new(-1))
10324                    .lock()
10325                    .unwrap(),
10326                prevjob: *crate::ported::jobs::PREVJOB
10327                    .get_or_init(|| std::sync::Mutex::new(-1))
10328                    .lock()
10329                    .unwrap(),
10330                maxjob: *crate::ported::jobs::MAXJOB
10331                    .get_or_init(|| std::sync::Mutex::new(0))
10332                    .lock()
10333                    .unwrap(),
10334                thisjob: *crate::ported::jobs::THISJOB
10335                    .get_or_init(|| std::sync::Mutex::new(-1))
10336                    .lock()
10337                    .unwrap(),
10338                // c:Src/exec.c entersubsh — fork copies the fd table;
10339                // the child's `exec >file` / `exec N<&-` mutations die
10340                // with it. Dup each user-range fd to >= 10 so
10341                // subshell_end can restore the parent's exact table.
10342                saved_fds: (0..10)
10343                    .map(|fd| {
10344                        let dup = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
10345                        (fd, dup)
10346                    })
10347                    .collect(),
10348            });
10349            // C forks for `(...)` — count the fork-equivalent so
10350            // `time (builtin)` reports like zsh (see FORK_EVENTS).
10351            crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
10352            // c:Src/exec.c:2862 — subshell fork flags carry ESUB_PGRP,
10353            // so entersubsh runs `clearjobtab(monitor)` (c:1219): the
10354            // child gets an EMPTY job table plus the procless control
10355            // job grabbed at Src/jobs.c:1828 (`thisjob = initjob()`).
10356            // That's why zsh's `(jobs)` prints nothing and `(kill %1)`
10357            // hits the empty control job instead of the parent's job 1.
10358            // The snapshot pushed above restores the parent's table at
10359            // subshell_end. Bug #462.
10360            let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
10361            crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
10362            // clearjobtab left THISJOB on the control job (Src/jobs.c:
10363            // 1828). In C the very next pipeline's execpline reassigns
10364            // thisjob (Src/exec.c:1700 `thisjob = newjob = initjob()`),
10365            // so by the time any builtin runs, thisjob never aliases
10366            // the control job. zshrs has no per-pipeline job slot —
10367            // model the between-pipelines state (-1) so getjob's
10368            // `jobnum != thisjob` (c:jobs.c:2107) doesn't reject %1 and
10369            // setcurjob doesn't demote an inherited curjob that
10370            // collides with the control slot.
10371            *crate::ported::jobs::THISJOB
10372                .get_or_init(|| std::sync::Mutex::new(-1))
10373                .lock()
10374                .unwrap() = -1;
10375            // Subshell starts with EXIT trap cleared so the parent's
10376            // EXIT handler doesn't fire when the subshell ends. zsh:
10377            // each subshell has its own trap context. Other signals
10378            // are inherited (well, parent's are still in place — but
10379            // a trap set INSIDE the subshell shouldn't leak out).
10380            if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
10381                t.remove("EXIT");
10382            }
10383            let level = exec
10384                .scalar("ZSH_SUBSHELL")
10385                .and_then(|s| s.parse::<i32>().ok())
10386                .unwrap_or(0);
10387            // c:Src/exec.c — ZSH_SUBSHELL carries PM_READONLY (declared
10388            // in params.rs special_params); setsparam would be rejected
10389            // by assignstrvalue's PM_READONLY guard. Write u_val
10390            // directly — same bypass pattern as BUILTIN_SET_LINENO at
10391            // line 2784. C zsh's PM_SPECIAL GSU vtable handles this
10392            // implicitly via the setfn callback.
10393            let new_level = (level + 1) as i64;
10394            if let Ok(mut tab) = crate::ported::params::paramtab().write() {
10395                if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
10396                    pm.u_val = new_level;
10397                    pm.u_str = Some(new_level.to_string());
10398                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
10399                }
10400            }
10401        });
10402        // Bump SUBSHELL_DEPTH so zexit defers process::exit (see
10403        // SUBSHELL_DEPTH declaration in src/ported/builtin.rs for
10404        // rationale).
10405        crate::ported::builtin::SUBSHELL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
10406        // c:Src/exec.c::entersubsh — C zsh's subshell is a forked
10407        // child process: signals sent to the parent (via `kill $$`
10408        // inside the subshell, where `$$` is the parent's pid)
10409        // never reach the child's signal handlers. zshrs's
10410        // in-process subshell shares the process pid with the
10411        // parent, so without queueing the subshell's trap handler
10412        // fires for signals that zsh would deliver only to the
10413        // parent. Queue signals across the subshell body so the
10414        // parent's restored trap table sees them after
10415        // subshell_end's unqueue drain. Bug #450.
10416        crate::ported::signals_h::queue_signals();
10417    }
10418
10419    fn subshell_end(&mut self) -> Option<i32> {
10420        // Fire subshell's EXIT trap BEFORE restoring parent state so
10421        // the trap body sees the subshell's vars and exit status. zsh
10422        // forks for `(...)` so the trap runs in the child process,
10423        // before exit. We mirror by running it here, just before the
10424        // pop+restore. REMOVE the trap before firing so the inner
10425        // execute_script doesn't fire it again at its own end.
10426        let exit_trap_body = crate::ported::builtin::traps_table()
10427            .lock()
10428            .ok()
10429            .and_then(|mut t| t.remove("EXIT"));
10430        if let Some(body) = exit_trap_body {
10431            // Execute the trap body. Errors during trap execution
10432            // don't bubble — zsh ignores trap-body errors.
10433            with_executor(|exec| {
10434                let _ = exec.execute_script(&body);
10435            });
10436        }
10437        with_executor(|exec| {
10438            if let Some(snap) = exec.subshell_snapshots.pop() {
10439                // Restore paramtab + hashed storage so subshell-scoped
10440                // writes via setsparam/setaparam/sethparam don't leak
10441                // to the parent via paramtab readers.
10442                if let Some(tab) = crate::ported::params::paramtab()
10443                    .write()
10444                    .ok()
10445                    .as_deref_mut()
10446                {
10447                    *tab = snap.paramtab;
10448                }
10449                // c:Src/exec.c::entersubsh fork semantics — restore
10450                // the parent's `$!`; a background job inside `(...)`
10451                // dies with the child in C zsh.
10452                crate::ported::modules::clone::lastpid
10453                    .store(snap.lastpid, std::sync::atomic::Ordering::Relaxed);
10454                // c:Src/exec.c::entersubsh fork semantics — restore the
10455                // parent's job table + curjob/prevjob/maxjob/thisjob.
10456                // The subshell mutated only its own copy. Bug #462.
10457                if let Ok(mut t) = crate::ported::jobs::JOBTAB
10458                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
10459                    .lock()
10460                {
10461                    *t = snap.jobtab;
10462                }
10463                *crate::ported::jobs::CURJOB
10464                    .get_or_init(|| std::sync::Mutex::new(-1))
10465                    .lock()
10466                    .unwrap() = snap.curjob;
10467                *crate::ported::jobs::PREVJOB
10468                    .get_or_init(|| std::sync::Mutex::new(-1))
10469                    .lock()
10470                    .unwrap() = snap.prevjob;
10471                *crate::ported::jobs::MAXJOB
10472                    .get_or_init(|| std::sync::Mutex::new(0))
10473                    .lock()
10474                    .unwrap() = snap.maxjob;
10475                *crate::ported::jobs::THISJOB
10476                    .get_or_init(|| std::sync::Mutex::new(-1))
10477                    .lock()
10478                    .unwrap() = snap.thisjob;
10479                if let Some(m) = crate::ported::params::paramtab_hashed_storage()
10480                    .lock()
10481                    .ok()
10482                    .as_deref_mut()
10483                {
10484                    *m = snap.paramtab_hashed_storage;
10485                }
10486                exec.set_pparams(snap.positional_params);
10487                // Restore the OS env to its pre-subshell state.
10488                // Removes any `export` writes the subshell made, and
10489                // restores any vars the subshell unset. Without this
10490                // `(export y=sub)` would leak `y` to the parent shell.
10491                let current: HashMap<String, String> = env::vars().collect();
10492                for k in current.keys() {
10493                    if !snap.env_vars.contains_key(k) {
10494                        env::remove_var(k);
10495                    }
10496                }
10497                for (k, v) in &snap.env_vars {
10498                    if current.get(k) != Some(v) {
10499                        env::set_var(k, v);
10500                    }
10501                }
10502                if let Some(cwd) = snap.cwd {
10503                    let _ = env::set_current_dir(&cwd);
10504                    // Resync $PWD env so a parent `pwd` doesn't read
10505                    // the cwd the subshell `cd`'d into.
10506                    env::set_var("PWD", &cwd);
10507                }
10508                // Restore umask. zsh's `(umask 077)` doesn't leak to
10509                // parent because the subshell forks; we run in-process
10510                // so we manually reset.
10511                unsafe {
10512                    libc::umask(snap.umask as libc::mode_t);
10513                }
10514                // Restore parent's traps (the subshell's own traps die
10515                // with it). zsh: `(trap "X" USR1)` doesn't leak the
10516                // USR1 trap out of the subshell. Write back to the
10517                // canonical `traps_table` (bin_trap writes there).
10518                if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
10519                    *t = snap.traps;
10520                }
10521                // Restore parent's option store so `(set -e)` /
10522                // `(setopt extendedglob)` don't leak. zsh forks
10523                // subshells so child option changes die with the
10524                // child; we run in-process and must restore.
10525                crate::ported::options::opt_state_restore(snap.opts);
10526                // c:Src/exec.c — fork() means alias mutations in a
10527                // subshell die with the child. Restore parent's
10528                // alias table from snapshot. Clear current entries
10529                // then re-add parent's. Bug #209 in docs/BUGS.md.
10530                if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
10531                    tab.clear();
10532                    for (name, text) in snap.aliases {
10533                        tab.add(crate::ported::zsh_h::alias {
10534                            node: crate::ported::zsh_h::hashnode {
10535                                next: None,
10536                                nam: name,
10537                                flags: 0,
10538                            },
10539                            text,
10540                            inuse: 0,
10541                        });
10542                    }
10543                }
10544                // c:Src/exec.c::entersubsh — same fork-copy
10545                //   semantics for shfunctab. Restore parent's function
10546                //   table from snapshot so `(f() { ... })` definitions
10547                //   inside the subshell don't leak to the parent.
10548                //   Bug #208 in docs/BUGS.md.
10549                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
10550                    tab.restore(snap.shfuncs);
10551                }
10552                // Restore the runtime dispatch tables (compiled chunks
10553                // + source). Without these, a subshell-defined
10554                // override leaves its bytecode in place even after
10555                // shfunctab is restored — `g` after the subshell would
10556                // still run the override.
10557                exec.functions_compiled = snap.functions_compiled;
10558                exec.function_source = snap.function_source;
10559                // c:Src/exec.c::entersubsh — restore parent's
10560                // modulestab so a subshell `(zmodload zsh/X)` doesn't
10561                // leak to the parent. Bug #210 in docs/BUGS.md.
10562                // Restore via per-module flag write since the
10563                // snapshot is `(name → flags)` only.
10564                if let Ok(mut t) = crate::ported::module::MODULESTAB.lock() {
10565                    for (name, saved_flags) in &snap.modules {
10566                        if let Some(m) = t.modules.get_mut(name) {
10567                            m.node.flags = *saved_flags;
10568                        }
10569                    }
10570                }
10571                // c:Src/exec.c::entersubsh — restore parent's THINGYTAB
10572                // so a subshell's `zle -N w f` / `zle -D w` doesn't
10573                // affect the parent's widget registry. Bug #453.
10574                if let Ok(mut t) = crate::ported::zle::zle_thingy::thingytab().lock() {
10575                    *t = snap.thingytab;
10576                }
10577                // Same for KEYMAPNAMTAB. Bug #454.
10578                if let Ok(mut t) = crate::ported::zle::zle_keymap::keymapnamtab().lock() {
10579                    *t = snap.keymapnamtab;
10580                }
10581                // c:Src/exec.c entersubsh fork semantics — restore the
10582                // parent's user-range fd table. A bare `exec >file` /
10583                // `exec N>&-` inside `(...)` died with the C child;
10584                // the in-process subshell must undo it here. Flush
10585                // Rust's stdout buffer FIRST so bytes the subshell
10586                // printed drain to the SUBSHELL's fd 1, not the
10587                // restored parent fd.
10588                {
10589                    use std::io::Write;
10590                    let _ = std::io::stdout().flush();
10591                }
10592                for (fd, saved) in snap.saved_fds {
10593                    unsafe {
10594                        if saved >= 0 {
10595                            libc::dup2(saved, fd);
10596                            libc::close(saved);
10597                        } else {
10598                            // fd was closed at entry; close whatever
10599                            // the subshell opened on that slot.
10600                            libc::close(fd);
10601                        }
10602                    }
10603                }
10604            }
10605        });
10606        // Decrement SUBSHELL_DEPTH. If a deferred subshell exit
10607        // landed inside (EXIT_PENDING set with depth > 0), promote
10608        // the deferred status into the subshell's exit status now
10609        // that we're at the boundary, then clear so the parent
10610        // continues. Matches C zsh's "subshell-exit-via-fork"
10611        // boundary where the child's process::exit(N) becomes
10612        // $WAITSTATUS / $? in the parent.
10613        crate::ported::builtin::SUBSHELL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
10614        // c:Src/exec.c — drain the signal queue against the now-
10615        // restored parent trap table. Pairs with the
10616        // queue_signals() call at the end of subshell_begin.
10617        // Any `kill $$` from inside the subshell is processed
10618        // here against OUTER's trap, matching C zsh's
10619        // signal-delivery-to-parent semantics. Bug #450.
10620        crate::ported::signals_h::unqueue_signals();
10621        // c:Src/exec.c — a `( … )` subshell is a FORK in C: an errflag
10622        // abort inside the child ends the child with its lastval as
10623        // the exit status, and the flag dies with the child process.
10624        // The parent's $? picks up the status and the parent's lists
10625        // keep running. zsh 5.9: `(readonly r=1; r=2); echo "after
10626        // $?"` prints `after 1`. zshrs runs the subshell in-process,
10627        // so mirror the fork isolation by clearing ERRFLAG_ERROR at
10628        // the subshell boundary — exec.last_status() already carries
10629        // the child's lastval (synced by ERREXIT_CHECK trigger 4).
10630        //
10631        // ERRFLAG_HARD must die at this boundary too: `${u:?msg}` sets
10632        // errflag |= ERRFLAG_HARD (c:Src/subst.c:3344) and then, in a
10633        // C forked subshell, `_exit(1)` (c:3353) — the parent never
10634        // sees ANY errflag bit. A leaked HARD bit here made every
10635        // subsequent zerr() take the silent arm (c:Src/utils.c:175-177
10636        // `if (errflag || noerrs) { errflag |= ERRFLAG_ERROR; return; }`),
10637        // so the next eval/source's parse silently "failed" and the
10638        // D04 harness shell wedged after chunk 10's
10639        // `(print ${unset1:?exiting1})`.
10640        crate::ported::utils::errflag.fetch_and(
10641            !(crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
10642            std::sync::atomic::Ordering::Relaxed,
10643        );
10644        let exit_pending =
10645            crate::ported::builtin::EXIT_PENDING.load(std::sync::atomic::Ordering::Relaxed);
10646        if exit_pending != 0 {
10647            // c:Src/builtin.c — `exit N` masks N to 8 bits because
10648            // POSIX _exit takes the low byte as status. `(exit 256)`
10649            // and `(exit 0)` are indistinguishable to the parent;
10650            // `(exit 257)` exits with 1. Without the mask zshrs's
10651            // in-process subshell propagated the full i32 (256) into
10652            // the parent's $?, diverging from zsh.
10653            let raw = crate::ported::builtin::EXIT_VAL.load(std::sync::atomic::Ordering::Relaxed);
10654            let val = raw & 0xFF;
10655            with_executor(|exec| exec.set_last_status(val));
10656            crate::ported::builtin::EXIT_PENDING.store(0, std::sync::atomic::Ordering::Relaxed);
10657            crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
10658            crate::ported::builtin::BREAKS.store(0, std::sync::atomic::Ordering::Relaxed);
10659            // Set the post-subshell-exit guard. The next GET_VAR
10660            // sync_status path consults this to skip its
10661            // vm.last_status→LASTVAL sync (which would overwrite the
10662            // deferred-exit status we just set with stale vm state
10663            // since SubshellEnd doesn't propagate status into the
10664            // VM). Cleared as soon as the next sync_status sees it.
10665            SUBSHELL_EXIT_STATUS_PENDING.with(|c| c.set(true));
10666            // Return the deferred-exit status so the VM updates its
10667            // own `last_status`. Otherwise run_chunk's post-script
10668            // `set_last_status(vm.last_status)` would clobber LASTVAL
10669            // back to the stale pre-subshell value.
10670            return Some(val);
10671        }
10672        None
10673    }
10674
10675    fn redirect(&mut self, fd: u8, op: u8, target: &str) {
10676        // Apply a redirection at the OS level for the next command/builtin.
10677        // The host tracks saved fds in a per-executor stack so a future
10678        // `with_redirects_end` can restore. For now, this is a thin wrapper
10679        // that performs the dup2; pairing with explicit save/restore is
10680        // delivered by `with_redirects_begin/end`.
10681        with_executor(|exec| exec.host_apply_redirect(fd, op, target));
10682    }
10683
10684    fn with_redirects_begin(&mut self, count: u8) {
10685        with_executor(|exec| exec.host_redirect_scope_begin(count));
10686    }
10687
10688    fn regex_match(&mut self, s: &str, regex: &str) -> bool {
10689        // c:Src/Modules/regex.c:54 `zcond_regex_match` — POSIX ERE
10690        // matching + populate `$MATCH` / `$MBEGIN` / `$MEND` /
10691        // `$match[]` / `$mbegin[]` / `$mend[]` (or `$BASH_REMATCH`
10692        // under BASHREMATCH). Direct delegation to the canonical
10693        // port at src/ported/modules/regex.rs:58.
10694        //
10695        // The bridge passthru path delivers TOKEN-form bytes here
10696        // (Inbrack \u{91}, Outbrack \u{92}, Star \u{87}, Quest
10697        // \u{86}, etc.) since the lexer tokenizes regex meta chars
10698        // inside `[[ ]]`. The host regex engine expects ASCII, so
10699        // untokenize the pattern (and subject, for safety) once at
10700        // this boundary. zsh C reaches its POSIX-ERE engine through
10701        // the same untokenize path inside zcond_regex_match.
10702        let s_clean = crate::lex::untokenize(s);
10703        let regex_clean = crate::lex::untokenize(regex);
10704        crate::ported::modules::regex::zcond_regex_match(
10705            &[s_clean.as_str(), regex_clean.as_str()],
10706            crate::ported::modules::regex::ZREGEX_EXTENDED,
10707        ) != 0
10708    }
10709
10710    fn with_redirects_end(&mut self) {
10711        with_executor(|exec| exec.host_redirect_scope_end());
10712        // c:Src/exec.c:5172 — if any redirect in this scope failed
10713        // (noclobber-blocked, ENOENT for read, etc.), the command's
10714        // exit status is forced to 1 regardless of what the (still-
10715        // executed) command's own exit was. C zsh prevents the
10716        // command from running at all when a redirect fails; the
10717        // Rust port still runs it (sinking output to /dev/null in
10718        // the noclobber arm at host_apply_redirect:5481) and then
10719        // overrides $? here. Same observable effect for the common
10720        // pattern `echo x > existing-file` under noclobber.
10721        let failed = with_executor(|exec| {
10722            let f = exec.redirect_failed;
10723            exec.redirect_failed = false;
10724            f
10725        });
10726        if failed {
10727            with_executor(|exec| exec.set_last_status(1));
10728        }
10729    }
10730
10731    fn heredoc(&mut self, content: &str) {
10732        // C `Src/exec.c:4641` — `parsestr(&buf)` runs parameter +
10733        // command substitution on the heredoc body. The lexer's
10734        // quoted-delimiter detection (`<<'EOF'`) routes through the
10735        // `Op::HereDoc` path in `compile_zsh.rs` which short-circuits
10736        // before reaching here; unquoted forms route through the
10737        // BUILTIN_EXPAND_TEXT mode-4 emit path that calls singsub.
10738        // This handler covers the verbatim/quoted case.
10739        with_executor(|exec| exec.host_set_pending_stdin(content.to_string()));
10740    }
10741
10742    fn herestring(&mut self, content: &str) {
10743        // Shell semantics: herestring appends a newline. `<<<` body
10744        // substitution (`Src/exec.c:4655 getherestr` calls
10745        // `quotesubst` + `untokenize`) lands here verbatim; the
10746        // upstream compiler routes through `Op::HereString` after
10747        // BUILTIN_EXPAND_TEXT for the substitution pass, so callers
10748        // of `host.herestring` see the already-expanded form.
10749        let mut s = content.to_string();
10750        s.push('\n');
10751        with_executor(|exec| exec.host_set_pending_stdin(s));
10752    }
10753
10754    fn exec(&mut self, args: Vec<String>) -> i32 {
10755        // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close
10756        // any `>(cmd)` write ends owned by this command once it
10757        // finishes (drops on every return path below).
10758        let _psub_fds = PsubFdGuard;
10759        // c:Src/subst.c paramsubst — when `${var:?msg}` or `${var?msg}`
10760        // triggered the "parameter null or not set" error, errflag
10761        // is raised and zsh aborts the simple command without
10762        // attempting exec. The expansion may have produced empty
10763        // argv[0] which falls into the c:?/permission-denied path
10764        // below, masking the real diagnostic with a spurious
10765        // "permission denied:" line and rc=126 instead of rc=1.
10766        // Honour errflag here so the script ends with the
10767        // paramsubst error as the sole diagnostic. Bug #86.
10768        //
10769        // c:Src/exec.c — C's execlist loop clears ERRFLAG_ERROR
10770        // between sublists when the error came from a NOMATCH-style
10771        // command failure (glob no-match, etc.) so subsequent
10772        // sublists run. zshrs's vm dispatch handles this at the
10773        // post-command-boundary HERE: if THIS command has its
10774        // `current_command_glob_failed` cell set (meaning the glob
10775        // NOMATCH happened during this command's argv prep), surface
10776        // status 1 and clear BOTH the cell AND ERRFLAG_ERROR so the
10777        // NEXT exec call sees a clean state. The errflag from
10778        // genuine script-fatal errors (parse, redirect, paramsubst
10779        // `${:?msg}`) does NOT come paired with glob_failed, so
10780        // those still short-circuit + propagate.
10781        consume_tilde_globsubst_carrier();
10782        let glob_failed = with_executor(|exec| {
10783            let f = exec.current_command_glob_failed.get();
10784            exec.current_command_glob_failed.set(false);
10785            f
10786        });
10787        if glob_failed {
10788            crate::ported::utils::errflag.fetch_and(
10789                !crate::ported::zsh_h::ERRFLAG_ERROR,
10790                std::sync::atomic::Ordering::Relaxed,
10791            );
10792            with_executor(|exec| exec.set_last_status(1));
10793            return 1;
10794        }
10795        // c:Src/subst.c:505-507 — CSH_NULL_GLOB external-path
10796        // boundary: command skipped with `no match` but the NEXT
10797        // sublist runs (zsh -fc 'setopt cshnullglob; ls *nope*;
10798        // print after' prints the error then `after` — verified
10799        // zsh 5.9.1), so clear ERRFLAG like the glob_failed arm.
10800        if consume_badcshglob() {
10801            crate::ported::utils::errflag.fetch_and(
10802                !crate::ported::zsh_h::ERRFLAG_ERROR,
10803                std::sync::atomic::Ordering::Relaxed,
10804            );
10805            with_executor(|exec| exec.set_last_status(1));
10806            return 1;
10807        }
10808        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
10809            & crate::ported::zsh_h::ERRFLAG_ERROR)
10810            != 0
10811        {
10812            return 1;
10813        }
10814        // c:Src/exec.c — two distinct empty-command cases:
10815        //
10816        // 1. args=[""]  — an explicit empty-string command word
10817        //    (`""`, `"\$unset"`, `\$'\$x'`). zsh attempts exec(2)
10818        //    on the empty path → EACCES → "permission denied", \$?
10819        //    = 126.
10820        //
10821        // 2. args=[]    — the WORD LIST is empty (unquoted \$(\$cmd)
10822        //    that produced empty, or an unquoted unset \$var that
10823        //    elided). zsh: no exec is attempted; \$? becomes the
10824        //    last cmd-subst's exit status (the inner sub-VM
10825        //    already set last_status), and the line completes
10826        //    silently. Critically NOT 126.
10827        if args.is_empty() {
10828            // c:Src/exec.c — empty word list passes through to a
10829            // no-op; preserve whatever the inner cmd-subst's exit
10830            // is. Return last_status so the caller's SetStatus
10831            // round-trips correctly.
10832            return with_executor(|exec| exec.last_status());
10833        }
10834        if args[0].is_empty() {
10835            let script_name =
10836                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
10837            let lineno: u64 = with_executor(|exec| {
10838                exec.scalar("LINENO")
10839                    .and_then(|s| s.parse::<u64>().ok())
10840                    .unwrap_or(1)
10841            });
10842            eprintln!("{}:{}: permission denied: ", script_name, lineno);
10843            return 126;
10844        }
10845        // c:Src/exec.c — when any redirect in the current scope
10846        // failed (e.g. noclobber blocked a `>` overwrite), zsh
10847        // refuses to execute the command and exits with status 1.
10848        // The Rust port still applied the command (writing to the
10849        // /dev/null sink installed by host_apply_redirect's
10850        // noclobber arm), but the success status overwrote the
10851        // intended `1`. Short-circuit here so the exec returns 1
10852        // without running the body.
10853        let redir_failed = with_executor(|exec| {
10854            let f = exec.redirect_failed;
10855            exec.redirect_failed = false;
10856            f
10857        });
10858        if redir_failed {
10859            return 1;
10860        }
10861        // Track `$_` as the last argument of the last command (zsh /
10862        // bash convention). Empty arglists leave it untouched.
10863        if let Some(last) = args.last() {
10864            with_executor(|exec| {
10865                exec.set_scalar("_".to_string(), last.clone());
10866            });
10867        }
10868        // Route external command spawning through `executor.execute_external`
10869        // so intercepts (AOP before/after/around), command_hash lookups,
10870        // pre/postexec hooks, and zsh-specific fork-then-exec all apply.
10871        // Without this override, fusevm's default `host.exec` calls
10872        // `Command::new` directly, bypassing zshrs's dispatch logic.
10873        let status = with_executor(|exec| exec.host_exec_external(&args));
10874        // c:Src/jobs.c:1748 waitonejob (no-procs else-branch). zshrs's
10875        // exec model routes external commands through host_exec_external
10876        // (which already waitpid'd in-line); the canonical waitonejob
10877        // expects a Job to derive lastval, but here we already know
10878        // it. Synthesize a procs-less job so waitonejob's no-procs
10879        // branch fires the `pipestats[0]=lastval; numpipestats=1;`
10880        // update via the canonical port.
10881        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
10882        let mut synth = crate::ported::zsh_h::job::default();
10883        crate::ported::jobs::waitonejob(&mut synth);
10884        status
10885    }
10886
10887    fn cmd_subst(&mut self, sub: &fusevm::Chunk) -> String {
10888        // Run the sub-chunk on a nested VM with the same host wired up,
10889        // capturing stdout. The current executor remains active via the
10890        // thread-local — the nested VM uses CallBuiltin to dispatch shell
10891        // ops back through `with_executor`.
10892        let (read_end, write_end) = match os_pipe::pipe() {
10893            Ok(p) => p,
10894            Err(_) => return String::new(),
10895        };
10896        let saved_stdout = unsafe { libc::dup(libc::STDOUT_FILENO) };
10897        if saved_stdout < 0 {
10898            return String::new();
10899        }
10900        let saved_stderr = unsafe { libc::dup(libc::STDERR_FILENO) };
10901        let write_fd = AsRawFd::as_raw_fd(&write_end);
10902        unsafe {
10903            libc::dup2(write_fd, libc::STDOUT_FILENO);
10904        }
10905        drop(write_end);
10906
10907        // c:Bug #56 — publish the saved outer fds so a trap firing
10908        // during the nested VM run can route its body output to the
10909        // PARENT's stdout instead of the cmdsub's pipe-bound fd 1.
10910        // zsh's forked cmdsub gets this for free (trap runs in the
10911        // parent process whose fd 1 is untouched). zshrs's
10912        // in-process cmdsub needs this thread-local stack so the
10913        // trap dispatcher can find the right destination fd.
10914        CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().push((saved_stdout, saved_stderr)));
10915
10916        // Nested scope for `>(cmd)` fd ownership — commands inside
10917        // the cmdsub must not drain the enclosing command's pending
10918        // psub fds (see PSUB_SCOPE_DEPTH).
10919        let _psub_scope = PsubScope::enter();
10920
10921        // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
10922        // which does `zsh_subshell++`; in-process equivalent.
10923        let _subshell_bump = CmdSubstSubshellBump::enter();
10924
10925        crate::fusevm_disasm::maybe_print_stdout("host.cmd_subst", sub);
10926        let mut vm = fusevm::VM::new(sub.clone());
10927        register_builtins(&mut vm);
10928        vm.set_shell_host(Box::new(ZshrsHost));
10929        let _ = vm.run();
10930        let cmd_status = vm.last_status;
10931
10932        CMDSUBST_OUTER_FDS.with(|s| {
10933            s.borrow_mut().pop();
10934        });
10935
10936        unsafe {
10937            libc::dup2(saved_stdout, libc::STDOUT_FILENO);
10938            libc::close(saved_stdout);
10939            if saved_stderr >= 0 {
10940                libc::close(saved_stderr);
10941            }
10942        }
10943
10944        // Inner cmd's status not propagated for the same reason as
10945        // run_command_substitution — see GAPS.md.
10946        let _ = cmd_status;
10947
10948        let mut buf = String::new();
10949        let mut reader = read_end;
10950        let _ = reader.read_to_string(&mut buf);
10951        // Strip trailing newlines (POSIX command substitution semantics)
10952        while buf.ends_with('\n') {
10953            buf.pop();
10954        }
10955        buf
10956    }
10957
10958    fn call_function(&mut self, name: &str, args: Vec<String>) -> Option<i32> {
10959        // c:Src/exec.c — when the command word is empty (e.g. `""`
10960        // or `"$nonexistent"`), zsh attempts the exec(2) which
10961        // returns EACCES ("permission denied") and exits 126. The
10962        // Rust port silently treated empty as a no-op (status 0).
10963        // Match zsh by emitting the diagnostic and returning 126.
10964        if name.is_empty() {
10965            let script_name =
10966                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
10967            let lineno: u64 = with_executor(|exec| {
10968                exec.scalar("LINENO")
10969                    .and_then(|s| s.parse::<u64>().ok())
10970                    .unwrap_or(1)
10971            });
10972            eprintln!("{}:{}: permission denied: ", script_name, lineno);
10973            with_executor(|exec| exec.set_last_status(126));
10974            return Some(126);
10975        }
10976        // c:Src/exec.c — redirect failure in this scope means the
10977        // command should NOT run. The Host::exec path already has
10978        // this gate (at fn exec above); call_function takes external
10979        // commands like `cat <&3` through a different code path, so
10980        // gate here too. Without this, bad-fd redirects produced
10981        // the diagnostic but the external command still ran, so $?
10982        // came out from the command's natural exit instead of the
10983        // forced 1.
10984        let redir_failed = with_executor(|exec| {
10985            let f = exec.redirect_failed;
10986            exec.redirect_failed = false;
10987            f
10988        });
10989        if redir_failed {
10990            with_executor(|exec| exec.set_last_status(1));
10991            return Some(1);
10992        }
10993        // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
10994        // functions, NOT builtins. zshrs ships fast native impls, but they
10995        // must behave like the zsh functions — command-not-found until
10996        // `autoload -Uz <name>` creates a function entry. When autoloaded we
10997        // run the native impl here (short-circuiting the fpath source, which
10998        // can hang zshrs's parser on zsh-specific syntax); when NOT autoloaded
10999        // we fall through (return None → resolution ends in command-not-found),
11000        // matching `zsh -f; zmv` → "command not found: zmv".
11001        if matches!(name, "zmv" | "zcp" | "zln" | "zcalc")
11002            && !with_executor(|exec| exec.function_exists(name))
11003        {
11004            return None;
11005        }
11006        match name {
11007            "zmv" => {
11008                return Some(crate::extensions::ext_builtins::zmv(&args, "mv"));
11009            }
11010            "zcp" => {
11011                return Some(crate::extensions::ext_builtins::zmv(&args, "cp"));
11012            }
11013            "zln" => {
11014                return Some(crate::extensions::ext_builtins::zmv(&args, "ln"));
11015            }
11016            "zcalc" => {
11017                return Some(crate::extensions::ext_builtins::zcalc(&args));
11018            }
11019            // ztest framework (src/extensions/ztest.rs — port of
11020            // ../strykelang's unit-test framework). All zassert_*/
11021            // ztest_* names route through the single try_dispatch
11022            // helper so adding/removing assertions only touches
11023            // ztest.rs.
11024            n if crate::extensions::ztest::try_dispatch_known(n) => {
11025                let status = with_executor(|exec| {
11026                    crate::extensions::ztest::try_dispatch(exec, n, &args).unwrap_or(1)
11027                });
11028                return Some(status);
11029            }
11030            // Daemon-managed z* builtins — thin IPC wrappers. Short-circuit BEFORE
11031            // the function-lookup path so a missing daemon doesn't fall through to
11032            // "command not found". The name list is owned by the daemon crate
11033            // (zshrs_daemon::builtins::ZSHRS_BUILTIN_NAMES); routing through
11034            // try_dispatch keeps this site zero-touch as new z* builtins land.
11035            n if crate::daemon::builtins::is_zshrs_builtin(n) => {
11036                let argv: Vec<String> = std::iter::once(name.to_string()).chain(args).collect();
11037                return Some(crate::daemon::builtins::try_dispatch(n, &argv).unwrap_or(1));
11038            }
11039            _ => {}
11040        }
11041
11042        // c:Src/exec.c:3050-3068 — module-provided builtins (registered
11043        // via each module's `bintab` and folded into the canonical
11044        // `builtintab` by `createbuiltintable`) must dispatch BEFORE
11045        // PATH lookup. fusevm's `shell_builtins::builtin_id` doesn't
11046        // know about per-module entries like `log`
11047        // (Src/Modules/watch.c:693) — they reach call_function as
11048        // CallFunction ops. Consult the merged builtintab here so
11049        // `log` runs the canonical `bin_log` instead of falling
11050        // through to `/usr/bin/log` on macOS. Bug #72 in docs/BUGS.md.
11051        //
11052        // User-defined functions still take precedence over builtins
11053        // (zsh's `alias → function → builtin → external` resolution
11054        // order, c:Src/exec.c:3038-3068). Check `functions_compiled`
11055        // first so a user `log() { ... }` shadows the module bin_log.
11056        // c:Src/exec.c — shfunctab->getnode (the DISABLED-filtering
11057        // accessor) returns NULL for entries flipped to DISABLED via
11058        // `disable -f NAME`. functions_compiled holds the body
11059        // independently of the DISABLED flag, so check shfunctab first
11060        // and mask the lookup when the entry is disabled. Bug #221
11061        // in docs/BUGS.md.
11062        let user_fn_disabled = crate::ported::hashtable::shfunctab_lock()
11063            .read()
11064            .ok()
11065            .and_then(|t| {
11066                let entry = t.get_including_disabled(name)?;
11067                Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
11068            })
11069            .unwrap_or(false);
11070        let has_user_fn =
11071            !user_fn_disabled && with_executor(|exec| exec.functions_compiled.contains_key(name));
11072        if !has_user_fn {
11073            // c:Src/exec.c:3056 — `builtintab->getnode(builtintab,
11074            // cmdarg)` returns NULL for DISABLED entries, falling
11075            // execcmd through to PATH lookup. Mirror by gating the
11076            // bn_in_tab match on the BUILTINS_DISABLED set. Bug #106
11077            // in docs/BUGS.md.
11078            let disabled = crate::ported::builtin::BUILTINS_DISABLED
11079                .lock()
11080                .map(|s| s.contains(name))
11081                .unwrap_or(false);
11082            let bn_in_tab =
11083                !disabled && crate::ported::builtin::createbuiltintable().contains_key(name);
11084            if bn_in_tab {
11085                return Some(dispatch_builtin_raw(name, args));
11086            }
11087        }
11088
11089        // c:Src/lex.c — alias expansion is a LEXER-TIME pass, not a
11090        // run-time lookup. zsh parses the whole `-c` argument (or
11091        // script) before executing, so aliases defined in the same
11092        // parse unit don't apply to commands parsed earlier. Only at
11093        // an INTERACTIVE prompt does each line parse separately with
11094        // the latest aliastab visible.
11095        //
11096        // Gate the run-time alias-rewrite path on `interactive` so
11097        // `alias hi='echo hello'; hi` in `-c` mode falls through to
11098        // "command not found" (matching zsh) while interactive REPL
11099        // input still re-parses with the live aliastab.
11100        let interactive = crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE);
11101        let already_expanding = if interactive {
11102            crate::ported::hashtable::aliastab_lock()
11103                .read()
11104                .ok()
11105                .and_then(|tab| tab.get(name).map(|a| a.inuse != 0))
11106                .unwrap_or(false)
11107        } else {
11108            true // suppress lookup entirely in non-interactive mode
11109        };
11110        let alias_body = if already_expanding {
11111            None
11112        } else {
11113            with_executor(|exec| exec.alias(name))
11114        };
11115        if let Some(body) = alias_body {
11116            let combined = if args.is_empty() {
11117                body
11118            } else {
11119                let quoted: Vec<String> = args
11120                    .iter()
11121                    .map(|a| {
11122                        let escaped = a.replace('\'', "'\\''");
11123                        format!("'{}'", escaped)
11124                    })
11125                    .collect();
11126                format!("{} {}", body, quoted.join(" "))
11127            };
11128            // Bump inuse → run → clear, matching C's lexer behavior.
11129            if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
11130                if let Some(a) = tab.get_mut(name) {
11131                    a.inuse += 1;
11132                }
11133            }
11134            let status = with_executor(|exec| exec.execute_script(&combined).unwrap_or(1));
11135            if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
11136                if let Some(a) = tab.get_mut(name) {
11137                    a.inuse = (a.inuse - 1).max(0);
11138                }
11139            }
11140            return Some(status);
11141        }
11142
11143        // $_ pre-body bump and pending-underscore tracking are
11144        // ZshrsHost-only concerns (prompt rendering). Apply BEFORE
11145        // delegating to dispatch_function_call so the body sees the
11146        // bumped value.
11147        //
11148        // c:Src/exec.c:3491 — `setunderscore((args && nonempty(args))
11149        // ? ((char *) getdata(lastnode(args))) : "")`. C sets $_ to
11150        // the LAST node of the WHOLE args list (which includes argv[0]
11151        // == the function name). So for a no-arg `f`, $_ becomes "f"
11152        // inside the function body. The Rust port at the CallFunction
11153        // op-handler receives `args` WITHOUT the command name
11154        // (compile_zsh.rs:1571 only pushes simple.words[1..]). The
11155        // last() fallback `|| fn_name.clone()` already covers the
11156        // no-arg case, but `exec.set_scalar("_", ...)` writes paramtab
11157        // — the canonical `$_` read goes through `underscoregetfn`
11158        // (params.rs:7836) which reads the `zunderscore` Mutex.
11159        // setsparam("_") doesn't update that mutex, so the body's
11160        // `${_}` returned empty. Bug #279 in docs/BUGS.md. Mirror the
11161        // C `setunderscore` by writing via `set_zunderscore` directly.
11162        let fn_name = name.to_string();
11163        with_executor(|exec| {
11164            let dollar_underscore = args.last().cloned().unwrap_or_else(|| fn_name.clone());
11165            exec.set_scalar("_".to_string(), dollar_underscore.clone());
11166            crate::ported::params::set_zunderscore(std::slice::from_ref(&dollar_underscore));
11167            exec.pending_underscore = Some(dollar_underscore);
11168        });
11169
11170        // Delegate the actual function dispatch to the canonical
11171        // `dispatch_function_call` (which itself wraps the canonical
11172        // `doshfunc` port from `Src/exec.c:5823`). Single doshfunc
11173        // call-site keeps scope-mgmt invariants in one place.
11174        let status = with_executor(|exec| exec.dispatch_function_call(&fn_name, &args));
11175
11176        // Anonymous functions (`() { … } args`, compiled by
11177        // parse_anon_funcdef as `_zshrs_anon_N` / `_zshrs_anon_kw_N`)
11178        // execute exactly ONCE and must not persist. zsh runs the body and
11179        // frees the function, so `${functions}` / `typeset -f` never show
11180        // it. Remove every trace right after the single invocation —
11181        // AFTER `status` is captured, so the body's exit code is preserved
11182        // ($? — calling `unfunction` here would reset it to 0 instead).
11183        // Without this, real plugins that use `() { … }` (fzf-tab, zinit,
11184        // p10k, …) leaked dozens of `_zshrs_anon_N` into `$functions`,
11185        // diverging from zsh's function table on every such config.
11186        if fn_name.starts_with("_zshrs_anon_") {
11187            // `${functions}` / `typeset -f` enumerate the canonical
11188            // `shfunctab` (via scanpmfunctions); the bytecode call path
11189            // also keeps the body in the executor's compiled-fn maps. Clear
11190            // BOTH so no trace of the one-shot anon survives.
11191            crate::ported::hashtable::removeshfuncnode(&fn_name);
11192            with_executor(|exec| {
11193                exec.functions_compiled.remove(&fn_name);
11194                exec.function_source.remove(&fn_name);
11195                exec.function_line_base.remove(&fn_name);
11196                exec.function_def_file.remove(&fn_name);
11197            });
11198        }
11199
11200        // $_ post-body — last call-arg or function name. Mirrors the
11201        // C `setunderscore` invocation after the body returns.
11202        with_executor(|exec| {
11203            let last_call_arg = args.last().cloned().unwrap_or_else(|| fn_name.clone());
11204            exec.set_scalar("_".to_string(), last_call_arg.clone());
11205            exec.pending_underscore = Some(last_call_arg);
11206        });
11207
11208        status
11209    }
11210}
11211
11212// ───────────────────────────────────────────────────────────────────────────
11213/// Render a failed-redirect open error the way C's `zerrmsg` `%e` format
11214/// code does (Src/utils.c): `strerror(errno)` with the first character
11215/// lowercased, except `EIO` (kept capitalized) and `EINTR` (→ "interrupt").
11216/// C's redirect open failures call `zwarn("%e: %s", errno, fname)`
11217/// (Src/exec.c:3741); zshrs's `zwarning` takes a pre-built string, so the
11218/// `%e` part is built here. Replaces the prior hardcoded `ErrorKind` match
11219/// that fell back to a generic "redirect failed" for `EROFS`/`EACCES`/etc.
11220fn redir_errno_msg(err: &std::io::Error) -> String {
11221    let errno = match err.raw_os_error() {
11222        Some(n) if n != 0 => n,
11223        _ => return "redirect failed".to_string(),
11224    };
11225    if errno == libc::EINTR {
11226        return "interrupt".to_string(); // c:zerrmsg %e — EINTR special-case
11227    }
11228    let cptr = unsafe { libc::strerror(errno) };
11229    if cptr.is_null() {
11230        return "redirect failed".to_string();
11231    }
11232    let msg = unsafe { std::ffi::CStr::from_ptr(cptr) }.to_string_lossy();
11233    if errno == libc::EIO {
11234        return msg.into_owned(); // c:zerrmsg %e — EIO keeps capitalization
11235    }
11236    // c:zerrmsg %e — `fputc(tulower(errmsg[0])); fputs(errmsg + 1)`.
11237    let mut chars = msg.chars();
11238    match chars.next() {
11239        Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
11240        None => "redirect failed".to_string(),
11241    }
11242}
11243
11244// Host-routed shell ops: ShellExecutor methods invoked by ZshrsHost from the
11245// fusevm VM. Not a port of Src/exec.c (see file-level docs above) — they're
11246// the bridge between fusevm opcodes and ShellExecutor state.
11247// ───────────────────────────────────────────────────────────────────────────
11248impl ShellExecutor {
11249    // ─── Host-routed shell ops (called by ZshrsHost from fusevm) ────────────
11250
11251    /// Apply a single redirection. The current scope's saved-fd vec gets a
11252    /// dup of the original fd so it can be restored by `host_redirect_scope_end`.
11253    /// `op_byte` matches `fusevm::op::redirect_op::*`.
11254    /// Apply a file-open result to a redirect fd; on error, emit
11255    /// zsh-format diagnostic, set redirect_failed, sink fd to /dev/null.
11256    /// Shared between WRITE/APPEND/READ/CLOBBER arms in
11257    /// host_apply_redirect to keep the error-handling identical.
11258    fn redir_open_or_fail(
11259        fd: i32,
11260        result: std::io::Result<fs::File>,
11261        target: &str,
11262        redirect_failed: &mut bool,
11263    ) -> bool {
11264        match result {
11265            Ok(file) => {
11266                let new_fd = file.into_raw_fd();
11267                unsafe {
11268                    // When the target fd was already closed (e.g. `exec 0<&-;
11269                    // cmd < file`), open() returns the lowest free fd, which is
11270                    // `fd` itself. Then `dup2(fd, fd)` is a no-op: closing new_fd
11271                    // would CLOSE the fd we just opened, AND — since Rust's
11272                    // File::open sets O_CLOEXEC and a no-op dup2 does NOT clear
11273                    // it — an exec'd child would lose the descriptor. So in the
11274                    // reuse case, keep the fd and clear its close-on-exec flag;
11275                    // otherwise dup2 (which clears cloexec on the copy) + close.
11276                    if new_fd != fd {
11277                        libc::dup2(new_fd, fd);
11278                        libc::close(new_fd);
11279                    } else {
11280                        libc::fcntl(fd, libc::F_SETFD, 0);
11281                    }
11282                }
11283                true
11284            }
11285            Err(e) => {
11286                // c:Src/exec.c:3741 — zwarn("%e: %s", errno, fname) with the
11287                // real lineno prefix; redir_errno_msg builds the `%e` errno
11288                // message for all errnos (not just the few hardcoded before).
11289                let msg = redir_errno_msg(&e);
11290                crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
11291                *redirect_failed = true;
11292                // The /dev/null sink keeps a failed scoped redirect
11293                // from leaking the aborted command's output to the
11294                // wrong fd until scope-end restores it. For a bare
11295                // `exec` redirect (permanent, no scope restore) C
11296                // leaves the fd UNTOUCHED — execerr() aborts the
11297                // statement and the original fd 1 keeps flowing
11298                // (A04redirect: `exec >./nonexistent/x` then `echo
11299                // output` still prints). c:Src/exec.c:3735-3742.
11300                let permanent = with_executor(|exec| exec.exec_redirs_permanent);
11301                if !permanent {
11302                    if let Ok(devnull) = fs::OpenOptions::new()
11303                        .read(true)
11304                        .write(true)
11305                        .open("/dev/null")
11306                    {
11307                        let new_fd = devnull.into_raw_fd();
11308                        unsafe {
11309                            if new_fd != fd {
11310                                libc::dup2(new_fd, fd);
11311                                libc::close(new_fd);
11312                            } else {
11313                                libc::fcntl(fd, libc::F_SETFD, 0);
11314                            }
11315                        }
11316                    }
11317                }
11318                false
11319            }
11320        }
11321    }
11322    /// `host_apply_redirect` — see implementation.
11323    pub fn host_apply_redirect(&mut self, fd: u8, op_byte: u8, target: &str) {
11324        // `&>` / `&>>` always target both fd 1 and fd 2 regardless of the
11325        // fd byte the parser supplied (the lexer's tokfd clamp makes the
11326        // raw value unreliable for these forms).
11327        let fd: i32 = if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
11328            1
11329        } else {
11330            fd as i32
11331        };
11332        // c:Src/exec.c — for DUP_READ / DUP_WRITE forms (<&N / >&N),
11333        // validate the source fd is open BEFORE the save-and-dup
11334        // dance below. The save's `dup(fd)` reclaims the lowest free
11335        // fd, which on closed-fd reuse would let dup2(src=N, …)
11336        // succeed against the freshly-claimed slot — masking the
11337        // user's "bad file descriptor" error. Check src_fd first.
11338        if matches!(op_byte, r::DUP_READ | r::DUP_WRITE) {
11339            let n_check = target.trim_start_matches('&');
11340            if n_check != "-" {
11341                if let Ok(src_fd) = n_check.parse::<i32>() {
11342                    if unsafe { libc::fcntl(src_fd, libc::F_GETFD) } == -1 {
11343                        // c:Src/exec.c — zwarn with real lineno prefix.
11344                        crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src_fd));
11345                        self.set_last_status(1);
11346                        self.redirect_failed = true;
11347                        return;
11348                    }
11349                }
11350            }
11351        }
11352        // c:Src/exec.c:3978-3986 — bare `exec` redirects (nullexec==1)
11353        // skip the save entirely: "we specifically *don't* restore the
11354        // original fd's". C's save[] is per-execcmd, so exec's redirs
11355        // never enter an enclosing group's save list either; pushing
11356        // into `redirect_scope_stack.last_mut()` here (the enclosing
11357        // group's scope) made `{ exec 1>&-; … } 2>/dev/null` restore
11358        // stdout at group end — diverging from zsh, which keeps fd 1
11359        // closed for the rest of the script.
11360        if !self.exec_redirs_permanent {
11361            let saved = unsafe { libc::dup(fd) };
11362            if saved >= 0 {
11363                if let Some(top) = self.redirect_scope_stack.last_mut() {
11364                    top.push((fd, saved));
11365                } else {
11366                    // No scope — leave saved fd open and let the next scope
11367                    // reclaim it. (Caller without a scope leaks the dup; this
11368                    // matches `WithRedirects` parser construction always wrapping.)
11369                    unsafe { libc::close(saved) };
11370                }
11371            }
11372            // For `&>` / `&>>` also save fd 2 so the scope restores it after
11373            // the body. Otherwise stderr stays redirected past the command.
11374            if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
11375                let saved2 = unsafe { libc::dup(2) };
11376                if saved2 >= 0 {
11377                    if let Some(top) = self.redirect_scope_stack.last_mut() {
11378                        top.push((2, saved2));
11379                    } else {
11380                        unsafe { libc::close(saved2) };
11381                    }
11382                }
11383            }
11384        }
11385        // c:Src/exec.c:3722-3724 + 2447-2480 — MULTIOS split when this
11386        // command's stdout IS the pipeline output. C registers the pipe
11387        // in mfds[1] (`addfd(forked, save, mfds, 1, output, 1, NULL)`)
11388        // BEFORE walking the explicit redirect list, so a write-side
11389        // redirect of fd 1 finds mfds[1] occupied and, with MULTIOS
11390        // set, "split[s] the stream": fd 1 becomes the write end of an
11391        // internal pipe whose reader tees every chunk to BOTH the
11392        // pipeline pipe and the new target. That is why
11393        // `{ echo a; echo b >&2; } 3>&1 1>&2 2>&3 3>&- | cat` sends
11394        // `a` to the pipe (via the tee) AND to stderr — plain dup2
11395        // replacement loses the pipe stream. The scope-depth gate
11396        // mirrors mfds being per-execcmd: only the redirect list
11397        // attached to the stage's own command joins the pipe; nested
11398        // commands inside the body (`{ echo a > f; } | cat`) get a
11399        // fresh "mfds" and replace as usual.
11400        if fd == 1
11401            && self
11402                .pipe_output_scope
11403                .is_some_and(|d| d + 1 == self.redirect_scope_stack.len())
11404            && crate::ported::options::opt_state_get("multios").unwrap_or(true)
11405        {
11406            // Resolve the new write target exactly as the plain arms
11407            // below would, but as a raw fd for the tee.
11408            let new_target_fd: i32 = match op_byte {
11409                r::DUP_WRITE => {
11410                    // Numeric `>&N` only; `-` (close) and `p` (coproc)
11411                    // fall through to the plain arms.
11412                    target
11413                        .trim_start_matches('&')
11414                        .parse::<i32>()
11415                        .map(|src| unsafe { libc::dup(src) })
11416                        .unwrap_or(-1)
11417                }
11418                r::WRITE | r::CLOBBER => fs::File::create(target)
11419                    .map(|f| f.into_raw_fd())
11420                    .unwrap_or(-1),
11421                r::APPEND => fs::OpenOptions::new()
11422                    .create(true)
11423                    .append(true)
11424                    .open(target)
11425                    .map(|f| f.into_raw_fd())
11426                    .unwrap_or(-1),
11427                _ => -1,
11428            };
11429            if new_target_fd >= 0 {
11430                let pipe_dup = unsafe { libc::dup(1) };
11431                match (pipe_dup >= 0).then(os_pipe::pipe) {
11432                    Some(Ok((read_end, write_end))) => {
11433                        // Splitter: same read-loop shape as
11434                        // BUILTIN_MULTIOS_REDIRECT, with one ordering
11435                        // refinement. C's tee is a forked process
11436                        // (closemn → teeproc) whose wakeup latency lets
11437                        // the stage's DIRECT pipe writes land first —
11438                        // observed zsh output for `{ echo a; echo b >&2; }
11439                        // 3>&1 1>&2 2>&3 3>&- | cat` is `b` then `a`,
11440                        // 15/15 runs. A Rust thread wakes faster than
11441                        // the debug-build VM dispatches the next echo,
11442                        // inverting the order. Emulate the C timing
11443                        // observably: stream to the NEW target (file /
11444                        // stderr dup) immediately, but defer the
11445                        // pipe-bound copy until EOF (or a 64KB cap so a
11446                        // long-running stream still flows instead of
11447                        // growing memory unboundedly).
11448                        let write_now = |tfd: i32, data: &[u8]| {
11449                            let mut off = 0;
11450                            while off < data.len() {
11451                                let w = unsafe {
11452                                    libc::write(
11453                                        tfd,
11454                                        data[off..].as_ptr() as *const libc::c_void,
11455                                        data.len() - off,
11456                                    )
11457                                };
11458                                if w <= 0 {
11459                                    break;
11460                                }
11461                                off += w as usize;
11462                            }
11463                        };
11464                        let handle = std::thread::spawn(move || {
11465                            let mut rd = read_end;
11466                            let mut buf = [0u8; 8192];
11467                            let mut pipe_pending: Vec<u8> = Vec::new();
11468                            loop {
11469                                match std::io::Read::read(&mut rd, &mut buf) {
11470                                    Ok(0) | Err(_) => break,
11471                                    Ok(n) => {
11472                                        write_now(new_target_fd, &buf[..n]);
11473                                        pipe_pending.extend_from_slice(&buf[..n]);
11474                                        if pipe_pending.len() >= 65536 {
11475                                            write_now(pipe_dup, &pipe_pending);
11476                                            pipe_pending.clear();
11477                                        }
11478                                    }
11479                                }
11480                            }
11481                            write_now(pipe_dup, &pipe_pending);
11482                            unsafe {
11483                                libc::close(pipe_dup);
11484                                libc::close(new_target_fd);
11485                            }
11486                        });
11487                        let write_raw = AsRawFd::as_raw_fd(&write_end);
11488                        unsafe { libc::dup2(write_raw, 1) };
11489                        drop(write_end);
11490                        // Scope-end closes this dup (the last writer once
11491                        // the saved fd 1 is restored) → EOF → join.
11492                        let close_on_end = unsafe { libc::dup(1) };
11493                        if let Some(top) = self.multios_scope_stack.last_mut() {
11494                            top.push((close_on_end, handle));
11495                        } else {
11496                            unsafe { libc::close(close_on_end) };
11497                            let _ = handle.join();
11498                        }
11499                        return;
11500                    }
11501                    _ => unsafe {
11502                        // pipe()/dup failure — fall through to plain replace.
11503                        if pipe_dup >= 0 {
11504                            libc::close(pipe_dup);
11505                        }
11506                        libc::close(new_target_fd);
11507                    },
11508                }
11509            }
11510        }
11511        match op_byte {
11512            r::WRITE => {
11513                // Honor `setopt noclobber`: refuse to overwrite an
11514                // existing regular file unless `>!` / `>|` (CLOBBER).
11515                // zsh internally stores the inverted-name `clobber`
11516                // (default ON); `setopt noclobber` writes
11517                // `clobber=false`. Honor both keys.
11518                //
11519                // c:Src/exec.c:2241-2245 clobber_open recover path:
11520                // after O_EXCL fails, reopen and `if (!S_ISREG(...))
11521                // return fd;` — non-regular targets (char/block-
11522                // special, FIFO, socket) bypass the noclobber check.
11523                // Bug #30 in docs/BUGS.md: this bridge-side check did
11524                // a bare `Path::exists()` and treated `/dev/null` as
11525                // a protected file, breaking `setopt no_clobber; echo
11526                // hi > /dev/null` and every `2> /dev/null` idiom.
11527                // Add a regular-file stat gate that matches the C
11528                // semantic. The canonical clobber_open at
11529                // src/ported/exec.rs:2123 already handles this; the
11530                // bridge duplicates a stripped-down version here and
11531                // must mirror the same check.
11532                let noclobber = opt_state_get("noclobber").unwrap_or(false)
11533                    || !opt_state_get("clobber").unwrap_or(true);
11534                let target_meta = std::fs::metadata(target).ok();
11535                let target_is_regular_file = target_meta
11536                    .as_ref()
11537                    .map(|m| m.file_type().is_file())
11538                    .unwrap_or(false);
11539                // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY permits
11540                // re-using an EMPTY regular file under noclobber: `setopt
11541                // noclobber clobberempty; : >f; echo hi >f` overwrites f.
11542                // The inline bridge check ignored this and errored.
11543                let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
11544                    && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
11545                if noclobber && target_is_regular_file && !clobber_empty_ok {
11546                    eprintln!(
11547                        "{}:{}: file exists: {}",
11548                        shname(),
11549                        crate::ported::lex::lineno(),
11550                        target
11551                    );
11552                    self.set_last_status(1);
11553                    // c:Src/exec.c — set redirect_failed so the scope-end
11554                    // hook (`with_redirects_end` in this file) forces
11555                    // $? to 1 regardless of the still-running command's
11556                    // own exit. Without this the next command (e.g.
11557                    // `echo x` writing to /dev/null below) succeeds
11558                    // and overwrites the redirect-failure status,
11559                    // making noclobber unobservable from $?.
11560                    self.redirect_failed = true;
11561                    // Sink the upcoming command's stdout to /dev/null
11562                    // so we don't leak its output to the terminal.
11563                    // zsh skips the command entirely; we approximate by
11564                    // discarding the output (the redirect target was
11565                    // the user's chosen sink, but with noclobber the
11566                    // file is protected — discarding matches the
11567                    // user's intent better than printing to terminal).
11568                    if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
11569                        let new_fd = file.into_raw_fd();
11570                        unsafe {
11571                            libc::dup2(new_fd, fd);
11572                            libc::close(new_fd);
11573                        }
11574                    }
11575                    return;
11576                }
11577                if !Self::redir_open_or_fail(
11578                    fd,
11579                    fs::File::create(target),
11580                    target,
11581                    &mut self.redirect_failed,
11582                ) {
11583                    self.set_last_status(1);
11584                }
11585            }
11586            r::CLOBBER => {
11587                if !Self::redir_open_or_fail(
11588                    fd,
11589                    fs::File::create(target),
11590                    target,
11591                    &mut self.redirect_failed,
11592                ) {
11593                    self.set_last_status(1);
11594                }
11595            }
11596            r::APPEND => {
11597                // c:Src/exec.c:3924-3927 — `>>` honors NO_CLOBBER+!APPENDCREATE
11598                // by opening O_APPEND|O_WRONLY WITHOUT O_CREAT, so missing
11599                // files yield ENOENT. zsh source:
11600                //   if (!isset(CLOBBER) && !isset(APPENDCREATE) &&
11601                //       !IS_CLOBBER_REDIR(fn->type))
11602                //       mode = O_WRONLY|O_APPEND|O_NOCTTY;
11603                //   else mode = O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY;
11604                // (IS_CLOBBER_REDIR — `>>!`/`>>|` — is currently flattened
11605                // to plain APPEND at compile time in
11606                // src/extensions/compile_zsh.rs:1654-1655, so the bang/pipe
11607                // forms can't be distinguished here yet.)
11608                let noclobber = opt_state_get("noclobber").unwrap_or(false)
11609                    || !opt_state_get("clobber").unwrap_or(true);
11610                let append_create = opt_state_get("appendcreate").unwrap_or(false)
11611                    || opt_state_get("append_create").unwrap_or(false);
11612                let open_result = if noclobber && !append_create {
11613                    fs::OpenOptions::new().append(true).open(target) // no create
11614                } else {
11615                    fs::OpenOptions::new()
11616                        .create(true)
11617                        .append(true)
11618                        .open(target)
11619                };
11620                if !Self::redir_open_or_fail(fd, open_result, target, &mut self.redirect_failed) {
11621                    self.set_last_status(1);
11622                }
11623            }
11624            r::READ => {
11625                if !Self::redir_open_or_fail(
11626                    fd,
11627                    fs::File::open(target),
11628                    target,
11629                    &mut self.redirect_failed,
11630                ) {
11631                    self.set_last_status(1);
11632                }
11633            }
11634            r::READ_WRITE => {
11635                if let Ok(file) = fs::OpenOptions::new()
11636                    .create(true)
11637                    .truncate(false) // <> opens existing-or-new without truncating
11638                    .read(true)
11639                    .write(true)
11640                    .open(target)
11641                {
11642                    let new_fd = file.into_raw_fd();
11643                    unsafe {
11644                        // See redir_open_or_fail: when the opened fd IS the
11645                        // destination (target fd was closed), keep it and clear
11646                        // O_CLOEXEC; else dup2 + close.
11647                        if new_fd != fd {
11648                            libc::dup2(new_fd, fd);
11649                            libc::close(new_fd);
11650                        } else {
11651                            libc::fcntl(fd, libc::F_SETFD, 0);
11652                        }
11653                    }
11654                }
11655            }
11656            r::DUP_READ | r::DUP_WRITE => {
11657                // Target is a numeric fd reference like `&3`. The parser
11658                // strips the `&` prefix before we get here in some paths,
11659                // others retain it — accept both. Also support `-` for
11660                // close-fd (`<&-` / `>&-`) per POSIX. The src_fd
11661                // validity check ran above before the save-and-dup.
11662                let n = target.trim_start_matches('&');
11663                if n == "-" {
11664                    unsafe { libc::close(fd) };
11665                } else if n == "p" {
11666                    // c:Src/exec.c — `<&p` / `>&p` route through the
11667                    // coprocin / coprocout globals. zsh's `coproc CMD`
11668                    // launch publishes those fds; the canonical
11669                    // bin_print / bin_read `-p` arms already consume
11670                    // them. The DUP redirect form is the third
11671                    // consumer: it must dup the coproc fd onto the
11672                    // target slot so the next command's stdin/stdout
11673                    // is wired to the running coprocess. Bug #388.
11674                    let coproc_fd = if op_byte == r::DUP_READ {
11675                        crate::ported::modules::clone::coprocin
11676                            .load(std::sync::atomic::Ordering::Relaxed)
11677                    } else {
11678                        crate::ported::modules::clone::coprocout
11679                            .load(std::sync::atomic::Ordering::Relaxed)
11680                    };
11681                    if coproc_fd < 0 {
11682                        eprintln!("{}:1: no coprocess", shname());
11683                        self.set_last_status(1);
11684                        self.redirect_failed = true;
11685                    } else {
11686                        unsafe {
11687                            libc::dup2(coproc_fd, fd);
11688                        }
11689                    }
11690                } else if let Ok(src_fd) = n.parse::<i32>() {
11691                    unsafe { libc::dup2(src_fd, fd) };
11692                } else if op_byte == r::DUP_WRITE {
11693                    // c:Src/glob.c:2184-2187 xpandredir — a MERGEOUT
11694                    // word that expands to a non-number becomes
11695                    // REDIR_ERRWRITE: `cmd >& word` opens `word` and
11696                    // routes BOTH fd 1 and fd 2 there. Reached only
11697                    // for dynamic words (`>&$var`); static filenames
11698                    // were converted at compile time.
11699                    if let Ok(file) = fs::File::create(target) {
11700                        let new_fd = file.into_raw_fd();
11701                        unsafe {
11702                            libc::dup2(new_fd, 1);
11703                            libc::dup2(new_fd, 2);
11704                            libc::close(new_fd);
11705                        }
11706                    }
11707                } else {
11708                    // c:Src/glob.c:2185 — MERGEIN non-number:
11709                    // `zerr("file number expected")`.
11710                    crate::ported::utils::zerr("file number expected");
11711                    self.set_last_status(1);
11712                    self.redirect_failed = true;
11713                }
11714            }
11715            r::WRITE_BOTH => {
11716                if let Ok(file) = fs::File::create(target) {
11717                    let new_fd = file.into_raw_fd();
11718                    unsafe {
11719                        libc::dup2(new_fd, 1);
11720                        libc::dup2(new_fd, 2);
11721                        libc::close(new_fd);
11722                    }
11723                }
11724            }
11725            r::APPEND_BOTH => {
11726                if let Ok(file) = fs::OpenOptions::new()
11727                    .create(true)
11728                    .append(true)
11729                    .open(target)
11730                {
11731                    let new_fd = file.into_raw_fd();
11732                    unsafe {
11733                        libc::dup2(new_fd, 1);
11734                        libc::dup2(new_fd, 2);
11735                        libc::close(new_fd);
11736                    }
11737                }
11738            }
11739            _ => {}
11740        }
11741    }
11742
11743    /// Push a fresh redirect scope. `_count` is informational — the actual
11744    /// saved fds are appended by host_apply_redirect into the top scope.
11745    pub fn host_redirect_scope_begin(&mut self, _count: u8) {
11746        // c:Src/exec.c:3722-3724 — the pipeline child set
11747        // `pipe_output_pending` right after dup2'ing its stdout onto
11748        // the pipe; the FIRST redirect scope opened in that child is
11749        // the stage command's own redirect list (same execcmd as the
11750        // pipe's addfd into mfds[1]). Capture the depth so only THAT
11751        // list's fd-1 write redirects MULTIOS-join the pipe.
11752        if self.pipe_output_pending {
11753            self.pipe_output_pending = false;
11754            self.pipe_output_scope = Some(self.redirect_scope_stack.len());
11755        }
11756        self.redirect_scope_stack.push(Vec::new());
11757        self.multios_scope_stack.push(Vec::new());
11758    }
11759
11760    /// Pop the top redirect scope, restoring saved fds.
11761    pub fn host_redirect_scope_end(&mut self) {
11762        // c:Src/exec.c — restore saved fds FIRST so the multios
11763        // pipe-write end is released from `fd`, then close our
11764        // tracked close_on_end (the last surviving writer dup), then
11765        // join the splitter thread. If we closed close_on_end before
11766        // restoring saved, `fd` would still hold a pipe writer and
11767        // the thread would block forever waiting for EOF.
11768        if let Some(saved) = self.redirect_scope_stack.pop() {
11769            for (fd, saved_fd) in saved.into_iter().rev() {
11770                unsafe {
11771                    libc::dup2(saved_fd, fd);
11772                    libc::close(saved_fd);
11773                }
11774            }
11775        }
11776        if let Some(scope) = self.multios_scope_stack.pop() {
11777            // Close ALL tracked writer dups BEFORE joining any
11778            // thread. When one splitter holds a dup of another's
11779            // pipe write-end (two multios in one scope where a later
11780            // one duped fd 1 while an earlier splitter owned it),
11781            // joining in push order deadlocks: splitter A's EOF
11782            // waits on splitter B's writer dup, which only closes
11783            // after B's thread exits — blocked behind A's join.
11784            let mut handles = Vec::with_capacity(scope.len());
11785            for (write_fd, handle) in scope {
11786                if write_fd >= 0 {
11787                    unsafe {
11788                        libc::close(write_fd);
11789                    }
11790                }
11791                handles.push(handle);
11792            }
11793            for handle in handles {
11794                let _ = handle.join();
11795            }
11796        }
11797        // The scope that captured the pipeline-output marker is gone;
11798        // deeper-nested future scopes must not re-match its depth.
11799        if self.pipe_output_scope == Some(self.redirect_scope_stack.len()) {
11800            self.pipe_output_scope = None;
11801        }
11802    }
11803
11804    /// Set up `content` as stdin (fd 0) for the next command.
11805    /// Used by `Op::HereDoc(idx)` and `Op::HereString`.
11806    ///
11807    /// c:Src/exec.c:4655 getherestr — C writes the body to a TEMP
11808    /// FILE (gettempfile → write_loop → close → reopen O_RDONLY →
11809    /// unlink), NOT a pipe. The previous pipe+writer-thread shape
11810    /// SIGPIPE'd the whole shell when the consumer never read the
11811    /// body (`: <<< ${(F)x/y}` — D04parameter chunk 211, flaky
11812    /// rc=141): the redirect-scope teardown closed the read end
11813    /// while the detached thread was still in write_all, and the
11814    /// shell's SIGPIPE disposition is SIG_DFL. A temp file has no
11815    /// reader/writer coupling — matching C exactly, including
11816    /// lseek-ability of fd 0, which pipes don't give.
11817    pub fn host_set_pending_stdin(&mut self, content: String) {
11818        // c:4673 — `gettempfile(NULL, 1, &s)`.
11819        let mut tmp = std::env::temp_dir();
11820        tmp.push(format!(
11821            "zshrs-herestr-{}-{:x}",
11822            std::process::id(),
11823            std::time::SystemTime::now()
11824                .duration_since(std::time::UNIX_EPOCH)
11825                .map(|d| d.as_nanos())
11826                .unwrap_or(0)
11827        ));
11828        // c:4675 — `write_loop(fd, t, len); close(fd);`
11829        if std::fs::write(&tmp, content.as_bytes()).is_err() {
11830            return; // c:4674 — tempfile failure → no redirect
11831        }
11832        // c:Src/utils.c gettempfile → mkstemp creates the temp file mode
11833        // 0600 IGNORING the umask, so the O_RDONLY reopen below always
11834        // succeeds. `std::fs::write` honors the umask, so under `umask
11835        // 0777` the file landed mode 0000 and the reopen failed with
11836        // EACCES — `cat <<<x` then read empty stdin. Force 0600 to match
11837        // mkstemp's umask-independent permissions.
11838        let _ = std::fs::set_permissions(
11839            &tmp,
11840            <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
11841        );
11842        // c:4677 — `fd = open(s, O_RDONLY | O_NOCTTY);`
11843        let file = match std::fs::File::open(&tmp) {
11844            Ok(f) => f,
11845            Err(_) => {
11846                let _ = std::fs::remove_file(&tmp);
11847                return;
11848            }
11849        };
11850        // c:4678 — `unlink(s);` — fd stays valid, name disappears.
11851        let _ = std::fs::remove_file(&tmp);
11852        let saved = unsafe { libc::dup(libc::STDIN_FILENO) };
11853        if saved >= 0 {
11854            if let Some(top) = self.redirect_scope_stack.last_mut() {
11855                top.push((libc::STDIN_FILENO, saved));
11856            } else {
11857                unsafe { libc::close(saved) };
11858            }
11859        }
11860        let read_fd = AsRawFd::as_raw_fd(&file);
11861        unsafe { libc::dup2(read_fd, libc::STDIN_FILENO) };
11862        drop(file);
11863    }
11864
11865    /// Spawn an external command using zshrs's full dispatch logic
11866    /// (intercepts, command_hash, redirect handling). Used by
11867    /// `ZshrsHost::exec` so the bytecode VM's `Op::Exec` and
11868    /// `Op::CallFunction` external fallback get the same semantics as
11869    /// the tree-walker's `execute_external` rather than a plain
11870    /// `Command::new` shortcut. Returns the exit status.
11871    pub fn host_exec_external(&mut self, args: &[String]) -> i32 {
11872        // If a glob expansion in this command's argv triggered the
11873        // nomatch error path, suppress the actual exec and return
11874        // status 1 — mirrors zsh's command-aborted-on-glob-error
11875        // behaviour. The flag is reset BEFORE returning so the next
11876        // command starts clean.
11877        //
11878        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH sets
11879        // ERRFLAG_ERROR but C's execlist clears the bit per-sublist
11880        // so subsequent commands run. Symmetric with the builtin
11881        // dispatcher's clear at fusevm_bridge.rs:299 — clear it here
11882        // too at the external-command post-command-boundary.
11883        consume_tilde_globsubst_carrier();
11884        if self.current_command_glob_failed.get() {
11885            self.current_command_glob_failed.set(false);
11886            crate::ported::utils::errflag.fetch_and(
11887                !crate::ported::zsh_h::ERRFLAG_ERROR,
11888                std::sync::atomic::Ordering::Relaxed,
11889            );
11890            self.set_last_status(1);
11891            return 1;
11892        }
11893        // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the
11894        // NOMATCH gate above, same external-path semantics (skip
11895        // command, `no match`, clear ERRFLAG so the next sublist
11896        // runs).
11897        if consume_badcshglob() {
11898            crate::ported::utils::errflag.fetch_and(
11899                !crate::ported::zsh_h::ERRFLAG_ERROR,
11900                std::sync::atomic::Ordering::Relaxed,
11901            );
11902            self.set_last_status(1);
11903            return 1;
11904        }
11905        let Some((cmd, rest)) = args.split_first() else {
11906            return 0;
11907        };
11908        // Empty command name (e.g. result of an empty `$(false)`
11909        // command-sub being the only word) — zsh: no command runs,
11910        // exit status preserved from prior step. Was hitting the
11911        // "command not found: " path with empty name.
11912        if cmd.is_empty() && rest.is_empty() {
11913            return self.last_status();
11914        }
11915        let rest_vec: Vec<String> = rest.to_vec();
11916        // Update `$_` with the just-arriving argv so the next command
11917        // reads `_=<last_arg>`. Mirrors C zsh's writeback in
11918        // `execcmd_exec` (Src/exec.c). Per `args.last()` semantics,
11919        // when invoked as `cmd a b c`, `$_` becomes "c" — for a bare
11920        // command with no args, `$_` becomes the command name itself.
11921        crate::ported::params::set_zunderscore(args);
11922
11923        // Builtins not in fusevm's name→id table fall through to
11924        // host.exec. Catch them here before the OS-level exec attempts
11925        // to spawn a non-existent binary.
11926        match cmd.as_str() {
11927            "sched" => return dispatch_builtin("sched", rest_vec.clone()),
11928            "echotc" => return dispatch_builtin("echotc", rest_vec.clone()),
11929            "echoti" => return dispatch_builtin("echoti", rest_vec.clone()),
11930            "zpty" => return dispatch_builtin("zpty", rest_vec.clone()),
11931            "ztcp" => return dispatch_builtin("ztcp", rest_vec.clone()),
11932            "zsocket" => {
11933                // c:Src/Modules/socket.c:276 BUILTIN spec — BUILTINS["zsocket"]
11934                // optstr "ad:ltv" parsed by execbuiltin.
11935                return dispatch_builtin("zsocket", rest_vec.clone());
11936            }
11937            "private" => {
11938                // c:Src/Modules/param_private.c:217 — bin_private via
11939                // BUILTINS["private"]. The autoload require_module
11940                // (exec.c:2700-2717) fires inside
11941                // dispatch_builtin_raw, the chokepoint for all routes.
11942                return dispatch_builtin("private", rest_vec.clone());
11943            }
11944            "zformat" => return dispatch_builtin("zformat", rest_vec.clone()),
11945            "zregexparse" => return dispatch_builtin("zregexparse", rest_vec.clone()),
11946            // `unalias`/`unhash`/`unfunction` share `bin_unhash` but
11947            // each carries its own funcid (BIN_UNALIAS / BIN_UNHASH /
11948            // BIN_UNFUNCTION) — dispatch_builtin handles the BUILTINS
11949            // lookup + funcid propagation via execbuiltin.
11950            "unalias" | "unhash" | "unfunction" => {
11951                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
11952            }
11953            // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
11954            // functions — implemented natively in Rust so `autoload -Uz zmv`
11955            // works without shipping the function source (and without the
11956            // fpath source hanging the parser). The `function_exists` guard
11957            // keeps them command-not-found until autoloaded, exactly like zsh;
11958            // an un-guarded arm ran them for bare `zmv`, diverging from
11959            // `zsh -f; zmv` → "command not found: zmv".
11960            "zmv" if self.function_exists("zmv") => {
11961                return crate::extensions::ext_builtins::zmv(&rest_vec, "mv")
11962            }
11963            "zcp" if self.function_exists("zcp") => {
11964                return crate::extensions::ext_builtins::zmv(&rest_vec, "cp")
11965            }
11966            "zln" if self.function_exists("zln") => {
11967                return crate::extensions::ext_builtins::zmv(&rest_vec, "ln")
11968            }
11969            "zcalc" if self.function_exists("zcalc") => {
11970                return crate::extensions::ext_builtins::zcalc(&rest_vec)
11971            }
11972            "zselect" => {
11973                // Route through canonical dispatch_builtin which goes
11974                // via execbuiltin → BUILTINS["zselect"] (zselect.c:272).
11975                return dispatch_builtin("zselect", rest_vec.clone());
11976            }
11977            "cap" => return dispatch_builtin("cap", rest_vec.clone()),
11978            "getcap" => return dispatch_builtin("getcap", rest_vec.clone()),
11979            "setcap" => return dispatch_builtin("setcap", rest_vec.clone()),
11980            "yes" => return self.builtin_yes(&rest_vec),
11981            "nl" => return self.builtin_nl(&rest_vec),
11982            "env" => return self.builtin_env(&rest_vec),
11983            "printenv" => return self.builtin_printenv(&rest_vec),
11984            "tty" => return self.builtin_tty(&rest_vec),
11985            // c:Src/Modules/files.c:806 — BUILTINS["chgrp"] with
11986            // BIN_CHGRP funcid + "hRs" optstr.
11987            "chgrp" => return dispatch_builtin("chgrp", rest_vec.clone()),
11988            "nproc" => return self.builtin_nproc(&rest_vec),
11989            "expr" => return self.builtin_expr(&rest_vec),
11990            "sha256sum" => return self.builtin_sha256sum(&rest_vec),
11991            "base64" => return self.builtin_base64(&rest_vec),
11992            "tac" => return self.builtin_tac(&rest_vec),
11993            "expand" => return self.builtin_expand(&rest_vec),
11994            "unexpand" => return self.builtin_unexpand(&rest_vec),
11995            "paste" => return self.builtin_paste(&rest_vec),
11996            "fold" => return self.builtin_fold(&rest_vec),
11997            "shuf" => return self.builtin_shuf(&rest_vec),
11998            "comm" => return self.builtin_comm(&rest_vec),
11999            "cksum" => return self.builtin_cksum(&rest_vec),
12000            "factor" => return self.builtin_factor(&rest_vec),
12001            "tsort" => return self.builtin_tsort(&rest_vec),
12002            "sum" => return self.builtin_sum(&rest_vec),
12003            "mkfifo" => return self.builtin_mkfifo(&rest_vec),
12004            "link" => return self.builtin_link(&rest_vec),
12005            "unlink" => return self.builtin_unlink(&rest_vec),
12006            "dircolors" => return self.builtin_dircolors(&rest_vec),
12007            "groups" => return self.builtin_groups(&rest_vec),
12008            "arch" => return self.builtin_arch(&rest_vec),
12009            "nice" => return self.builtin_nice(&rest_vec),
12010            "logname" => return self.builtin_logname(&rest_vec),
12011            "tput" => return self.builtin_tput(&rest_vec),
12012            "users" => return self.builtin_users(&rest_vec),
12013            // "sync" => return self.bin_sync(&rest_vec),
12014            "zbuild" => return self.builtin_zbuild(&rest_vec),
12015            // `zf_*` aliases from `zsh/files` (Src/Modules/files.c
12016            // BUILTIN table at line 816-824). The C source binds
12017            // both unprefixed (`chmod`) and prefixed (`zf_chmod`)
12018            // names to the SAME `bin_chmod` etc. handlers — the
12019            // prefixed forms exist so a script can portably reach
12020            // the builtin even when a function or alias has shadowed
12021            // the bare name. Each arm routes through the canonical
12022            // zf_* aliases route through canonical BUILTINS entries
12023            // (files.c:816-824) — execbuiltin parses each fn's optstr
12024            // automatically.
12025            "mkdir" | "zf_mkdir" | "zf_rm" | "zf_rmdir" | "zf_chmod" | "zf_chown" | "zf_chgrp"
12026            | "zf_ln" | "zf_mv" | "zf_sync"
12027                // `--zsh` parity gate: zsh -fc has zsh/files UNLOADED
12028                // — bare `mkdir` is /bin/mkdir (so `command mkdir -p`
12029                // honors the system flag set; zconvey.plugin.zsh:44
12030                // got "File exists" from the in-process bin_mkdir
12031                // that this arm intercepted) and `zf_*` names are
12032                // command-not-found 127 until `zmodload zsh/files`.
12033                // Fall through to the external/exec path in --zsh
12034                // mode; default zshrs mode keeps the anti-fork
12035                // intercept.
12036                if !crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) =>
12037            {
12038                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
12039            }
12040            // `zstat` — port of zsh/stat module (Src/Modules/stat.c
12041            // BUILTIN("zstat", …)). Returns file metadata as
12042            // `field value` pairs / an assoc / a plus-separated
12043            // list depending on flags. zsh ALSO registers `stat`
12044            // bound to the same handler, but that name conflicts
12045            // with the system `stat(1)` binary (every script that
12046            // calls `stat -f '%Lp' …` would break). zsh resolves
12047            // this through opt-in `zmodload`; zshrs's modules are
12048            // statically linked so we keep `stat` routing to the
12049            // external command and only intercept the unambiguous
12050            // `zstat` name.
12051            "zstat" => {
12052                // Canonical bin_stat per stat.c:638 via BUILTINS["zstat"].
12053                return dispatch_builtin("zstat", rest_vec.clone());
12054            }
12055            _ => {}
12056        }
12057
12058        // AOP intercepts: when an `intercept :before/:around/:after foo` block
12059        // is registered, dynamic-command-name dispatch must consult it before
12060        // spawning. Without this, `cmd=ls; $cmd` bypasses every intercept that
12061        // a literal `ls` would trigger. The full_cmd string mirrors what the
12062        // tree-walker era passed (cmd + args joined by space) so existing
12063        // pattern matchers continue to work.
12064        if !self.intercepts.is_empty() {
12065            let full_cmd = if rest_vec.is_empty() {
12066                cmd.clone()
12067            } else {
12068                format!("{} {}", cmd, rest_vec.join(" "))
12069            };
12070            if let Some(intercept_result) = self.run_intercepts(cmd, &full_cmd, &rest_vec) {
12071                return intercept_result.unwrap_or(127);
12072            }
12073        }
12074
12075        // User-defined function lookup before OS-level exec. zsh's
12076        // dynamic-command-name dispatch (`cmd=hook1; $cmd`) checks
12077        // the function table FIRST — without this, `$f` for a
12078        // function-name `f` was always falling through to
12079        // `execute_external` and erroring "command not found".
12080        // Plugin code uses this pattern constantly:
12081        //   for f in "${precmd_functions[@]}"; do "$f"; done
12082        if self.function_exists(cmd) {
12083            if let Some(status) = self.dispatch_function_call(cmd, &rest_vec) {
12084                return status;
12085            }
12086        }
12087
12088        self.execute_external(cmd, &rest_vec, &[]).unwrap_or(127)
12089    }
12090}