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    /// Tuple: `(retflag, breaks, contflag, exit_pending, try_errflag,
113    /// try_interrupt)`. The last two are c:Src/loop.c:762-763's
114    /// `save_try_errflag` / `save_try_interrupt` — the ENCLOSING try
115    /// block's values, restored at c:778-779 so nested
116    /// `{…} always {…}` constructs don't leak `$TRY_BLOCK_ERROR`
117    /// outward.
118    static TRY_ESCAPE_SAVE: RefCell<Vec<(i32, i32, i32, i32, i64, i64)>> =
119        const { RefCell::new(Vec::new()) };
120    /// Re-entry guard for BUILTIN_DEBUG_TRAP. While the DEBUG trap
121    /// body is running, the per-statement DEBUG_TRAP dispatch in the
122    /// trap body must NOT re-fire (otherwise infinite recursion +
123    /// stack overflow). zsh's in_trap counter at Src/signals.c
124    /// serves the same purpose.
125    static DEBUG_TRAP_REENTRY: Cell<bool> = const { Cell::new(false) };
126    /// Stack of (saved_stdout, saved_stderr) tuples pushed by
127    /// `cmd_subst` around its nested-VM run. RUST-ONLY: zsh forks
128    /// each cmdsub so trap output during the cmdsub naturally
129    /// lands on the PARENT's stdout. zshrs's in-process cmdsub
130    /// dups fd 1 → pipe, so a trap firing during cmdsub would
131    /// emit into the captured value. Traps consult this stack
132    /// to route their body output to the topmost saved_stdout
133    /// instead of the cmdsub's fd 1. Bug #56 in docs/BUGS.md.
134    pub static CMDSUBST_OUTER_FDS: RefCell<Vec<(i32, i32)>> =
135        const { RefCell::new(Vec::new()) };
136    /// c:Src/exec.c:5025 getproc (PATH_DEV_FD branch) — the parent
137    /// keeps the `>(cmd)` pipe WRITE end open under `/dev/fd/N`,
138    /// parks it in the job's filelist (`fdtable[fd] =
139    /// FDT_PROC_SUBST; addfilelist(NULL, fd)`), and deletefilelist
140    /// closes it when the consuming job finishes — that close is
141    /// what lets the `>(cmd)` child's reader see EOF. zshrs runs
142    /// commands in-process, so the equivalent is: record
143    /// `(scope_depth, fd)` here and drain after the consuming
144    /// command (external exec or builtin dispatch) completes.
145    static PSUB_PENDING_FDS: RefCell<Vec<(usize, i32)>> = const { RefCell::new(Vec::new()) };
146    /// `(scope_depth, path)` for `=(cmd)` temp files, unlinked at the same
147    /// job-end boundary as the pending fds (c:Src/jobs.c deletefilelist).
148    static PSUB_PENDING_FILES: RefCell<Vec<(usize, String)>> = const { RefCell::new(Vec::new()) };
149    /// Scope depth for PSUB_PENDING_FDS tagging. Incremented around
150    /// nested execution contexts (cmd-subst bodies, shell-function
151    /// bodies) so a command running INSIDE the nested context only
152    /// drains its own `>(cmd)` fds, never the enclosing command's
153    /// (e.g. `tee >(wc) $(print x)` — print must not close tee's
154    /// fd). Mirrors C's per-job filelist ownership.
155    static PSUB_SCOPE_DEPTH: Cell<usize> = const { Cell::new(0) };
156    /// Forked `<(cmd)`/`>(cmd)` child pids awaiting reap. Drained
157    /// non-blockingly (WNOHANG) by note_psub_child so proc-sub children
158    /// don't accumulate as zombies across a shell session.
159    static PSUB_CHILDREN: RefCell<Vec<i32>> = const { RefCell::new(Vec::new()) };
160}
161
162/// Record a proc-sub child pid and best-effort reap any already-exited
163/// proc-sub children (WNOHANG). Non-blocking: still-running children
164/// stay parked and get reaped on a later call.
165pub(crate) fn note_psub_child(pid: i32) {
166    if pid <= 0 {
167        return;
168    }
169    PSUB_CHILDREN.with(|v| {
170        let mut v = v.borrow_mut();
171        v.push(pid);
172        v.retain(|&p| {
173            let mut status = 0;
174            // WNOHANG: reap if exited, else keep parked.
175            let r = unsafe { libc::waitpid(p, &mut status, libc::WNOHANG) };
176            // r == p → reaped; r == 0 → still running (keep); r < 0 →
177            // already gone/not ours (drop).
178            r == 0
179        });
180    });
181}
182
183/// Port of `fputs(s, xtrerr)` / `fprintf(xtrerr, "%s", s)` (Src/exec.c):
184/// append `s` to the buffered xtrace line. Nothing reaches stderr until
185/// [`xtrerr_flush`] (the port of `fflush(xtrerr)`) writes the line whole.
186pub(crate) fn xtrerr_fputs(s: &str) {
187    XTRERR.with(|b| b.borrow_mut().push_str(s));
188}
189
190/// Port of `fflush(xtrerr)` (Src/exec.c:1373/2123/2596): write the
191/// buffered xtrace line to stderr in ONE `write` and clear the buffer, so
192/// the line lands on the shared fd atomically (no interleaving across
193/// concurrent pipeline stages).
194pub(crate) fn xtrerr_flush() {
195    XTRERR.with(|b| {
196        let mut buf = b.borrow_mut();
197        if !buf.is_empty() {
198            use std::io::Write;
199            let _ = std::io::stderr().write_all(buf.as_bytes());
200            buf.clear();
201        }
202    });
203}
204
205/// RAII guard bumping the psub scope depth — see PSUB_SCOPE_DEPTH.
206pub(crate) struct PsubScope;
207
208impl PsubScope {
209    pub(crate) fn enter() -> Self {
210        PSUB_SCOPE_DEPTH.with(|d| d.set(d.get() + 1));
211        PsubScope
212    }
213}
214
215impl Drop for PsubScope {
216    fn drop(&mut self) {
217        PSUB_SCOPE_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
218    }
219}
220
221/// RAII guard bumping `$ZSH_SUBSHELL` for the duration of an
222/// in-process command substitution.
223///
224/// c:Src/exec.c:1161 — entersubsh() does `zsh_subshell++;` and zsh's
225/// cmdsub FORKS, so the increment dies with the child and the parent's
226/// value is untouched. zshrs runs cmdsubs in-process on a nested VM,
227/// so the visible param must be bumped on entry and restored on exit.
228/// Writes paramtab u_val directly because ZSH_SUBSHELL is PM_READONLY
229/// (same bypass pattern as the subshell-builtin bump below); also
230/// mirrors into ported::exec::zsh_subshell so exec.c:4376-style
231/// `forked | zsh_subshell` reads agree.
232pub(crate) struct CmdSubstSubshellBump {
233    saved_val: i64,
234    saved_str: Option<String>,
235}
236
237impl CmdSubstSubshellBump {
238    pub(crate) fn enter() -> Self {
239        let mut saved_val = 0i64;
240        let mut saved_str = None;
241        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
242            if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
243                saved_val = pm.u_val;
244                saved_str = pm.u_str.clone();
245                pm.u_val = saved_val + 1;
246                pm.u_str = Some((saved_val + 1).to_string());
247                pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
248            }
249        }
250        crate::ported::exec::zsh_subshell.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
251        CmdSubstSubshellBump {
252            saved_val,
253            saved_str,
254        }
255    }
256}
257
258impl Drop for CmdSubstSubshellBump {
259    fn drop(&mut self) {
260        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
261            if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
262                pm.u_val = self.saved_val;
263                pm.u_str = self.saved_str.take();
264            }
265        }
266        crate::ported::exec::zsh_subshell.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
267    }
268}
269
270/// Port of deletefilelist() from Src/jobs.c (the `>(cmd)` fd arm):
271/// closes every pending proc-subst write end created at or inside
272/// the current scope depth, exactly when C deletes the consuming
273/// job's filelist (getproc parks the fd there via
274/// `addfilelist(NULL, fd)`, Src/exec.c:5025+).
275fn close_pending_psub_fds() {
276    let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
277    PSUB_PENDING_FDS.with(|v| {
278        v.borrow_mut().retain(|&(d, fd)| {
279            if d >= depth {
280                unsafe { libc::close(fd) };
281                false
282            } else {
283                true
284            }
285        });
286    });
287    // c:Src/jobs.c deletefilelist — `=(cmd)` temp files are unlinked at the
288    // same job-end boundary. `f==(print x); [[ -f $f ]]` is false after the
289    // command line ends.
290    PSUB_PENDING_FILES.with(|v| {
291        v.borrow_mut().retain(|(d, path)| {
292            if *d >= depth {
293                let _ = fs::remove_file(path);
294                false
295            } else {
296                true
297            }
298        });
299    });
300}
301
302/// RAII drain guard — instantiated at the top of the consuming-
303/// command paths (dispatch_builtin, ZshrsHost::exec) so the pending
304/// `>(cmd)` write ends close on every exit path once the command
305/// finished, exactly when C's job filelist would be deleted.
306struct PsubFdGuard;
307
308impl Drop for PsubFdGuard {
309    fn drop(&mut self) {
310        close_pending_psub_fds();
311    }
312}
313
314/// Peek the outermost cmdsub-saved (stdout, stderr) fds, if any.
315/// Returns None when no cmdsub is currently capturing. Used by the
316/// trap dispatcher in `src/ported/signals.rs::dotrap` to route trap
317/// body output to the parent's real stdout (matching zsh's forked
318/// cmdsub behaviour) instead of the cmdsub's pipe-bound fd 1.
319/// Bug #56 in docs/BUGS.md.
320pub fn cmdsubst_outer_stdout() -> Option<i32> {
321    CMDSUBST_OUTER_FDS.with(|s| s.borrow().last().map(|(o, _)| *o))
322}
323
324thread_local! {
325    /// The pipeline fds a stage still has to install onto 0/1, as
326    /// `(input, output)` with -1 meaning "leave this fd alone".
327    ///
328    /// c:Src/exec.c:3720-3724 — the pipe's read/write ends are the
329    /// FIRST entries of the command's multio table:
330    ///     /* Add pipeline input/output to mnodes */
331    ///     if (input)  addfd(forked, save, mfds, 0, input, 0, NULL);
332    ///     if (output) addfd(forked, save, mfds, 1, output, 1, NULL);
333    /// and that runs AFTER prefork (c:3304) + globlist (c:3702) have
334    /// expanded the command's argument words. So a `$(...)` inside a
335    /// stage's ARGS reads the shell's original fd 0, not the pipe:
336    /// `print -rl -- c a b | print -r -- "[$(cat)]"` prints `[]`.
337    /// (The stage's own fork at c:3000 happens before the expansion,
338    /// which is why `${x::=v}` in a non-last stage doesn't survive —
339    /// but the fds are still installed after it.)
340    ///
341    /// zshrs's [`BUILTIN_RUN_PIPELINE`] forks per stage, so it parks
342    /// the stage's fds here instead of dup2'ing them itself, and the
343    /// compiled stage chunk installs them via
344    /// [`BUILTIN_PIPE_FDS_INSTALL`] at the C-faithful point: after the
345    /// arg-word ops, before the redirect scope
346    /// (compile_zsh.rs::emit_stage_fds_install). A compound stage
347    /// (`{ … }`, `( … )`, a function) installs at chunk entry — its
348    /// body legitimately reads the pipe.
349    static PENDING_STAGE_FDS: std::cell::Cell<(i32, i32)> = const { std::cell::Cell::new((-1, -1)) };
350}
351
352/// Park the current stage's `(input, output)` pipe fds for the stage
353/// chunk's `BUILTIN_PIPE_FDS_INSTALL` to pick up. Returns the previous
354/// value so a nested pipeline (`print -- "$(a | b)" | c`) can restore
355/// the outer stage's still-uninstalled fds when it finishes.
356fn stage_fds_park(input: i32, output: i32) -> (i32, i32) {
357    PENDING_STAGE_FDS.with(|c| c.replace((input, output)))
358}
359
360/// Take (and clear) the parked stage fds.
361fn stage_fds_take() -> (i32, i32) {
362    PENDING_STAGE_FDS.with(|c| c.replace((-1, -1)))
363}
364
365// Thread-local pointer to the current ShellExecutor.
366// Set before VM execution, cleared after. Used by builtin handlers.
367thread_local! {
368    static CURRENT_EXECUTOR: RefCell<Option<*mut ShellExecutor>> = const { RefCell::new(None) };
369    /// The installed session executor, registered by
370    /// `exec::install_session_executor`. Lets [`with_session_context`]
371    /// establish a VM execution context for STARTUP work that runs
372    /// before the loop's first `execode` enters one (rc-file sourcing in
373    /// `run_init_scripts`, c:1914). Mirrors exec.rs's own
374    /// `SESSION_EXECUTOR`; kept here so the context helper lives next to
375    /// `ExecutorContext`/`CURRENT_EXECUTOR`.
376    static SESSION_EXECUTOR_PTR: std::cell::Cell<Option<*mut ShellExecutor>> = const { std::cell::Cell::new(None) };
377    /// GLOB_ASSIGN eligibility carrier. Set true by BUILTIN_MARK_GLOB_ELIGIBLE
378    /// (emitted by the compiler ONLY when a scalar-assignment RHS carries an
379    /// UNQUOTED glob token — Star/Quest/Inbrack), read+cleared by the next
380    /// BUILTIN_SET_VAR. Matches C zsh's `GLOB_ASSIGN` (Src/exec.c:2554): only
381    /// a literal unquoted glob pattern in the wordcode is globbed; values from
382    /// `$param` / `$(cmd)` / quoted strings are NOT (verified against zsh).
383    /// The runtime SET_VAR value arrives untokenized (the compiler DQ-wraps to
384    /// suppress compile-time globbing), so quoting can no longer be recovered
385    /// from the value bytes — this flag carries the compile-time decision.
386    static SET_VAR_GLOB_ELIGIBLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
387}
388
389/// Register the session executor pointer (called from
390/// `install_session_executor`). See [`with_session_context`].
391pub fn register_session_executor(exec: &mut ShellExecutor) {
392    SESSION_EXECUTOR_PTR.with(|c| c.set(Some(exec as *mut ShellExecutor)));
393}
394
395/// Run `f` with the registered session executor established as
396/// `CURRENT_EXECUTOR`, so code reaching the live executor via
397/// `try_with_executor` works even when no per-command `execode` context
398/// is active yet.
399///
400/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
401/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
402/// first `execode`. Without an active context those sourced bodies
403/// `try_with_executor` → `None` → no-op, so the shell silently ignored
404/// the user's dotfiles. The scope is entered once around the startup
405/// sourcing window and dropped before the loop begins — deliberately NOT
406/// Run `f` with the registered session executor established as
407/// `CURRENT_EXECUTOR`, so code reaching the live executor via
408/// `try_with_executor` works even when no per-command `execode` context
409/// is active yet.
410///
411/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
412/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
413/// first `execode`. Without an active context those sourced bodies
414/// `try_with_executor` → `None` → no-op, so the shell silently ignored
415/// the user's dotfiles. The scope is entered once around the startup
416/// sourcing window and dropped before the loop begins — deliberately NOT
417/// a global fallback inside `execute_script_zsh_pipeline`, which would
418/// re-enter the executor on nested command substitution and block on
419/// input.
420pub fn with_session_context<R>(f: impl FnOnce() -> R) -> R {
421    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
422    match ptr {
423        // SAFETY: set by install_session_executor to an executor that
424        // outlives the single-threaded interactive session.
425        Some(ptr) => {
426            let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
427            f()
428        }
429        None => f(),
430    }
431}
432
433/// Merge finished background-compinit results into shell state, callable
434/// from any site (active VM context first, session executor otherwise —
435/// the ZLE completion path runs OUTSIDE a VM frame, where
436/// `try_with_executor` alone is None and $_comps stayed empty forever).
437pub fn drain_compinit_bg_hook() {
438    if try_with_executor(|exec| exec.drain_compinit_bg()).is_some() {
439        return;
440    }
441    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
442    if let Some(ptr) = ptr {
443        // SAFETY: per with_session_context.
444        let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
445        unsafe { (*ptr).drain_compinit_bg() }
446    }
447}
448
449/// RAII guard that sets/clears the thread-local executor pointer.
450///
451/// Idempotent: calling `enter` when a context is already active is a no-op
452/// for the entry side, and the guard's drop only clears the thread-local if
453/// *this* call was the one that set it. Nested `execute_command` invocations
454/// (e.g. from inside a builtin handler) reuse the outer pointer instead of
455/// stomping it.
456pub(crate) struct ExecutorContext {
457    we_set_it: bool,
458}
459
460impl ExecutorContext {
461    pub(crate) fn enter(executor: &mut ShellExecutor) -> Self {
462        let we_set_it = CURRENT_EXECUTOR.with(|cell| {
463            let mut slot = cell.borrow_mut();
464            if slot.is_some() {
465                false
466            } else {
467                *slot = Some(executor as *mut ShellExecutor);
468                true
469            }
470        });
471        ExecutorContext { we_set_it }
472    }
473}
474
475impl Drop for ExecutorContext {
476    fn drop(&mut self) {
477        if self.we_set_it {
478            CURRENT_EXECUTOR.with(|cell| {
479                *cell.borrow_mut() = None;
480            });
481        }
482    }
483}
484
485/// Access the current executor from a builtin handler.
486/// # Safety
487/// Only call this from within a VM execution context (after ExecutorContext::enter).
488#[inline]
489pub(crate) fn with_executor<F, R>(f: F) -> R
490where
491    F: FnOnce(&mut ShellExecutor) -> R,
492{
493    CURRENT_EXECUTOR.with(|cell| {
494        let ptr = cell
495            .borrow()
496            .expect("with_executor called outside VM context");
497        // SAFETY: The pointer is valid for the duration of VM execution,
498        // and we're single-threaded within the executor.
499        let executor = unsafe { &mut *ptr };
500        f(executor)
501    })
502}
503
504/// Non-panicking variant of [`with_executor`]: runs `f` against the
505/// current executor and returns `Some(result)`, or `None` when no
506/// executor is in scope (`CURRENT_EXECUTOR` unset — e.g. unit tests /
507/// compsys contexts with no fusevm bridge running).
508///
509/// This is the primitive the `crate::ported::exec` accessor wrappers
510/// (array/assoc/dispatch_function_call/execute_script/...) use to
511/// reach the live executor while preserving the exact "no executor →
512/// fall back to the direct param table / default value" semantics that
513/// the deleted `exec_hooks` OnceLock layer encoded via its
514/// "is-the-hook-installed?" check. `CURRENT_EXECUTOR` being set is the
515/// faithful equivalent of "the bridge installed the hooks".
516#[inline]
517pub(crate) fn try_with_executor<F, R>(f: F) -> Option<R>
518where
519    F: FnOnce(&mut ShellExecutor) -> R,
520{
521    CURRENT_EXECUTOR.with(|cell| {
522        let ptr = (*cell.borrow())?;
523        // SAFETY: same contract as with_executor — the pointer is valid
524        // for the duration of VM execution and access is single-threaded.
525        let executor = unsafe { &mut *ptr };
526        Some(f(executor))
527    })
528}
529
530/// Look up a canonical builtin by name in `BUILTINS` and dispatch
531/// via `execbuiltin` (Src/builtin.c:250). NO shadow check — calls the
532/// builtin even if a user function with the same name exists. Used by
533/// the `builtin foo` prefix opcode (which explicitly bypasses function
534/// lookup per zsh semantics) and by internal call sites where shadowing
535/// is unwanted. For zsh's normal name-resolution order (function shadows
536/// builtin), use `dispatch_builtin` instead.
537/// Shell-identifier prefix for diagnostic lines. Reads the canonical
538/// scriptname (`zsh` in `--zsh` parity mode, `zshrs` otherwise) so a
539/// single helper replaces hardcoded `"zshrs:"` literals across the
540/// file's eprintln paths.
541fn shname() -> String {
542    crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string())
543}
544
545/// c:Src/subst.c:505-507 + Src/exec.c:3378-3380 — per-command
546/// CSH_NULL_GLOB outcome check. During this command's word expansion
547/// `expand_glob` accumulated `badcshglob |= 1` per failed glob and
548/// `|= 2` per successful one (Src/glob.c:1871-1875). Exactly 1 —
549/// failures and no successes — is the csh-style error: `no match`,
550/// command skipped, status 1. Any other value (0 = no globs, 2/3 =
551/// at least one matched) is silent. Always resets the counter for
552/// the next command (C resets at prefork entry, subst.rs:1307).
553/// Returns true when the error fired; callers mirror their
554/// glob_failed handling (builtins leave ERRFLAG_ERROR set so the
555/// script aborts, externals clear it so the next sublist runs —
556/// verified against zsh 5.9.1).
557/// Restore the user's GLOB_SUBST after a `${~spec}` carrier flip
558/// (see subst::TILDE_GLOBSUBST_CARRIER). Runs at the same
559/// command-dispatch boundaries that consume glob_failed /
560/// badcshglob — by then every glob op of the current word pipeline
561/// has read the carrier.
562pub(crate) fn consume_tilde_globsubst_carrier() {
563    crate::ported::subst::TILDE_GLOBSUBST_CARRIER.with(|c| {
564        if let Some(saved) = c.take() {
565            crate::ported::options::opt_state_set("globsubst", saved);
566        }
567    });
568}
569
570/// Pop `argc` stack slots for a whole-array assignment: the LAST popped
571/// (deepest pushed) is the param name, the rest are the values in stack
572/// order, with any `Value::Array` flattened to its elements. Mirrors the
573/// Flatten an array-assignment RHS value into scalar strings, descending
574/// through nested `Value::Array`s. zsh arrays are always flat, so recursion
575/// only collapses the wrapper layers the compiler introduces — in particular
576/// the single `Value::Array` built by `Op::MakeArray` for `arr=(...)` literals
577/// (used to dodge `CallBuiltin`'s u8 argc cap), whose own elements may
578/// themselves be arrays from an unquoted `$other_array` expansion. A
579/// one-level flatten would stringify those inner arrays into a single element.
580fn flatten_array_value(v: Value, out: &mut Vec<String>) {
581    match v {
582        Value::Array(items) => {
583            for it in items {
584                flatten_array_value(it, out);
585            }
586        }
587        other => out.push(other.to_str()),
588    }
589}
590
591/// pop/flatten prologue of BUILTIN_SET_ARRAY / BUILTIN_APPEND_ARRAY.
592fn pop_array_args_with_name(vm: &mut fusevm::VM, argc: u8) -> (String, Vec<String>) {
593    let n = argc as usize;
594    let mut popped: Vec<Value> = Vec::with_capacity(n);
595    for _ in 0..n {
596        popped.push(vm.pop());
597    }
598    popped.reverse();
599    let name = popped.pop().map(|v| v.to_str()).unwrap_or_default();
600    let mut values: Vec<String> = Vec::new();
601    for v in popped {
602        flatten_array_value(v, &mut values);
603    }
604    (name, values)
605}
606
607fn consume_badcshglob() -> bool {
608    let v = crate::ported::glob::BADCSHGLOB.swap(0, std::sync::atomic::Ordering::Relaxed);
609    if v == 1 {
610        crate::ported::utils::zerr("no match"); // c:Src/subst.c:507
611        true
612    } else {
613        false
614    }
615}
616
617/// Map a builtin name to the zsh module that owns it, IFF zsh does
618/// not auto-load that builtin on first use. Used by
619/// `dispatch_builtin_raw` to gate `--zsh` mode dispatch behind
620/// `zmodload`, mirroring `zsh -fc <name>` returning 127 for these
621/// names without an explicit module load.
622///
623/// Returns `Some(module_name)` if `name` belongs to a non-auto-load
624/// module per the per-module `Src/Modules/<x>.c` `bintab[]` plus
625/// the auto-load flag set at module-build time. `None` for core
626/// builtins and for auto-loaded module builtins (sched, log, echotc,
627/// echoti, zformat, zparseopts, zregexparse, zstyle, strftime,
628/// private, vared, zle, bindkey, comp*) which work without zmodload.
629fn module_bound_builtin_module(name: &str) -> Option<&'static str> {
630    match name {
631        "zftp" => Some("zsh/zftp"),
632        "zsocket" => Some("zsh/net/socket"),
633        "ztcp" => Some("zsh/net/tcp"),
634        "zstat" => Some("zsh/stat"),
635        "zselect" => Some("zsh/zselect"),
636        "zpty" => Some("zsh/zpty"),
637        "zprof" => Some("zsh/zprof"),
638        "zsystem" | "syserror" => Some("zsh/system"),
639        "clone" => Some("zsh/clone"),
640        "zcurses" => Some("zsh/curses"),
641        "ztie" | "zuntie" | "zgdbmpath" => Some("zsh/db/gdbm"),
642        "pcre_compile" | "pcre_match" | "pcre_study" => Some("zsh/pcre"),
643        "example" => Some("zsh/example"),
644        "cap" | "getcap" | "setcap" => Some("zsh/cap"),
645        "zgetattr" | "zsetattr" | "zdelattr" | "zlistattr" => Some("zsh/attr"),
646        // c:Src/Modules/datetime.c — `strftime` is registered via
647        // partab[] when zsh/datetime loads. Verified by
648        // `zsh -fc 'strftime -s s %Y 0'` → 127 "command not found".
649        "strftime" => Some("zsh/datetime"),
650        _ => None,
651    }
652}
653
654/// Dispatch a zshrs-ORIGINAL builtin by NAME, argv-style. These are
655/// registered as fusevm opcodes in [`register_builtins`] (async, doctor,
656/// peach, …), so a *literal* name compiles to `CallBuiltin` and runs. But
657/// they are absent from the static `BUILTINS` port table and the merged
658/// `builtintab`, so when the command name is resolved only at run time —
659/// `$var` indirection, `builtin NAME` — the ported command-resolution path
660/// never finds them and reports "command not found" / "no such builtin",
661/// even though `whence` (correctly) calls them builtins. The
662/// `register_builtins` closures use the VM only to pop args and then call an
663/// executor method, so the identical dispatch works here from any parent-side
664/// resolver that has an executor — no VM re-entry (which would alias the
665/// running `&mut VM`). Returns `None` for a name that is not one of them, so
666/// the caller falls through to external lookup.
667///
668/// !!! Keep in sync with the matching `vm.register_builtin(...)` closures in
669/// `register_builtins`: both must route a name to the same executor method.
670pub(crate) fn try_run_registered_builtin(name: &str, argv: &[String]) -> Option<i32> {
671    let s = match name {
672        "async" => with_executor(|e| e.builtin_async(argv)),
673        "await" => with_executor(|e| e.builtin_await(argv)),
674        "barrier" => with_executor(|e| e.builtin_barrier(argv)),
675        "peach" => with_executor(|e| e.builtin_peach(argv)),
676        "pmap" => with_executor(|e| e.builtin_pmap(argv)),
677        "pgrep" => with_executor(|e| e.builtin_pgrep(argv)),
678        "intercept" => with_executor(|e| e.builtin_intercept(argv)),
679        "intercept_proceed" => with_executor(|e| e.builtin_intercept_proceed(argv)),
680        "doctor" => with_executor(|e| e.builtin_doctor(argv)),
681        "dbview" => with_executor(|e| e.builtin_dbview(argv)),
682        "profile" => with_executor(|e| e.builtin_profile(argv)),
683        "caller" => with_executor(|e| e.builtin_caller(argv)),
684        "help" => with_executor(|e| e.builtin_help(argv)),
685        "cdreplay" => with_executor(|e| e.builtin_cdreplay(argv)),
686        "zsleep" => crate::extensions::ext_builtins::zsleep(argv),
687        _ => return None,
688    };
689    Some(s)
690}
691
692pub(crate) fn dispatch_builtin_raw(name: &str, args: Vec<String>) -> i32 {
693    // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
694    // Native p10k engine intercept (src/extensions/p10k): sourcing
695    // powerlevel10k.zsh-theme activates the Rust segment engine
696    // instead of executing the ~13k-line zsh theme. The user's
697    // `.p10k.zsh` CONFIG is NOT intercepted — it sources normally so
698    // its POWERLEVEL9K_* typesets land in the paramtab, which the
699    // engine reads live at every render. Placed here (the chokepoint
700    // every builtin route funnels through) so `source`, `.`, and
701    // `builtin source` all hit it.
702    if matches!(name, "source" | ".") {
703        if let Some(status) = crate::p10k::maybe_intercept_theme_source(&args) {
704            // Register a `p10k` stub function so `${+functions[p10k]}`
705            // guards in .zshrc templates stay truthy. The body forwards
706            // to the bridge-intercepted `zshrs-p10k-api` name so
707            // `p10k segment` (custom-segment protocol) and the other
708            // API subcommands reach the native engine (p10k_api).
709            try_with_executor(|exec| {
710                let _ = exec.execute_script("function p10k() { zshrs-p10k-api \"$@\" }");
711            });
712            return status;
713        }
714    }
715    // Native p10k API dispatch — the `p10k` stub function forwards
716    // here (see the theme intercept above). Must run before the
717    // generic builtintab lookup: the name is not a real builtin.
718    if let Some(status) = crate::p10k::maybe_intercept_command(name, &args) {
719        return status;
720    }
721    // c:Src/exec.c:2700-2717 — `private` is an autoloaded builtin in
722    // zsh (autofeature b:private of zsh/param/private): first use
723    // runs ensurefeature → require_module → load_module → boot_,
724    // marking the module MOD_INIT_B (what `zmodload -e` reads) and
725    // installing the wrap_private FuncWrap (param_private.c:712).
726    // doshfunc gates the wrapper dispatch on that load state, so
727    // this require_module is what activates private scoping. The
728    // raw dispatcher is the chokepoint every builtin route funnels
729    // through; require_module is idempotent after the first call
730    // (needs_load checks MOD_INIT_B).
731    if name == "private" {
732        if let Ok(mut tab) = crate::ported::module::MODULESTAB.lock() {
733            let _ = crate::ported::module::require_module(&mut tab, "zsh/param/private", None, 0);
734            // c:2710 ensurefeature
735        }
736    }
737    // c:Src/Modules/param_private.c:682-685 setup_ — loading
738    // zsh/param/private REPLACES the `local` builtintab node's
739    // handlerfunc + optstr with bin_private's ("Even more horrible
740    // hack"), so once the module is loaded `local` IS bin_private: it
741    // accepts the -P/-Pa private-scope flags, and without -P delegates
742    // to bin_typeset, which already treats `local` and `private`
743    // identically (is_locallike, builtin.rs:3666). Replicate the swap by
744    // routing `local` through the `private` node only after the module
745    // is loaded — before then, `local -P` still errors "bad option: -P"
746    // exactly like stock zsh. The `private` node carries the augmented
747    // optstr (with P) that the `local` node lacks.
748    if name == "local"
749        && crate::ported::module::MODULESTAB
750            .lock()
751            .map(|t| t.is_bound("zsh/param/private"))
752            .unwrap_or(false)
753    {
754        return dispatch_builtin_raw("private", args);
755    }
756    // c:Bugs #475/#504/#555 — bash-only builtins (`mapfile`,
757    // `readarray`, `compopt`) should emit "command not found" in
758    // `--zsh` mode matching zsh's external-command-lookup miss.
759    // The per-opcode closures for caller/help/complete/compgen
760    // already gate via IS_ZSH_MODE at their registration sites;
761    // names without dedicated opcodes (compopt/mapfile/readarray)
762    // route through this generic builtintab lookup and need the
763    // gate here.
764    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
765        && matches!(name, "compopt" | "mapfile" | "readarray")
766    {
767        eprintln!("zsh:1: command not found: {}", name);
768        let _ = args;
769        return 127;
770    }
771    // c:Src/exec.c:2700-2724 resolvebuiltin — autoloaded-builtin stub
772    // (registered by `zmodload -ab MOD NAME`, Src/module.c:426
773    // add_autobin) fires on first use: ensurefeature loads the owning
774    // module, then dispatch proceeds against the real builtin. Must
775    // run BEFORE the module-bound 127 gate below — `zmodload -ab
776    // zsh/zselect zselect; zselect` previously died there with
777    // `command not found` because the gate only checked is_loaded,
778    // never the autoload ledger.
779    if let Some(rc) = crate::ported::module::resolvebuiltin(name) {
780        if rc != 0 {
781            // Load failed or feature undefined — diagnostics already
782            // printed (load_module zwarn / resolvebuiltin zerr).
783            // C's execbuiltin head returns 1 (Src/builtin.c:264-267).
784            return 1;
785        }
786        // Module loaded — fall through; the is_loaded gates below now
787        // pass and the normal dispatch chain runs the real builtin.
788    }
789    // c:Src/Modules/<mod>.c boot_/setup_ chain — module-bound builtins
790    // (zftp, zsocket, ztcp, zstat, etc.) are only registered into
791    // `builtintab` when their module is loaded via `zmodload`. In
792    // zsh `-fc` (the parity test harness's invocation), the modules
793    // are NOT pre-loaded, so each name reports "command not found"
794    // with exit 127. zshrs intentionally pre-loads all module bintabs
795    // in `createbuiltintable` (builtin.rs:131-152) for the default
796    // mode so users can call these without `zmodload`; that auto-load
797    // diverges from zsh's gate behavior. Match zsh's stance only when
798    // the user explicitly asked for parity via `--zsh`.
799    //
800    // The list is the union of builtins from modules that zsh does
801    // NOT auto-load (verified via `zsh -fc <name>` returning 127):
802    //   zsh/zftp          → zftp
803    //   zsh/net/socket    → zsocket
804    //   zsh/net/tcp       → ztcp
805    //   zsh/stat          → zstat (NOT `stat`; that name resolves to
806    //                              /bin/stat on PATH per zsh's setup)
807    //   zsh/zselect       → zselect
808    //   zsh/zpty          → zpty
809    //   zsh/zprof         → zprof
810    //   zsh/system        → zsystem, syserror
811    //   zsh/clone         → clone
812    //   zsh/curses        → zcurses
813    //   zsh/db/gdbm       → ztie, zuntie, zgdbmpath
814    //   zsh/pcre          → pcre_compile, pcre_match, pcre_study
815    //   zsh/example       → example
816    //   zsh/cap           → cap, getcap, setcap
817    //   zsh/attr          → zgetattr, zsetattr, zdelattr, zlistattr
818    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
819        && module_bound_builtin_module(name)
820            .map(|m| {
821                !crate::ported::module::MODULESTAB
822                    .lock()
823                    .map(|t| t.is_loaded(m))
824                    .unwrap_or(false)
825            })
826            .unwrap_or(false)
827    {
828        eprintln!("zsh:1: command not found: {}", name);
829        let _ = args;
830        return 127;
831    }
832    // c:Src/Modules/files.c:806-824 — zsh/files registers `chmod`,
833    // `chown`, `chgrp`, `ln`, `mkdir`, `mv`, `rm`, `rmdir`, `sync`
834    // (plus their `zf_*` aliases) into builtintab on module load.
835    // Without an explicit `zmodload zsh/files`, zsh resolves the
836    // names through PATH lookup — `zsh -fc 'chmod +x f'` runs
837    // `/bin/chmod`, whose argv-parser accepts symbolic modes like
838    // `+x` that bin_chmod's octal-only parser rejects with
839    // "invalid mode `+x'". The shadow-aware wrapper at
840    // `dispatch_builtin` (line 438) already has this gate, but the
841    // direct `dispatch_builtin_raw` path used by fusevm's
842    // CallBuiltin opcode bypasses it. Mirror the gate here so the
843    // low-level dispatch matches C's PATH-fall-through behavior.
844    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
845        && module_gated_files_builtin(name)
846        && !crate::ported::module::MODULESTAB
847            .lock()
848            .map(|t| t.is_loaded("zsh/files"))
849            .unwrap_or(false)
850    {
851        // PATH lookup uses the LITERAL name: bare `mkdir` finds
852        // /bin/mkdir; a `zf_*` alias finds nothing and exits 127 —
853        // matching zsh -fc `zf_mkdir d` → "command not found:
854        // zf_mkdir" (the aliases exist ONLY in the loaded module's
855        // builtintab, Src/Modules/files.c:816-824; PATH has no
856        // /bin/zf_rm). The previous zf_-strip silently ran the
857        // system binary instead.
858        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
859        return status;
860    }
861    // c:Src/Modules/stat.c:637-638 — zsh/stat registers BOTH `stat`
862    // and `zstat`. `zstat` is in the module_bound 127-gate above (no
863    // /usr/bin/zstat exists), but the bare `stat` name must FALL
864    // THROUGH to PATH when zsh/stat isn't loaded — zsh -fc
865    // 'stat -f %Lp f' runs /usr/bin/stat, while bin_stat's parser
866    // rejects stat(1) flags ("bad option: -c"). Same fall-through
867    // shape as the zsh/files gate above.
868    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
869        && name == "stat"
870        && !crate::ported::module::MODULESTAB
871            .lock()
872            .map(|t| t.is_loaded("zsh/stat"))
873            .unwrap_or(false)
874    {
875        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
876        return status;
877    }
878    // c:Src/exec.c:3050-3068 — builtin lookup hits `builtintab` (the
879    // merged table containing module-provided builtins). The previous
880    // port walked only the core `BUILTINS` slice, so per-module
881    // entries like `log` (Src/Modules/watch.c:693 `BUILTIN("log", …,
882    // bin_log, …)`) were registered into builtintab via
883    // createbuiltintable but never reached at dispatch — `log` fell
884    // through to PATH and ran `/usr/bin/log`. Bug #72 in docs/BUGS.md.
885    let tab = crate::ported::builtin::createbuiltintable();
886    if let Some(bn_static) = tab.get(name) {
887        let bn_ptr = *bn_static as *const _ as *mut _;
888        return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
889    }
890    1
891}
892
893/// Shadow-aware dispatch matching zsh's name-resolution order:
894/// alias → reserved word → **function (shadows builtin)** → builtin →
895/// external. All `BUILTIN_X` opcode handlers route through here so a
896/// user-defined `cd () { … }` (or `r`, `fc`, `which`, … anything in
897/// fusevm's name→opcode map) takes precedence over the C builtin —
898/// matching `Src/exec.c:execcmd_exec`'s dispatch at c:3050-3068.
899/// Without this, compile-time builtin resolution silently ignored
900/// user wrappers (e.g. ZPWR's `cd () { builtin cd "$@"; … }`).
901/// True for builtins that are bound by zsh/files's boot_/setup_
902/// chain (Src/Modules/files.c:806-824). These are the bare-name
903/// `mkdir`/`rm`/`mv`/`ln`/`chmod`/`chown`/`chgrp`/`sync`/`rmdir`
904/// AND their `zf_*` aliases at c:816-824. Without explicit
905/// `zmodload zsh/files`, the names fall through to PATH lookup
906/// (zsh's `type rm` reports `/bin/rm`). Bug #28.
907fn module_gated_files_builtin(name: &str) -> bool {
908    matches!(
909        name,
910        "mkdir"
911            | "rmdir"
912            | "rm"
913            | "mv"
914            | "ln"
915            | "chmod"
916            | "chown"
917            | "chgrp"
918            | "sync"
919            | "zf_mkdir"
920            | "zf_rmdir"
921            | "zf_rm"
922            | "zf_mv"
923            | "zf_ln"
924            | "zf_chmod"
925            | "zf_chown"
926            | "zf_chgrp"
927            | "zf_sync"
928    )
929}
930
931pub(crate) fn dispatch_builtin(name: &str, args: Vec<String>) -> i32 {
932    // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close any
933    // `>(cmd)` write ends owned by this command once it finishes
934    // (drops on every return path below).
935    let _psub_fds = PsubFdGuard;
936    // c:Src/exec.c — when any redirect in the current scope failed
937    // (e.g. noclobber blocked a `>` overwrite), zsh refuses to
938    // execute the command and exits with status 1. The Rust port
939    // still applied the command (writing to the /dev/null sink
940    // installed by host_apply_redirect's noclobber arm) so the
941    // success status overwrote the intended 1. Short-circuit here
942    // for builtins (the external-exec equivalent lives in
943    // ZshrsHost::exec).
944    let redir_failed = with_executor(|exec| {
945        let f = exec.redirect_failed;
946        exec.redirect_failed = false;
947        f
948    });
949    if redir_failed {
950        // c:Src/exec.c:4367-4386 — POSIX special-builtin escalation:
951        // a failed redirect on a PSPECIAL builtin (set, readonly,
952        // typeset, ...) under POSIX_BUILTINS is FATAL in a
953        // non-interactive shell (`exit(1)` at c:4383). The `command`
954        // prefix resets this (BINF_COMMAND, c:4369) — that path
955        // dispatches through bin_command, not here.
956        if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
957            && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
958            && builtin_is_pspecial(name)
959        {
960            use std::sync::atomic::Ordering;
961            crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
962            crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
963        }
964        return 1;
965    }
966    // c:Src/glob.c:1876-1880 NOMATCH path — when expand_glob() failed
967    // on a no-match glob, zsh aborts the simple command after zerr()
968    // printed "no matches found". In C, this works because zerr()
969    // sets ERRFLAG_ERROR (Src/utils.c) and execcmd_exec()
970    // (Src/exec.c:3050+) checks errflag before invoking the builtin
971    // table. Rust's builtin dispatch doesn't sit on the same errflag
972    // gate, so we explicitly consume the per-command glob-fail cell
973    // and short-circuit with status 1. Mirrors the external-path
974    // guard at host_exec_external (line 5167). Without this:
975    // `echo /never/*` would print empty (silently rolled back to ""
976    // by the empty glob expansion). Parity bug #13.
977    consume_tilde_globsubst_carrier();
978    let glob_failed = with_executor(|exec| {
979        let f = exec.current_command_glob_failed.get();
980        exec.current_command_glob_failed.set(false); // c:1879 cleanup
981        f
982    });
983    if glob_failed {
984        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH zerr sets
985        // ERRFLAG_ERROR (via utils.c:184). For a BUILTIN command the
986        // expansion runs IN the shell process, so errflag stays set
987        // and the rest of the input aborts (zsh -fc 'echo /nope_*;
988        // echo after' prints nothing after the error — verified
989        // against zsh 5.9). The continue-after-nomatch behaviour
990        // belongs ONLY to externals: C forks BEFORE expansion there,
991        // so the child's zerr can't touch the parent's errflag (zsh
992        // -fc 'ls /nope_*; echo after' prints `after`) — that path's
993        // clear lives in fn exec / execute_external. Leave
994        // ERRFLAG_ERROR set here; BUILTIN_ERREXIT_CHECK trigger 4
995        // aborts the remaining script at the next command boundary.
996        return 1; // c:1880 — command aborted, status 1
997    }
998    // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the NOMATCH
999    // gate above: all of this command's globs failed silently (words
1000    // dropped, badcshglob accumulated 1s and no 2s) → `no match`,
1001    // skip the builtin, status 1. Like the NOMATCH path, ERRFLAG
1002    // from zerr stays set for builtins so the rest of the script
1003    // aborts (zsh -fc 'setopt cshnullglob; print *nope* x; print
1004    // after' prints only the error — verified zsh 5.9.1).
1005    if consume_badcshglob() {
1006        // c:Src/exec.c:3380 — `lastval = 1;` so the shell's final
1007        // exit status reflects the aborted command.
1008        with_executor(|exec| exec.set_last_status(1));
1009        return 1;
1010    }
1011    // c:Src/exec.c:4162-4295 — assignment-builtin (BINF_ASSIGN family:
1012    // typeset / declare / local / export / readonly / integer / float /
1013    // private) whose `name=value` postassign arg raised errflag while
1014    // its RHS was preforked (PREFORK_ASSIGN, c:4239-4245) — the classic
1015    // case is a math error in `typeset -F fv=$((1/0))`. The postassign
1016    // loop `break`s on errflag (c:4243) and then `if (!errflag)
1017    // execbuiltin(...)` (c:4287) SKIPS the builtin entirely, so `lastval`
1018    // is left UNCHANGED from before the command (0 fresh, 1 after
1019    // `false`). This differs from a PLAIN assignment `x=$((1/0))`, which
1020    // goes through execsimple c:1375 `lv = errflag ? errflag : cmdoutval`
1021    // → 1, and from a NON-assign builtin `print $((1/0))`, whose main
1022    // args-prefork errflag lands on c:3760 `lastval = 1`. Only the
1023    // assignment-BUILTIN postassign path preserves the prior status.
1024    // Mirror it here: the fusevm reg_passthru dispatch still calls us
1025    // with errflag set (unlike C's pre-invoke gate), so consume that
1026    // state and return the prior LASTVAL instead of running the builtin.
1027    {
1028        use std::sync::atomic::Ordering;
1029        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
1030        let ef = live & crate::ported::zsh_h::ERRFLAG_ERROR;
1031        let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
1032        // Only the SOFT recoverable error (math failure like `$((1/0))`,
1033        // ERRFLAG_ERROR without ERRFLAG_HARD) preserves the prior status
1034        // per c:4287. A HARD error (`${var?msg}`, which c:Src/subst.c
1035        // OR's ERRFLAG_HARD onto errflag) is a script-abort that yields
1036        // status 1 regardless of the prior status — leave that to the
1037        // normal dispatch/abort path below (which returns 1 and keeps
1038        // ERRFLAG_HARD set for the downstream errexit gate).
1039        if ef != 0 && hard == 0 && builtin_is_assign_family(name) {
1040            // c:4287 — execbuiltin skipped; lastval unchanged.
1041            return crate::ported::builtin::LASTVAL.load(Ordering::Relaxed);
1042        }
1043    }
1044    if let Some(status) = try_user_fn_override(name, &args) {
1045        // c:Src/jobs.c:1748 waitonejob — canonical single-command
1046        // pipestats update via the no-procs else-branch.
1047        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1048        let mut synth = crate::ported::zsh_h::job::default();
1049        crate::ported::jobs::waitonejob(&mut synth);
1050        return status;
1051    }
1052    // c:Src/builtin.c:587 + Src/exec.c:3056 — a builtin disabled via
1053    // `disable <name>` has its `DISABLED` flag set in `builtintab`;
1054    // `builtintab->getnode` (the DISABLED-filtering accessor) returns
1055    // NULL for it at lookup time, so execcmd_exec falls through to
1056    // PATH lookup and runs the external. The Rust port stores the
1057    // disabled set in `BUILTINS_DISABLED`; the previous dispatcher
1058    // only checked the immutable `createbuiltintable` HashMap which
1059    // never reflects disablement — so `disable echo; echo hi` kept
1060    // running the bin_echo builtin. Bug #106 in docs/BUGS.md.
1061    //
1062    // dispatch_builtin (the high-level wrapper used by the BUILTIN_*
1063    // opcode handlers and reg_passthru! callsites) is the correct
1064    // gate: `dispatch_builtin_raw` is the low-level entry point
1065    // used by `bin_builtin` itself which MUST bypass the disabled
1066    // set (man zshbuiltins: `builtin name` runs the builtin
1067    // regardless of disable state). Place the check here so the
1068    // bypass path stays clean.
1069    let disabled = crate::ported::builtin::BUILTINS_DISABLED
1070        .lock()
1071        .map(|s| s.contains(name))
1072        .unwrap_or(false);
1073    if disabled {
1074        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1075        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1076        let mut synth = crate::ported::zsh_h::job::default();
1077        crate::ported::jobs::waitonejob(&mut synth);
1078        return status;
1079    }
1080    // c:Src/Modules/files.c:806-814 — `mkdir`, `rm`, `mv`, `ln`, `chmod`,
1081    // `chown`, `chgrp`, `sync`, `rmdir` are bound by the `zsh/files`
1082    // module's boot_/setup_ chain. Without explicit `zmodload zsh/files`,
1083    // these bare names fall through to PATH (`/bin/rm`, `/usr/bin/chmod`,
1084    // etc.) in zsh; `type rm` reports `rm is /bin/rm`. The `zf_*`
1085    // aliases (`zf_rm`, `zf_chmod`, …) are bound by the same module
1086    // and gated the same way. Bug #28 in docs/BUGS.md.
1087    if module_gated_files_builtin(name) {
1088        if !crate::ported::module::MODULESTAB
1089            .lock()
1090            .unwrap()
1091            .is_loaded("zsh/files")
1092        {
1093            // PATH lookup uses the literal name. In --zsh parity mode
1094            // `zf_rm` must 127 like zsh -fc (no /bin/zf_rm); default
1095            // zshrs mode keeps the convenience zf_-strip so the alias
1096            // still reaches the system binary.
1097            let path_name = if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1098                name
1099            } else {
1100                name.strip_prefix("zf_").unwrap_or(name)
1101            };
1102            let status =
1103                with_executor(|exec| exec.execute_external(path_name, &args, &[])).unwrap_or(127);
1104            crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1105            let mut synth = crate::ported::zsh_h::job::default();
1106            crate::ported::jobs::waitonejob(&mut synth);
1107            return status;
1108        }
1109    }
1110    // c:Src/exec.c:3997 `int q = queue_signal_level();`
1111    // c:Src/exec.c:4231 `dont_queue_signals();`
1112    // c:Src/exec.c:4243 `restore_queue_signals(q);`
1113    //
1114    // C runs EVERY builtin with signal queueing switched OFF. Two
1115    // consequences the zshrs port was missing:
1116    //
1117    //   1. `dont_queue_signals()` DRAINS the pending queue (it calls
1118    //      run_queued_signals()), so a signal that arrived while an
1119    //      enclosing scope held queue_signals() — doshfunc holds one
1120    //      for the whole call, c:Src/exec.c:5835 — fires its trap at
1121    //      the NEXT command boundary rather than at function exit.
1122    //   2. While the builtin runs, queueing stays off, so a signal the
1123    //      builtin sends to itself (`kill -USR1 $$`) dispatches the
1124    //      trap synchronously inside the builtin — which is why zsh
1125    //      prints pre/trap/post for
1126    //      `f() { print pre; kill -USR1 $$; print post }`.
1127    //
1128    // Without this bracket every trap raised inside a function was
1129    // deferred to the enclosing unqueue_signals() (i.e. script end).
1130    let q = crate::ported::signals_h::queue_signal_level(); // c:3997
1131    crate::ported::signals_h::dont_queue_signals(); // c:4231
1132    let status = dispatch_builtin_raw(name, args);
1133    crate::ported::signals_h::restore_queue_signals(q); // c:4243
1134                                                        // c:Src/jobs.c:1748 waitonejob — canonical single-command pipestats update.
1135    crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1136    let mut synth = crate::ported::zsh_h::job::default();
1137    crate::ported::jobs::waitonejob(&mut synth);
1138    // c:Src/exec.c:4367-4386 — done: tail. A PSPECIAL builtin that
1139    // raised errflag under POSIX_BUILTINS exits the non-interactive
1140    // shell with status 1 ("hard error in POSIX" — e.g. bin_dot's
1141    // zerrnam at Src/builtin.c:6133). Arm the deferred-exit pair so
1142    // the next ERREXIT_CHECK unwinds; EXIT_VAL=1 matches C's
1143    // hardcoded exit(1), NOT the builtin's own status (dot returns
1144    // 127 but POSIX exits 1).
1145    if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
1146        && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
1147        && builtin_is_pspecial(name)
1148        && (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
1149            & crate::ported::zsh_h::ERRFLAG_ERROR)
1150            != 0
1151    {
1152        use std::sync::atomic::Ordering;
1153        crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
1154        crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
1155    }
1156    status
1157}
1158
1159/// c:Src/zsh.h:1467 BINF_PSPECIAL — true when `name` is a POSIX
1160/// special builtin per the canonical builtin table flags
1161/// (Src/builtin.c:48-129: `.`, `:`, break, continue, declare, eval,
1162/// exit, export, float, integer, local, readonly, return, set,
1163/// shift, source, times, trap, typeset, unset).
1164fn builtin_is_pspecial(name: &str) -> bool {
1165    crate::ported::builtin::createbuiltintable()
1166        .get(name)
1167        .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_PSPECIAL) != 0)
1168        .unwrap_or(false)
1169}
1170
1171/// c:Src/zsh.h:1486 BINF_ASSIGN — the assignment-builtin family
1172/// (typeset / declare / local / export / readonly / integer / float /
1173/// private). Their `name=value` args are handled as postassigns
1174/// (c:Src/exec.c:4162-4295), whose errflag-abort skips execbuiltin and
1175/// preserves the prior `lastval`. Read the flag straight from the
1176/// builtin table (same pattern as `builtin_is_pspecial`).
1177fn builtin_is_assign_family(name: &str) -> bool {
1178    crate::ported::builtin::createbuiltintable()
1179        .get(name)
1180        .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_ASSIGN) != 0)
1181        .unwrap_or(false)
1182}
1183
1184// The former `install_exec_hooks()` fn-pointer registry is gone. Code
1185// under `src/ported/` now reaches `ShellExecutor` operations
1186// (array/assoc storage, script eval, function dispatch, command
1187// substitution) through the `crate::ported::exec::*` accessor wrappers,
1188// which resolve the live executor via `try_with_executor`
1189// (`CURRENT_EXECUTOR`). The bridge lives in exec.rs — the sanctioned
1190// fusevm-access exception — per `feedback_no_exec_script_from_ported` /
1191// `feedback_no_shellexecutor_in_ported`.
1192
1193/// Register all zsh builtins with the VM.
1194pub(crate) fn register_builtins(vm: &mut fusevm::VM) {
1195    // src/ported/ reaches the live executor (param store, function
1196    // dispatch, nested script/cmdsubst exec) through the
1197    // `crate::ported::exec::*` accessor wrappers, which read
1198    // `CURRENT_EXECUTOR` via `try_with_executor`. No install step is
1199    // needed: the executor is in scope for the duration of any VM run
1200    // (set by `ExecutorContext::enter`), so the wrappers resolve it
1201    // directly. (Replaces the former `exec_hooks` OnceLock fn-ptr
1202    // registry, now deleted.)
1203    // Engage fusevm's tiered JIT (block + tracing) so hot, fully-eligible
1204    // numeric chunks run in native code and — with the `jit-disk-cache`
1205    // feature (on by default) — persist that native code to
1206    // `~/.cache/fusevm-jit`, letting repeated zsh invocations skip Cranelift
1207    // codegen. fusevm gates the JIT on per-chunk eligibility and warms up by
1208    // an invocation threshold, falling back to the interpreter for any chunk
1209    // it cannot compile (e.g. host-builtin/`Extended` command dispatch), so
1210    // enabling it here never changes observable behaviour — it only caches
1211    // the numeric hot path. Idempotent: re-enabling on each VM is a no-op.
1212    vm.enable_tracing_jit();
1213    // Macro for builtins that user functions are allowed to shadow.
1214    // zsh dispatch order is alias → function → builtin; without the
1215    // try_user_fn_override probe a `cat() { ... }; cat` would silently
1216    // run the C builtin and ignore the user function.
1217    macro_rules! reg_overridable {
1218        ($vm:expr, $id:expr, $name:literal, $method:ident) => {
1219            $vm.register_builtin($id, |vm, argc| {
1220                let args = pop_args(vm, argc);
1221                // c:Src/exec.c getproc + Src/jobs.c deletefilelist —
1222                // close `>(cmd)` write ends owned by this command
1223                // once it finishes (shadows bypass dispatch_builtin
1224                // and ZshrsHost::exec, so they need their own guard:
1225                // `tee >(wc -c) </dev/null` left wc blocked).
1226                let _psub_fds = PsubFdGuard;
1227                if let Some(s) = try_user_fn_override($name, &args) {
1228                    return Value::Status(s);
1229                }
1230                // c:Src/exec.c — redirect failure in the current
1231                // scope means the command must NOT run. coreutils
1232                // shadows (cat / head / tail / etc.) take a separate
1233                // dispatch path from dispatch_builtin, so they need
1234                // their own gate. Without this `cat <&3` after a
1235                // closed-fd diagnostic still ran the shadow and
1236                // overwrote $? from the forced 1.
1237                let redir_failed = with_executor(|exec| {
1238                    let f = exec.redirect_failed;
1239                    exec.redirect_failed = false;
1240                    f
1241                });
1242                if redir_failed {
1243                    return Value::Status(1);
1244                }
1245                // `[builtins].coreutils_shadows = off` in
1246                // ~/.zshrs/zshrs.toml (or `ZSHRS_NO_COREUTILS_SHADOWS=1`
1247                // env override) bypasses the in-process shadow and
1248                // fork-execs the real /bin/X. Safety valve for any
1249                // script that hits an edge-case divergence between
1250                // the zshrs shadow and system coreutils. Cached
1251                // after first call, so the hot path is one atomic
1252                // load per shadowed-builtin invocation.
1253                if !crate::daemon_presence::coreutils_shadows_enabled() {
1254                    return Value::Status(exec_system_command($name, &args));
1255                }
1256                let status = with_executor(|exec| exec.$method(&args));
1257                Value::Status(status)
1258            });
1259        };
1260    }
1261
1262    // Pure-passthru builtin: pops args, routes to canonical
1263    // `dispatch_builtin(name, args)` (which goes via execbuiltin →
1264    // BUILTINS[name] → bin_X). No pre/post bridge work. Used by
1265    // ~25 handlers that were 4-line copy-paste boilerplate.
1266    macro_rules! reg_passthru {
1267        ($vm:expr, $id:expr, $name:literal) => {
1268            $vm.register_builtin($id, |vm, argc| {
1269                let args = pop_args(vm, argc);
1270                // function > builtin: a same-named user function wins over
1271                // the builtin on the normal (CallBuiltin) invocation path.
1272                // The compiler's `user_function_shadow` already routes the
1273                // same-compile-unit case through CallFunction; this probe
1274                // extends that to the cross-unit / interactive case (define
1275                // `zstyle() { … }` on one line, call it on the next). The
1276                // forced `builtin NAME` / `command NAME` paths dispatch
1277                // through their own handlers, not this one, so they still
1278                // reach the builtin as required.
1279                if let Some(s) = try_user_fn_override($name, &args) {
1280                    return Value::Status(s);
1281                }
1282                Value::Status(dispatch_builtin($name, args))
1283            });
1284        };
1285    }
1286
1287    // zshrs-original extension builtins (async / peach / doctor / …) that
1288    // route to an ExecutorContext method. Like `reg_overridable!`, they
1289    // probe `try_user_fn_override` FIRST so a user function of the same
1290    // name wins — zsh's alias → function → builtin dispatch order. Without
1291    // the probe, `doctor() { … }; doctor` silently ran the builtin and
1292    // ignored the function (function > builtin violated for these).
1293    macro_rules! reg_ext_overridable {
1294        ($vm:expr, $id:expr, $name:literal, $method:ident) => {
1295            $vm.register_builtin($id, |vm, argc| {
1296                let args = pop_args(vm, argc);
1297                if let Some(s) = try_user_fn_override($name, &args) {
1298                    return Value::Status(s);
1299                }
1300                Value::Status(with_executor(|exec| exec.$method(&args)))
1301            });
1302        };
1303    }
1304
1305    // Core builtins
1306    vm.register_builtin(BUILTIN_CD, |vm, argc| {
1307        let args = pop_args(vm, argc);
1308        if let Some(s) = try_user_fn_override("cd", &args) {
1309            return Value::Status(s);
1310        }
1311        let status = dispatch_builtin("cd", args);
1312        // c:Src/builtin.c:1258 — `callhookfunc("chpwd", NULL, 1, NULL)`
1313        // after cd succeeds. The canonical port at
1314        // src/ported/utils.rs:1532 handles both the `chpwd` shfunc
1315        // dispatch AND the `chpwd_functions` array walk.
1316        if status == 0 {
1317            crate::ported::utils::callhookfunc("chpwd", None, 1, std::ptr::null_mut());
1318        }
1319        Value::Status(status)
1320    });
1321
1322    vm.register_builtin(BUILTIN_PWD, |vm, argc| {
1323        let args = pop_args(vm, argc);
1324        if let Some(s) = try_user_fn_override("pwd", &args) {
1325            return Value::Status(s);
1326        }
1327        // Route through the canonical execbuiltin path so the `rLP`
1328        // optstr at BUILTINS["pwd"] is parsed into `ops`.
1329        let status = dispatch_builtin("pwd", args);
1330        Value::Status(status)
1331    });
1332
1333    vm.register_builtin(BUILTIN_ECHO, |vm, argc| {
1334        let args = pop_args(vm, argc);
1335        if let Some(s) = try_user_fn_override("echo", &args) {
1336            return Value::Status(s);
1337        }
1338        // Update `$_` to the last arg before running. C zsh sets
1339        // zunderscore in execcmd_exec for every simple command,
1340        // including builtins.
1341        crate::ported::params::set_zunderscore(&args);
1342        let status = dispatch_builtin("echo", args);
1343        Value::Status(status)
1344    });
1345
1346    vm.register_builtin(BUILTIN_PRINT, |vm, argc| {
1347        let args = pop_args(vm, argc);
1348        if let Some(s) = try_user_fn_override("print", &args) {
1349            return Value::Status(s);
1350        }
1351        crate::ported::params::set_zunderscore(&args);
1352        let status = dispatch_builtin("print", args);
1353        Value::Status(status)
1354    });
1355
1356    reg_passthru!(vm, BUILTIN_PRINTF, "printf");
1357    reg_passthru!(vm, BUILTIN_EXPORT, "export");
1358    reg_passthru!(vm, BUILTIN_UNSET, "unset");
1359    // `source` (Src/builtin.c c:116) wired to bin_dot via BUILTINS.
1360    reg_passthru!(vm, BUILTIN_SOURCE, "source");
1361    reg_passthru!(vm, BUILTIN_DOT, ".");
1362    reg_passthru!(vm, BUILTIN_LOGOUT, "logout");
1363
1364    vm.register_builtin(BUILTIN_EXIT, |vm, argc| {
1365        let args = pop_args(vm, argc);
1366        let status = dispatch_builtin("exit", args);
1367        Value::Status(status)
1368    });
1369
1370    vm.register_builtin(BUILTIN_RETURN, |vm, argc| {
1371        let args = pop_args(vm, argc);
1372        // zsh: bare `return` (no arg) returns with the status of
1373        // the most recently executed command — `false; return`
1374        // returns 1, not 0. Direct port of zsh's bin_break/RETURN.
1375        // The executor's `last_status` is stale here (synced at
1376        // statement boundaries, not after each VM op), so read
1377        // the live `vm.last_status` instead.
1378        let live_status = vm.last_status;
1379        let status = {
1380            // Sync canonical LASTVAL to the VM's view BEFORE
1381            // bin_break("return") reads it for the no-arg fallback.
1382            with_executor(|exec| exec.set_last_status(live_status));
1383            dispatch_builtin("return", args)
1384        };
1385        Value::Status(status)
1386    });
1387
1388    vm.register_builtin(BUILTIN_TRUE, |vm, argc| {
1389        let args = pop_args(vm, argc);
1390        if let Some(s) = try_user_fn_override("true", &args) {
1391            return Value::Status(s);
1392        }
1393        // c:Src/exec.c:1257 — zsh sets `zunderscore` AT THE END of
1394        // each command (the `if (!noerrs)` block runs `zsfree(prev_argv0); …;
1395        // zunderscore = …`). For no-arg `true`, $_ becomes the
1396        // command name itself. Set DIRECTLY (not via pending_underscore)
1397        // so the NEXT command's argv-expansion of `$_` reads "true",
1398        // not the stale prior value — pending_underscore is consumed
1399        // by pop_args which runs AFTER argv expansion, too late.
1400        // c:Src/exec.c:1257 — `zunderscore = …` at end-of-command.
1401        // With args, $_ = args.last(). Without args, $_ = command name.
1402        // Write DIRECTLY to the canonical zunderscore static (the
1403        // underscoregetfn at params.rs:7003 reads from there); the
1404        // paramtab "_" slot is shadowed by lookup_special_var so
1405        // set_scalar on it has no effect on `$_` reads.
1406        if args.is_empty() {
1407            crate::ported::params::set_zunderscore(&["true".to_string()]);
1408        } else {
1409            crate::ported::params::set_zunderscore(&args);
1410        }
1411        // Route through canonical execbuiltin so PS4 xtrace fires
1412        // via the c:442 printprompt4 path.
1413        Value::Status(dispatch_builtin("true", args))
1414    });
1415    vm.register_builtin(BUILTIN_FALSE, |vm, argc| {
1416        let args = pop_args(vm, argc);
1417        if let Some(s) = try_user_fn_override("false", &args) {
1418            return Value::Status(s);
1419        }
1420        // Direct set; see BUILTIN_TRUE above for rationale.
1421        if args.is_empty() {
1422            crate::ported::params::set_zunderscore(&["false".to_string()]);
1423        } else {
1424            crate::ported::params::set_zunderscore(&args);
1425        }
1426        // Route through canonical execbuiltin — see BUILTIN_TRUE
1427        // above for the same rationale (xtrace + fast-path removal).
1428        let status = dispatch_builtin("false", args);
1429        Value::Status(status)
1430    });
1431    vm.register_builtin(BUILTIN_COLON, |vm, argc| {
1432        let args = pop_args(vm, argc);
1433        // Direct set; see BUILTIN_TRUE above for rationale.
1434        if args.is_empty() {
1435            crate::ported::params::set_zunderscore(&[":".to_string()]);
1436        } else {
1437            crate::ported::params::set_zunderscore(&args);
1438        }
1439        let status = dispatch_builtin(":", args);
1440        Value::Status(status)
1441    });
1442
1443    vm.register_builtin(BUILTIN_TEST, |vm, argc| {
1444        let args = pop_args(vm, argc);
1445        // Distinguish `[ … ]` from `test …` by sniffing the trailing
1446        // `]` — `[` requires it (c:Src/builtin.c:7241), `test` rejects
1447        // it. The compile path emits BUILTIN_TEST for both, so the
1448        // dispatch name carries the `[` vs `test` semantic for
1449        // execbuiltin's funcid (BIN_BRACKET=21 vs BIN_TEST=20). Without
1450        // this, bin_test's `if func == BIN_BRACKET` arm (which pops
1451        // the trailing `]`) never fired for `[` calls, so the `]`
1452        // leaked into evalcond as a positional and silently changed
1453        // the result. Bug surfaced via test_test_dashdash_unknown_condition.
1454        let name = if args.last().map(|s| s.as_str()) == Some("]") {
1455            "["
1456        } else {
1457            "test"
1458        };
1459        let status = dispatch_builtin(name, args);
1460        Value::Status(status)
1461    });
1462
1463    // Variable declaration. `local` (Src/builtin.c bin_local) handles
1464    // the scope chain (`pm->old = oldpm` at Src/params.c:1137 inside
1465    // createparam, `pm->level = locallevel` at Src/builtin.c:2576).
1466    // `typeset` / `declare` are aliases — fusevm maps both to
1467    // BUILTIN_TYPESET; compile_zsh special-cases `declare` to keep
1468    // the `declare:` error prefix.
1469    reg_passthru!(vm, BUILTIN_LOCAL, "local");
1470    reg_passthru!(vm, BUILTIN_TYPESET, "typeset");
1471
1472    reg_passthru!(vm, BUILTIN_DECLARE, "declare");
1473    reg_passthru!(vm, BUILTIN_READONLY, "readonly");
1474    reg_passthru!(vm, BUILTIN_INTEGER, "integer");
1475    reg_passthru!(vm, BUILTIN_FLOAT, "float");
1476    reg_passthru!(vm, BUILTIN_READ, "read");
1477    // c:Bug #504 — fusevm reserves BUILTIN_MAPFILE for the bash
1478    // mapfile/readarray builtins. Neither exists in zsh; in --zsh
1479    // parity mode the dispatch must emit "command not found" + rc=127
1480    // matching zsh's external-command-lookup miss. The previous wiring
1481    // left BUILTIN_MAPFILE unregistered, so fusevm's VM treated the op
1482    // as a no-op rc=0 — `mapfile` (and `readarray`) silently succeeded
1483    // in --zsh mode. The host gate in `dispatch_builtin_raw` never
1484    // fired because the compile path emitted `Op::CallBuiltin(31, ..)`
1485    // directly. Register the slot so the gate runs (or a future
1486    // non-zsh mode can wire in a real impl).
1487    vm.register_builtin(fusevm::shell_builtins::BUILTIN_MAPFILE, |vm, argc| {
1488        let args = pop_args(vm, argc);
1489        // The fusevm name→id map collapses both `mapfile` and
1490        // `readarray` to the same opcode; pick the right diagnostic
1491        // by sniffing the user's actual invocation. The xtrace ARGS
1492        // push earlier records the cmd-prefix as the bottom of the
1493        // popped argv, but `args` here excludes the prefix — so we
1494        // can't recover the user-typed name from the stack. Default
1495        // to `mapfile` (the more-common spelling); both produce
1496        // identical diagnostics in any case.
1497        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1498            eprintln!("zsh:1: command not found: mapfile");
1499            let _ = args;
1500            return Value::Status(127);
1501        }
1502        // Non-zsh modes (bash drop-in): mapfile / readarray reads lines
1503        // from stdin (or `-u fd`) into an array. Handled by the ported
1504        // bash builtin in ext_builtins.
1505        Value::Status(crate::extensions::ext_builtins::readarray(&args))
1506    });
1507    reg_passthru!(vm, BUILTIN_BREAK, "break");
1508    reg_passthru!(vm, BUILTIN_CONTINUE, "continue");
1509    reg_passthru!(vm, BUILTIN_SHIFT, "shift");
1510
1511    vm.register_builtin(BUILTIN_EVAL, |vm, argc| {
1512        // Direct port of `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))` body from Src/builtin.c:6151:
1513        //   `if (!*argv) return 0;`
1514        //   `prog = parse_string(zjoin(argv, ' ', 1), 1);`
1515        //   `execode(prog, 1, 0, "eval");`
1516        // The execode invocation lives here (not in the canonical
1517        // free-fn) because it must run through the bytecode VM's
1518        // current executor — the same VM that's mid-dispatch.
1519        let mut args = pop_args(vm, argc);
1520        // c:Src/builtin.c:407-411 — generic `--` end-of-options
1521        // strip applied by `execbuiltin` for builtins that have
1522        // NULL optstr AND no BINF_HANDLES_OPTS. `eval` qualifies
1523        // (Src/builtin.c:65 `BUILTIN("eval", BINF_PSPECIAL, ...,
1524        // NULL, NULL)`). The BUILTIN_EVAL fast-path bypasses
1525        // execbuiltin, so we mirror the strip inline. Bug #319.
1526        if args.first().is_some_and(|s| s == "--") {
1527            args.remove(0);
1528        }
1529        if args.is_empty() {
1530            return Value::Status(0); // c:6160
1531        }
1532        let src = args.join(" "); // c:6166
1533                                  // c:Src/builtin.c:6164-6165 — `if (!ineval) scriptname =
1534                                  // "(eval)";`. Diagnostics emitted while the eval body runs
1535                                  // (command-not-found, parse errors, etc.) use scriptname as
1536                                  // the source-context prefix. Without setting it here the
1537                                  // BUILTIN_EVAL fast-path leaked the outer "zsh" prefix
1538                                  // through, breaking the `(eval):N:` convention zsh uses
1539                                  // for in-eval errors. Bug #420.
1540        // c:Src/builtin.c:6209 — `execode(prog, 1, 0, "eval");`. execode
1541        // (c:Src/exec.c:1245-1266) APPENDS its context argument to
1542        // `zsh_eval_context` for the duration of the body, so code inside
1543        // `eval` sees `cmdarg:eval` (and `cmdarg:shfunc:eval` when the eval is
1544        // in a function). zshrs pushed "shfunc" and, since #1065, "cmdsubst",
1545        // but never "eval". Popped on every return path by the guard, matching
1546        // execode's stack discipline. Bug #1065 (eval leg).
1547        let sync_eval_ctx = |stack: &[String]| {
1548            let joined = stack.join(":");
1549            if let Ok(mut tab) = crate::ported::params::paramtab().write() {
1550                if let Some(pm) = tab.get_mut("zsh_eval_context") {
1551                    pm.u_arr = Some(stack.to_vec());
1552                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
1553                }
1554                if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
1555                    pm.u_str = Some(joined);
1556                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
1557                }
1558            }
1559        };
1560        if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
1561            ctx.push("eval".to_string());
1562            sync_eval_ctx(&ctx);
1563        }
1564        struct EvalCtxGuard<F: Fn(&[String])>(F);
1565        impl<F: Fn(&[String])> Drop for EvalCtxGuard<F> {
1566            fn drop(&mut self) {
1567                if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
1568                    ctx.pop();
1569                    (self.0)(&ctx);
1570                }
1571            }
1572        }
1573        let _eval_ctx_guard = EvalCtxGuard(sync_eval_ctx);
1574        // c:Src/builtin.c:6163-6178 — `eval` pushes a funcstack frame named
1575        // "(eval)" (tp = FS_EVAL), gated on `ineval = !isset(EVALLINENO)` /
1576        // `if (!ineval)` — i.e. pushed when EVAL_LINENO is SET, which is the
1577        // zsh default. zshrs already set `scriptname = "(eval)"` (below) but
1578        // never pushed the frame, so `eval '…${#funcstack}'` reported 0 where
1579        // zsh reports 1, and inside a function `${(j:,:)funcstack}` was `f`
1580        // rather than `(eval),f`. Both shells already agreed under
1581        // `unsetopt evallineno` (no frame), so the option gate is load-bearing
1582        // and is mirrored here. Popped on every return path by the guard.
1583        // Bug #1066.
1584        let eval_pushed_frame = crate::ported::zsh_h::isset(crate::ported::zsh_h::EVALLINENO);
1585        if eval_pushed_frame {
1586            let caller = {
1587                let stk = crate::ported::modules::parameter::FUNCSTACK
1588                    .lock()
1589                    .unwrap_or_else(|e| e.into_inner());
1590                stk.last()
1591                    .map(|f| f.name.clone()) // c:6167 funcstack->name
1592                    .or_else(|| crate::ported::utils::argzero()) // c:6167 argzero
1593            };
1594            let frame = crate::ported::zsh_h::funcstack {
1595                prev: None, // c:6166 (Vec-stack: index encodes link)
1596                name: "(eval)".to_string(), // c:6166 fstack.name = scriptname
1597                filename: None,
1598                caller,
1599                flineno: 0,
1600                lineno: 0,                             // c:6169
1601                tp: crate::ported::zsh_h::FS_EVAL,     // c:6170
1602            };
1603            crate::ported::modules::parameter::FUNCSTACK
1604                .lock()
1605                .unwrap_or_else(|e| e.into_inner())
1606                .push(frame); // c:6178 funcstack = &fstack
1607        }
1608        struct EvalFuncstackGuard(bool);
1609        impl Drop for EvalFuncstackGuard {
1610            fn drop(&mut self) {
1611                if self.0 {
1612                    crate::ported::modules::parameter::FUNCSTACK
1613                        .lock()
1614                        .unwrap_or_else(|e| e.into_inner())
1615                        .pop();
1616                }
1617            }
1618        }
1619        let _eval_fs_guard = EvalFuncstackGuard(eval_pushed_frame);
1620        let oscriptname = crate::ported::utils::scriptname_get();
1621        crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
1622        // Recursion backstop — c:Src/jobs.c:1878-1884. zsh caps eval recursion
1623        // via its job table (every eval'd pipeline grabs a job slot; the table
1624        // caps at MAX_MAXJOBS → "job table full or recursion limit exceeded").
1625        // The fusevm runtime allocates no job per pipeline, and nested evals
1626        // push no funcstack frame (INEVAL suppression, c:6164), so eval nesting
1627        // is invisible to both the job table AND FUNCNEST/FUNCSTACK — runaway
1628        // `eval`-string recursion overflowed the 256 MB main-thread stack →
1629        // uncatchable SIGBUS. Track eval re-entry depth (the Rust proxy for
1630        // held job slots) and refuse at the same MAX_MAXJOBS ceiling.
1631        let eval_depth = crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| {
1632            let v = d.get() + 1;
1633            d.set(v);
1634            v
1635        });
1636        let mut status = if eval_depth >= crate::ported::jobs::MAX_MAXJOBS {
1637            crate::ported::utils::zerr("job table full or recursion limit exceeded");
1638            1
1639        } else {
1640            with_executor(|exec| {
1641                // c:6175 execode
1642                exec.execute_script(&src).unwrap_or(1)
1643            })
1644        };
1645        crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
1646        // c:Src/builtin.c:6211-6212 — `if (errflag && !lastval)
1647        //   lastval = errflag;`
1648        // c:Src/builtin.c:6221 — `errflag &= ~ERRFLAG_ERROR;`
1649        // eval is a CONTAINMENT boundary: an error inside the eval
1650        // body (readonly reassign, bad assoc set, ${unset?msg}, …)
1651        // breaks the eval body's lists via errflag, then eval clears
1652        // the flag and returns lastval, and the CALLER's next list
1653        // runs. zsh 5.9: `eval 'assoc=(odd)'; echo "after $?"`
1654        // prints `after 1` in -c, script, and stdin contexts.
1655        {
1656            use std::sync::atomic::Ordering;
1657            let ef = crate::ported::utils::errflag.load(Ordering::Relaxed)
1658                & crate::ported::zsh_h::ERRFLAG_ERROR;
1659            if ef != 0 && status == 0 {
1660                status = ef; // c:6212 lastval = errflag
1661            }
1662            crate::ported::utils::errflag
1663                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
1664        }
1665        crate::ported::utils::set_scriptname(oscriptname);
1666        Value::Status(status)
1667    });
1668
1669    // `builtin foo args…`: precmd-modifier that forces builtin dispatch,
1670    // bypassing alias AND function lookup. Without this, `builtin cd /`
1671    // inside a user `cd () { … }` wrapper recurses (real-world ZPWR pattern).
1672    // Handler pops argc args from the stack, treats args[0] as the builtin
1673    // name, and dispatches the rest via `dispatch_builtin` → `execbuiltin`
1674    // → `bin_*` directly. No function/alias lookup happens.
1675    vm.register_builtin(BUILTIN_BUILTIN, |vm, argc| {
1676        let args = pop_args(vm, argc);
1677        let Some((name, rest)) = args.split_first() else {
1678            // `builtin` with no args → list builtins (zsh emits nothing,
1679            // exit 0). Match that behavior; the BIN_BUILTIN bin_* in C
1680            // does the same default-list-nothing.
1681            return Value::Status(0);
1682        };
1683        // zshrs extension builtins (daemon z* family: zd, zcache, zjob,
1684        // …) are dispatched by name via try_dispatch instead of living
1685        // in builtintab — but they ARE builtins, so the `builtin`
1686        // precommand must reach them (`builtin zd ping` errored
1687        // "no such builtin: zd" while bare `zd ping` worked).
1688        if crate::daemon::builtins::is_zshrs_builtin(name) {
1689            let argv: Vec<String> =
1690                std::iter::once(name.to_string()).chain(rest.iter().cloned()).collect();
1691            return Value::Status(
1692                crate::daemon::builtins::try_dispatch(name, &argv).unwrap_or(1),
1693            );
1694        }
1695        // c:Src/exec.c:3435-3436 — `builtin NAME` with NAME not in
1696        // builtintab emits `zwarn("no such builtin: %s", cmdarg)`
1697        // and returns 1. zshrs's dispatch_builtin_raw bare-returned 1
1698        // silently. Probe the table here so the diagnostic fires
1699        // before dispatch.
1700        let tab = crate::ported::builtin::createbuiltintable();
1701        if !tab.contains_key(name.as_str()) {
1702            // zshrs-original opcode builtins (async, doctor, peach, …) aren't
1703            // in builtintab; `builtin NAME` must still reach them.
1704            if let Some(status) = try_run_registered_builtin(name, rest) {
1705                return Value::Status(status);
1706            }
1707            // c:Src/exec.c:3436 — `zwarn("no such builtin: %s", cmdarg);`.
1708            // Route through the ported `zwarn` rather than formatting the
1709            // prefix by hand: zwarn emits zsh's `zsh:LINE:` prefix, and the
1710            // hand-rolled `eprintln!` here printed `zshrs:1:` instead. Of the
1711            // twelve error shapes probed this was the ONLY one carrying the
1712            // wrong prefix — the ported twin at exec.rs:9878 already used
1713            // zwarn correctly, so this was a reimplementation shadowing a
1714            // faithful port (same shape as #1027 / #1031 / #1044 / #1050).
1715            // Bug #1063.
1716            crate::ported::utils::zwarn(&format!("no such builtin: {}", name));
1717            return Value::Status(1);
1718        }
1719        // `builtin foo` MUST bypass function shadow — that's the whole
1720        // point of the prefix. Use the _raw helper, not the shadow-aware
1721        // one. Without this, `cd () { builtin cd "$@"; }` recurses.
1722        Value::Status(dispatch_builtin_raw(name, rest.to_vec()))
1723    });
1724
1725    // `command foo args…` — BINF_COMMAND prefix (Src/builtin.c:44). Zsh
1726    // semantic: bypass alias+function lookup, search builtin then $PATH.
1727    // Without this, `cd () { command cd "$@" }` would re-invoke the user
1728    // wrapper (same root cause as the `builtin` bug). Flags `-p`/`-v`/`-V`
1729    // route to bin_whence with BIN_COMMAND funcid; bare `command foo`
1730    // dispatches builtin if present, else external (no fork — direct
1731    // spawn via execute_external since zshrs is non-forking).
1732    // BUILTIN_COMMAND — `command [-p] [-v|-V] cmd args…` BIN_PREFIX
1733    // (Src/builtin.c:45). PURE PASSTHRU: prepend "command" and hand
1734    // to `exec::execcmd_compile_head` (the fusevm-bytecode-time head
1735    // resolver mirroring `Src/exec.c::execcmd_exec` precommand-modifier
1736    // walk at c:3104-3187). That helper already does the -p / -v / -V
1737    // option parsing, surfaces `has_command_vv` for the whence
1738    // redirect, and reports the dispatch shape (is_builtin vs external).
1739    vm.register_builtin(BUILTIN_COMMAND, |vm, argc| {
1740        let args = pop_args(vm, argc);
1741        let mut full = Vec::with_capacity(args.len() + 1);
1742        full.push("command".to_string());
1743        full.extend(args.clone());
1744        let dispatch =
1745            crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
1746        let post = &full[dispatch.precmd_skip..];
1747        // c:Src/builtin.c:4500 — `command -p` resets PATH for the
1748        // exec to the POSIX-defined default (`getconf PATH`), so
1749        // standard utilities resolve even when the caller has
1750        // emptied $PATH. zsh restores the original PATH after the
1751        // command returns. Mirror via a scoped env::set_var.
1752        //
1753        // command's OWN options end at the first non-flag arg —
1754        // everything after the command name belongs to IT. The
1755        // previous `.any()` scan over ALL args stole `-p` from
1756        // `command mkdir -p DIR` (zconvey.plugin.zsh:44), stripping
1757        // the flag before /bin/mkdir ran → "File exists" errors on
1758        // every re-source.
1759        let mut lead = 0usize;
1760        let mut dash_p = false;
1761        let mut kept_flags: Vec<String> = Vec::new();
1762        for a in post.iter() {
1763            let s = a.as_str();
1764            if s == "--" {
1765                lead += 1;
1766                break;
1767            }
1768            if s.starts_with('-')
1769                && s.len() >= 2
1770                && s[1..].chars().all(|c| c == 'p' || c == 'v' || c == 'V')
1771            {
1772                if s.contains('p') {
1773                    dash_p = true;
1774                }
1775                // -v / -V drive the whence-style lookup downstream —
1776                // keep them in post (only the PATH-reset `p` is
1777                // consumed here).
1778                let rest: String = s[1..].chars().filter(|c| *c != 'p').collect();
1779                if !rest.is_empty() {
1780                    kept_flags.push(format!("-{}", rest));
1781                }
1782                lead += 1;
1783                continue;
1784            }
1785            break;
1786        }
1787        let mut post: Vec<String> = {
1788            let mut v = kept_flags;
1789            v.extend(post[lead..].iter().cloned());
1790            v
1791        };
1792        // c:Src/exec.c:3176-3177 — `BINF_COMMAND` arm strips a single
1793        // leading `--` end-of-options marker.
1794        // `execcmd_compile_head` (src/ported/exec.rs:1042) performs
1795        // this removal on its LOCAL `preargs` Vec but doesn't surface
1796        // the modified args; the caller still sees `--` in `full` and
1797        // tried to dispatch it as the command name. Bug #251. Mirror
1798        // the C strip here so `command -- echo hi` and
1799        // `command -p -- echo hi` route correctly.
1800        if let Some(first) = post.first() {
1801            if first == "--" {
1802                post.remove(0);
1803            }
1804        }
1805        let post = post.as_slice();
1806        let _path_guard = if dash_p {
1807            let saved = env::var("PATH").ok();
1808            let default_path = std::process::Command::new("getconf")
1809                .arg("PATH")
1810                .output()
1811                .ok()
1812                .and_then(|o| String::from_utf8(o.stdout).ok())
1813                .map(|s| s.trim().to_string())
1814                .filter(|s| !s.is_empty())
1815                .unwrap_or_else(|| "/usr/bin:/bin:/usr/sbin:/sbin".to_string());
1816            env::set_var("PATH", &default_path);
1817            crate::ported::params::setsparam("PATH", &default_path);
1818            Some(saved)
1819        } else {
1820            None
1821        };
1822        struct PathGuard {
1823            saved: Option<String>,
1824            active: bool,
1825        }
1826        impl Drop for PathGuard {
1827            fn drop(&mut self) {
1828                if !self.active {
1829                    return;
1830                }
1831                match self.saved.take() {
1832                    Some(p) => {
1833                        env::set_var("PATH", &p);
1834                        crate::ported::params::setsparam("PATH", &p);
1835                    }
1836                    None => {
1837                        env::remove_var("PATH");
1838                        crate::ported::params::setsparam("PATH", "");
1839                    }
1840                }
1841            }
1842        }
1843        let _restore = PathGuard {
1844            saved: _path_guard.unwrap_or(None),
1845            active: dash_p,
1846        };
1847        if dispatch.has_command_vv {
1848            // `-v` / `-V` → bin_whence with BIN_COMMAND funcid.
1849            let mut ops = options {
1850                ind: [0u8; MAX_OPS],
1851                args: Vec::new(),
1852                argscount: 0,
1853                argsalloc: 0,
1854            };
1855            let mut name_pos = 0usize;
1856            let mut flag_byte = b'v';
1857            for (i, a) in post.iter().enumerate() {
1858                if a.starts_with('-') && a.len() >= 2 {
1859                    let body = &a.as_bytes()[1..];
1860                    if body.contains(&b'V') {
1861                        flag_byte = b'V';
1862                    }
1863                    name_pos = i + 1;
1864                } else {
1865                    name_pos = i;
1866                    break;
1867                }
1868            }
1869            ops.ind[flag_byte as usize] = 1;
1870            let whence_args: Vec<String> = post[name_pos..].to_vec();
1871            return Value::Status(crate::ported::builtin::bin_whence(
1872                "command",
1873                &whence_args,
1874                &ops,
1875                crate::ported::hashtable_h::BIN_COMMAND,
1876            ));
1877        }
1878        if dispatch.is_empty_command {
1879            return Value::Status(0);
1880        }
1881        let Some((name, rest)) = post.split_first() else {
1882            return Value::Status(0);
1883        };
1884        // c:Src/exec.c:3275-3278 — `execcmd_compile_head` cleared
1885        // hn for the BINF_COMMAND + !POSIXBUILTINS case, surfacing
1886        // is_builtin=false. Run as external. Under POSIXBUILTINS
1887        // dispatch.is_builtin would be true; honour it.
1888        let n = name.clone();
1889        let r = rest.to_vec();
1890        if dispatch.is_builtin
1891            && crate::ported::builtin::BUILTINS
1892                .iter()
1893                .any(|b| b.node.nam == n.as_str())
1894        {
1895            return Value::Status(dispatch_builtin_raw(&n, r));
1896        }
1897        Value::Status(with_executor(|exec| exec.execute_external(&n, &r, &[])).unwrap_or(127))
1898    });
1899
1900    // `exec cmd args…` — BINF_EXEC prefix (Src/builtin.c:45). Zsh
1901    // semantic: replace the current shell process with `cmd`. On Unix
1902    // this is `execvp(2)`; the call only returns on error. zshrs is
1903    // non-forking, so the shell process IS the calling process —
1904    // execvp here directly replaces it. Options `-a name` (override
1905    // argv[0]), `-c` (clean env), `-l` (login shell — prepend `-`)
1906    // ported minimally; advanced redirect-only `exec >file` is handled
1907    // upstream by compile_zsh and never reaches this handler.
1908    vm.register_builtin(BUILTIN_EXEC, |vm, argc| {
1909        let mut args = pop_args(vm, argc);
1910        let mut argv0_override: Option<String> = None;
1911        let mut clean_env = false;
1912        let mut login = false;
1913        let mut i = 0;
1914        // c:Src/builtin.c:1075-1080 — track if any flag was consumed.
1915        // `exec -c`, `exec -l`, `exec -a NAME` without a following
1916        // command emit "exec requires a command to execute" rc=1.
1917        // Bare `exec` (no args at all) is the silent-redirect-apply
1918        // form per POSIX.
1919        let mut saw_flag = false;
1920        while i < args.len() {
1921            let a = &args[i];
1922            if a == "--" {
1923                args.remove(i);
1924                break;
1925            }
1926            // c:Src/builtin.c:42 `BIN_PREFIX("-", BINF_DASH)`. A bare
1927            // `-` is its own BINF_PREFIX builtin (BINF_DASH flag —
1928            // "login shell, prepend `-` to argv[0]"). In the canonical
1929            // precmd-walk at Src/exec.c:3056-3091 a bare `-` after
1930            // `exec` is recognized AS a builtin and stripped from
1931            // preargs (precmd_skip++), accumulating BINF_DASH into
1932            // cflags. The fast-path here bypasses execcmd_compile_head,
1933            // so we mirror the strip locally: bare `-` → set login,
1934            // remove, continue. Without this `exec -` (with no command
1935            // following) tried to exec `-` as a literal command and
1936            // exited the shell. Bug #252.
1937            if a == "-" {
1938                saw_flag = true;
1939                login = true;
1940                args.remove(i);
1941                continue;
1942            }
1943            if !a.starts_with('-') || a.len() < 2 {
1944                break;
1945            }
1946            match a.as_str() {
1947                "-a" => {
1948                    saw_flag = true;
1949                    args.remove(i);
1950                    if i < args.len() {
1951                        argv0_override = Some(args.remove(i));
1952                    }
1953                }
1954                "-c" => {
1955                    saw_flag = true;
1956                    clean_env = true;
1957                    args.remove(i);
1958                }
1959                "-l" => {
1960                    saw_flag = true;
1961                    login = true;
1962                    args.remove(i);
1963                }
1964                _ => {
1965                    // c:Src/exec.c:3196-3208 — when an unrecognized
1966                    // `-X`-style arg has NO following arg, the lexer's
1967                    // IS_DASH walk hits the "no next node" branch at
1968                    // c:3199 before the unknown-flag-letter switch at
1969                    // c:3249, so the canonical message is "exec
1970                    // requires a command to execute" rc=1 (verified vs
1971                    // `/opt/homebrew/bin/zsh -fc 'exec --bad'`).
1972                    // Consume the lone flag so the post-loop check
1973                    // fires. When a following arg exists, leave the
1974                    // unknown-flag arg in place — that arg becomes
1975                    // the command name and execution proceeds.
1976                    if args.len() == 1 {
1977                        saw_flag = true;
1978                        args.remove(i);
1979                        continue;
1980                    }
1981                    break;
1982                }
1983            }
1984        }
1985        let Some(cmd) = args.first().cloned() else {
1986            if saw_flag {
1987                // c:Src/builtin.c:1078-1080 — flags consumed but no
1988                // command follows → "exec requires a command to
1989                // execute" rc=1.
1990                eprintln!("zshrs:1: exec requires a command to execute");
1991                return Value::Status(1);
1992            }
1993            // `exec` with no command + no redirects = no-op success.
1994            return Value::Status(0);
1995        };
1996        let rest: Vec<String> = args[1..].to_vec();
1997        let display_argv0 = match argv0_override {
1998            Some(a) => a,
1999            None => {
2000                if login {
2001                    format!("-{}", cmd)
2002                } else {
2003                    cmd.clone()
2004                }
2005            }
2006        };
2007        // c:Src/exec.c::execcmd — `exec funcname` runs the function
2008        // in-process as the shell's last act, then exits with the
2009        // function's status. zsh's dispatcher falls through from the
2010        // BINF_EXEC prefix into the normal Builtin/External/Function
2011        // resolution and only execvp's if the target ISN'T a
2012        // function. Bug #101 in docs/BUGS.md: zshrs's exec went
2013        // straight to execvp and errored `not found` for shell
2014        // functions.
2015        //
2016        // For both subshell and top-level contexts: dispatch through
2017        // the function/builtin lookup first; only fall through to
2018        // execvp/spawn if the name isn't shell-resolvable.
2019        let has_user_fn = with_executor(|exec| exec.functions_compiled.contains_key(&cmd));
2020        if has_user_fn {
2021            let status =
2022                with_executor(|exec| exec.dispatch_function_call(&cmd, &rest).unwrap_or(127));
2023            // Top-level `exec funcname` — exit the shell with the
2024            // function's status (mirrors C's "exec replaces shell as
2025            // last act"). Subshell `(exec funcname)` — return through
2026            // the EXIT_PENDING path so the subshell body aborts and
2027            // the parent resumes via subshell_end.
2028            let in_subshell_now = with_executor(|exec| !exec.subshell_snapshots.is_empty());
2029            if in_subshell_now {
2030                crate::ported::builtin::EXIT_VAL
2031                    .store(status, std::sync::atomic::Ordering::Relaxed);
2032                crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
2033                return Value::Status(status);
2034            }
2035            std::process::exit(status);
2036        }
2037        // c:Src/exec.c — builtin path: `exec builtin` runs the
2038        // builtin in-process and exits.
2039        let bn_in_tab = crate::ported::builtin::createbuiltintable().contains_key(&cmd);
2040        if bn_in_tab {
2041            let status = dispatch_builtin_raw(&cmd, rest.clone());
2042            let in_subshell_now = with_executor(|exec| !exec.subshell_snapshots.is_empty());
2043            if in_subshell_now {
2044                crate::ported::builtin::EXIT_VAL
2045                    .store(status, std::sync::atomic::Ordering::Relaxed);
2046                crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
2047                return Value::Status(status);
2048            }
2049            std::process::exit(status);
2050        }
2051        // c:Src/exec.c — `exec` inside a subshell (`(exec cmd)`)
2052        // replaces ONLY the subshell child process; the parent shell
2053        // continues. C zsh always forks for `(...)`, so the actual
2054        // execvp lands in the forked child. zshrs runs subshells via
2055        // a snapshot/restore pattern in the SAME process — calling
2056        // execvp here would replace the parent too. Bug #94 in
2057        // docs/BUGS.md.
2058        //
2059        // Detect subshell context via the non-empty
2060        // `subshell_snapshots` stack. When in a subshell: spawn the
2061        // command as a child, wait for it, then signal the subshell
2062        // body to abort (return Status(N) and the caller's
2063        // subshell_end will pop the snapshot and resume the parent).
2064        let in_subshell = with_executor(|exec| !exec.subshell_snapshots.is_empty());
2065        if in_subshell {
2066            let mut command = std::process::Command::new(&cmd);
2067            command.arg0(&display_argv0);
2068            command.args(&rest);
2069            if clean_env {
2070                command.env_clear();
2071            }
2072            // Queue signals across spawn+wait so the SIGCHLD reaper
2073            // can't reap this child before child.wait() does — see
2074            // ForegroundWaitGuard.
2075            let _wait_guard = ForegroundWaitGuard::enter();
2076            let status = match command.spawn() {
2077                Ok(mut child) => match child.wait() {
2078                    Ok(s) => s.code().unwrap_or(127),
2079                    Err(_) => 127,
2080                },
2081                Err(e) => {
2082                    // c:Src/exec.c:797 — `zerr("%e: %s", lerrno, arg0)`
2083                    //                     when arg0 contains `/`.
2084                    // c:872-876 — when arg0 has no `/` (PATH search
2085                    //              path), C tracks the "good" errno
2086                    //              via `isgooderr`; if all PATH entries
2087                    //              were ENOENT-not-good, eno stays 0
2088                    //              and C emits `command not found: %s`
2089                    //              instead of strerror.
2090                    // %e expands to strerror(errno) with the first
2091                    // letter lowercased (unless errno == EIO; see
2092                    // Src/utils.c:362-368). `zerr` prepends the
2093                    // scriptname:lineno: prefix — matching zsh's
2094                    // canonical `zsh:N: <errmsg>: <cmd>` pattern.
2095                    // Previously emitted `zshrs: exec: {}: not found`
2096                    // (wrong prefix, hardcoded message, missing
2097                    // lineno). Bug #140 in docs/BUGS.md.
2098                    let errno = e.raw_os_error().unwrap_or(libc::ENOENT);
2099                    let has_slash = cmd.contains('/');
2100                    if !has_slash && errno == libc::ENOENT {
2101                        // c:876 — PATH search exhausted with no good
2102                        // errno → `command not found: arg0`.
2103                        crate::ported::utils::zerr(&format!("command not found: {}", cmd));
2104                    } else {
2105                        let mut errmsg = crate::ported::compat::strerror(errno);
2106                        if errno != libc::EIO {
2107                            if let Some(c) = errmsg.chars().next() {
2108                                errmsg = format!(
2109                                    "{}{}",
2110                                    c.to_ascii_lowercase(),
2111                                    &errmsg[c.len_utf8()..]
2112                                );
2113                            }
2114                        }
2115                        crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
2116                    }
2117                    // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
2118                    if errno == libc::EACCES || errno == libc::ENOEXEC {
2119                        126
2120                    } else {
2121                        127
2122                    }
2123                }
2124            };
2125            // Mark the subshell as exec-replaced so subsequent body
2126            // commands skip — mirrors the post-execvp "child process
2127            // is gone" reality in C. EXIT_PENDING + EXIT_VAL drive
2128            // the next ERREXIT_CHECK to unwind to the subshell-end
2129            // patch.
2130            crate::ported::builtin::EXIT_VAL.store(status, std::sync::atomic::Ordering::Relaxed);
2131            crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
2132            return Value::Status(status);
2133        }
2134        let mut command = std::process::Command::new(&cmd);
2135        command.arg0(&display_argv0);
2136        command.args(&rest);
2137        if clean_env {
2138            command.env_clear();
2139        }
2140        use std::os::unix::process::CommandExt;
2141        // `exec` returns the OS error iff exec(2) failed; on success
2142        // it never returns. Match zsh: print the error to stderr with
2143        // the `exec` prefix and exit 127 (cmd not found) or 126 (not
2144        // executable).
2145        let err = command.exec();
2146        // c:Src/exec.c:797 / c:872-876 — same format as in-subshell
2147        // branch. arg0-has-/ → `<strerror>: <cmd>`; arg0-no-/ +
2148        // ENOENT → `command not found: <cmd>`. Lowercase strerror
2149        // first letter unless EIO. Bug #140 in docs/BUGS.md.
2150        let errno = err.raw_os_error().unwrap_or(libc::ENOENT);
2151        let has_slash = cmd.contains('/');
2152        if !has_slash && errno == libc::ENOENT {
2153            crate::ported::utils::zerr(&format!("command not found: {}", cmd));
2154        } else {
2155            let mut errmsg = crate::ported::compat::strerror(errno);
2156            if errno != libc::EIO {
2157                if let Some(c) = errmsg.chars().next() {
2158                    errmsg = format!("{}{}", c.to_ascii_lowercase(), &errmsg[c.len_utf8()..]);
2159                }
2160            }
2161            crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
2162        }
2163        // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
2164        let code = if errno == libc::EACCES || errno == libc::ENOEXEC {
2165            126
2166        } else {
2167            127
2168        };
2169        std::process::exit(code);
2170    });
2171
2172    reg_passthru!(vm, BUILTIN_LET, "let");
2173
2174    // Job control
2175    reg_passthru!(vm, BUILTIN_JOBS, "jobs");
2176    reg_passthru!(vm, BUILTIN_FG, "fg");
2177    reg_passthru!(vm, BUILTIN_BG, "bg");
2178    reg_passthru!(vm, BUILTIN_KILL, "kill");
2179    reg_passthru!(vm, BUILTIN_DISOWN, "disown");
2180    reg_passthru!(vm, BUILTIN_WAIT, "wait");
2181    reg_passthru!(vm, BUILTIN_SUSPEND, "suspend");
2182
2183    // History — `fc` / `history` / `r` all route to `bin_fc` (zsh
2184    // registers them as aliases of the same builtin per Src/builtin.c).
2185    reg_passthru!(vm, BUILTIN_FC, "fc");
2186    reg_passthru!(vm, BUILTIN_HISTORY, "history");
2187    reg_passthru!(vm, BUILTIN_R, "r");
2188
2189    // Aliases — alias is `BINF_MAGICEQUALS` per Src/builtin.c:50.
2190    // c:Src/exec.c:3298-3304 — when a builtin has BINF_MAGICEQUALS,
2191    // execcmd_exec sets esprefork = PREFORK_TYPESET and calls
2192    // `prefork(args, esprefork, NULL)` on the argv. prefork (subst.c:
2193    // 100) drives `filesub` on each word (c:133), which (c:677-686)
2194    // looks for the assignment Equals and runs `filesubstr` on the
2195    // VALUE side. That's how `alias bad===` triggers equalsubstr's
2196    // "= not found" via the inner Equals after the first `=`.
2197    //
2198    // The fusevm dispatch path doesn't go through execcmd_exec, so
2199    // BUILTIN_ALIAS previously passed args straight to bin_alias with
2200    // no expansion — `alias x=~/foo` stored literal `~/foo` (no tilde
2201    // expand), `alias bad===` stored a broken entry without firing
2202    // the "= not found" diagnostic. The prefork(PREFORK_TYPESET) runs
2203    // per arg word via BUILTIN_MAGIC_EQUALS_PREFORK ops that
2204    // compile_simple emits BEFORE the redirect scope opens (matching
2205    // c:3304 prefork-before-addfd order), so the dispatch here is a
2206    // plain passthrough — re-running prefork would double-fire the
2207    // "= not found" diagnostic.
2208    reg_passthru!(vm, BUILTIN_ALIAS, "alias");
2209    // c:Src/exec.c:3298-3304 — per-word magic-equals prefork; see the
2210    // const doc at BUILTIN_MAGIC_EQUALS_PREFORK. prefork's filesub
2211    // trigger (subst.c:678 `strchr(*namptr+1, Equals)`) looks for the
2212    // EQUALS TOKEN, not literal `=`. The fusevm path delivers args
2213    // already-untokenized, so re-tokenize each element via
2214    // `shtokenize` (the same call C's lexer makes implicitly when
2215    // assembling the word) so prefork sees Equals tokens at `=`
2216    // boundaries and Tilde tokens at `~` starts. After prefork
2217    // expands, untokenize for storage.
2218    vm.register_builtin(BUILTIN_MAGIC_EQUALS_PREFORK, |vm, _argc| {
2219        let raw = vm.pop();
2220        let inputs: Vec<String> = match raw {
2221            Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
2222            other => vec![other.to_str()],
2223        };
2224        let mut as_linklist: crate::ported::linklist::LinkList<String> = Default::default();
2225        for s in &inputs {
2226            let mut tokd = s.clone();
2227            crate::ported::glob::shtokenize(&mut tokd);
2228            as_linklist.push_back(tokd);
2229        }
2230        let mut rf = 0i32;
2231        crate::ported::subst::prefork(
2232            &mut as_linklist,
2233            crate::ported::zsh_h::PREFORK_TYPESET,
2234            &mut rf,
2235        );
2236        let mut expanded: Vec<String> = Vec::with_capacity(inputs.len());
2237        while let Some(s) = as_linklist.pop_front() {
2238            expanded.push(crate::ported::lex::untokenize(&s).to_string());
2239        }
2240        if expanded.len() == 1 {
2241            Value::str(expanded.into_iter().next().unwrap())
2242        } else {
2243            Value::Array(expanded.into_iter().map(Value::str).collect())
2244        }
2245    });
2246
2247    // Options. `setopt` (BIN_SETOPT=0) / `unsetopt` (BIN_UNSETOPT=1)
2248    // share bin_setopt (options.c:580) — funcid bit discriminates
2249    // the polarity via BUILTINS table entries.
2250    reg_passthru!(vm, BUILTIN_SET, "set");
2251    reg_passthru!(vm, BUILTIN_SETOPT, "setopt");
2252    reg_passthru!(vm, BUILTIN_UNSETOPT, "unsetopt");
2253
2254    vm.register_builtin(BUILTIN_SHOPT, |vm, argc| {
2255        let args = pop_args(vm, argc);
2256        let status = crate::extensions::ext_builtins::shopt(&args);
2257        Value::Status(status)
2258    });
2259
2260    reg_passthru!(vm, BUILTIN_EMULATE, "emulate");
2261    reg_passthru!(vm, BUILTIN_GETOPTS, "getopts");
2262    reg_passthru!(vm, BUILTIN_AUTOLOAD, "autoload");
2263    reg_passthru!(vm, BUILTIN_FUNCTIONS, "functions");
2264    reg_passthru!(vm, BUILTIN_TRAP, "trap");
2265    reg_passthru!(vm, BUILTIN_DIRS, "dirs");
2266    // pushd / popd dispatch through canonical bin_cd via execbuiltin
2267    // — the BUILTINS table at src/ported/builtin.rs:9298 wires
2268    // `pushd` to bin_cd with funcid=BIN_PUSHD, and `popd` similarly
2269    // with BIN_POPD. Without these reg_passthru lines the fusevm
2270    // BUILTIN_PUSHD/POPD opcodes had no handler installed, so the
2271    // emitted CallBuiltin(110, …) silently returned a no-op and the
2272    // dirstack/$dirstack/pwd all stayed unchanged.
2273    reg_passthru!(vm, BUILTIN_PUSHD, "pushd");
2274    reg_passthru!(vm, BUILTIN_POPD, "popd");
2275    // type / whence / where / which all route through `bin_whence`
2276    // (canonical port at `src/ported/builtin.rs:3734` of
2277    // `Src/builtin.c:3975`). Each gets its own opcode so funcid +
2278    // defopts come from the BUILTINS table entry — execbuiltin
2279    // applies them correctly via the module-level dispatch_builtin.
2280    reg_passthru!(vm, BUILTIN_WHENCE, "whence");
2281    reg_passthru!(vm, BUILTIN_TYPE, "type");
2282    reg_passthru!(vm, BUILTIN_WHICH, "which");
2283    reg_passthru!(vm, BUILTIN_WHERE, "where");
2284    reg_passthru!(vm, BUILTIN_HASH, "hash");
2285    reg_passthru!(vm, BUILTIN_REHASH, "rehash");
2286
2287    // `unhash`/`unalias`/`unfunction` share `bin_unhash` (Src/builtin.c
2288    // c:4350) but each carries its own funcid (BIN_UNHASH /
2289    // BIN_UNALIAS / BIN_UNFUNCTION) in the BUILTINS table.
2290    reg_passthru!(vm, BUILTIN_UNHASH, "unhash");
2291    vm.register_builtin(BUILTIN_UNALIAS, |vm, argc| {
2292        let args = pop_args(vm, argc);
2293        Value::Status(dispatch_builtin("unalias", args))
2294    });
2295    vm.register_builtin(BUILTIN_UNFUNCTION, |vm, argc| {
2296        let args = pop_args(vm, argc);
2297        Value::Status(dispatch_builtin("unfunction", args))
2298    });
2299
2300    // Completion
2301    vm.register_builtin(BUILTIN_COMPGEN, |vm, argc| {
2302        let args = pop_args(vm, argc);
2303        // c:Bug #475/#555 — `compgen` is a bash-only builtin. In
2304        // `--zsh` mode emit "command not found" matching zsh's
2305        // external-command lookup miss — UNLESS a user FUNCTION of
2306        // that name exists: zsh has no such builtin, so bashcompinit's
2307        // `compgen() {...}` definition wins the dispatch there. The
2308        // unconditional 127 shadowed it and broke every
2309        // bashcompinit-style completion file (zsh-more-completions
2310        // _msync/_gocomplete/_qshell/_cw), spraying "command not
2311        // found: complete" at every deferred compinit load.
2312        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2313            if crate::ported::utils::getshfunc("compgen").is_some() {
2314                let status = with_executor(|exec| exec.dispatch_function_call("compgen", &args))
2315                    .unwrap_or(127);
2316                return Value::Status(status);
2317            }
2318            eprintln!("zsh:1: command not found: compgen");
2319            let _ = args;
2320            return Value::Status(127);
2321        }
2322        let status = with_executor(|exec| exec.builtin_compgen(&args));
2323        Value::Status(status)
2324    });
2325
2326    vm.register_builtin(BUILTIN_COMPLETE, |vm, argc| {
2327        let args = pop_args(vm, argc);
2328        // c:Bug #475 — `complete` is a bash-only builtin. Same gate +
2329        // user-function precedence as BUILTIN_COMPGEN above.
2330        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2331            if crate::ported::utils::getshfunc("complete").is_some() {
2332                let status = with_executor(|exec| exec.dispatch_function_call("complete", &args))
2333                    .unwrap_or(127);
2334                return Value::Status(status);
2335            }
2336            eprintln!("zsh:1: command not found: complete");
2337            let _ = args;
2338            return Value::Status(127);
2339        }
2340        let status = with_executor(|exec| exec.builtin_complete(&args));
2341        Value::Status(status)
2342    });
2343
2344    reg_passthru!(vm, BUILTIN_COMPADD, "compadd");
2345    reg_passthru!(vm, BUILTIN_COMPSET, "compset");
2346
2347    // See the const's doc comment for the contract. Stack (bottom→top):
2348    // base, e1, …, eN — argc = N + 1.
2349    vm.register_builtin(BUILTIN_TYPESET_PAREN_PACK, |vm, argc| {
2350        let mut vals: Vec<Value> = Vec::with_capacity(argc as usize);
2351        for _ in 0..argc {
2352            vals.push(vm.pop());
2353        }
2354        vals.reverse();
2355        let mut it = vals.into_iter();
2356        let mut out = it.next().map(|v| v.to_str()).unwrap_or_default();
2357        for v in it {
2358            match v {
2359                // Array → splice items as separate elements (splat);
2360                // empty array contributes nothing (empty elision).
2361                Value::Array(items) => {
2362                    for item in items {
2363                        out.push('\u{1f}');
2364                        out.push_str(&item.to_str());
2365                    }
2366                }
2367                other => {
2368                    out.push('\u{1f}');
2369                    out.push_str(&other.to_str());
2370                }
2371            }
2372        }
2373        Value::str(out)
2374    });
2375
2376    vm.register_builtin(BUILTIN_TYPESET_PAREN_CLOSE, |vm, _argc| {
2377        let base = vm.pop().to_str();
2378        Value::str(format!("{}\u{1f})", base))
2379    });
2380
2381    vm.register_builtin(BUILTIN_COMPDEF, |vm, argc| {
2382        let args = pop_args(vm, argc);
2383        // ACTUALLY A ZSH FUNCTION: compdef is defined by `compinit`, it is
2384        // never a builtin. Without the completion system set up it is
2385        // command-not-found (127) in every mode — `zsh -f; compdef` prints
2386        // "command not found: compdef". A user/compsys `compdef` FUNCTION
2387        // (autoload compinit → compinit defines compdef) wins and runs the
2388        // fast native impl; otherwise it's command-not-found. Previously the
2389        // extension builtin ran in native mode (bare `compdef` → "I need
2390        // arguments"), diverging from zsh.
2391        // compinit installs a `compdef` function stub (see
2392        // NATIVE_COMPDEF_MARKER) purely so `${+functions[compdef]}` is
2393        // true; route that exact body to the fast native impl instead of
2394        // dispatching the stub. A genuine user/compsys compdef function
2395        // (any other body) still wins via try_user_fn_override below.
2396        let is_native_stub = crate::ported::hashtable::shfunctab_lock()
2397            .read()
2398            .ok()
2399            .and_then(|t| t.get("compdef").and_then(|shf| shf.body.clone()))
2400            .map(|b| b.trim() == crate::extensions::ext_builtins::NATIVE_COMPDEF_MARKER)
2401            .unwrap_or(false);
2402        if is_native_stub {
2403            return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
2404        }
2405        if let Some(s) = try_user_fn_override("compdef", &args) {
2406            return Value::Status(s);
2407        }
2408        if with_executor(|exec| exec.function_exists("compdef")) {
2409            return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
2410        }
2411        eprintln!("zsh:1: command not found: compdef");
2412        Value::Status(127)
2413    });
2414
2415    vm.register_builtin(BUILTIN_COMPINIT, |vm, argc| {
2416        let args = pop_args(vm, argc);
2417        // ACTUALLY A ZSH FUNCTION: compinit is a contrib FUNCTION (autoloaded
2418        // from $fpath), never a builtin. Without `autoload -Uz compinit` it is
2419        // command-not-found
2420        // (127) in every mode — `zsh -f; compinit` prints
2421        // "command not found: compinit". zshrs previously ran its builtin
2422        // unconditionally, so bare `compinit` succeeded. Gate on a compinit
2423        // function entry existing (which `autoload -Uz compinit` creates);
2424        // once the user has autoloaded/defined it, run zshrs's implementation.
2425        if !with_executor(|exec| exec.function_exists("compinit")) {
2426            eprintln!("zsh:1: command not found: compinit");
2427            let _ = args;
2428            return Value::Status(127);
2429        }
2430        Value::Status(with_executor(|exec| exec.builtin_compinit(&args)))
2431    });
2432
2433    reg_ext_overridable!(vm, BUILTIN_CDREPLAY, "cdreplay", builtin_cdreplay);
2434
2435    // Zsh-specific
2436    reg_passthru!(vm, BUILTIN_ZSTYLE, "zstyle");
2437    reg_passthru!(vm, BUILTIN_ZMODLOAD, "zmodload");
2438    reg_passthru!(vm, BUILTIN_BINDKEY, "bindkey");
2439    reg_passthru!(vm, BUILTIN_ZLE, "zle");
2440    reg_passthru!(vm, BUILTIN_VARED, "vared");
2441    reg_passthru!(vm, BUILTIN_ZCOMPILE, "zcompile");
2442    reg_passthru!(vm, BUILTIN_ZFORMAT, "zformat");
2443    reg_passthru!(vm, BUILTIN_ZPARSEOPTS, "zparseopts");
2444    reg_passthru!(vm, BUILTIN_ZREGEXPARSE, "zregexparse");
2445
2446    // Resource limits
2447    reg_passthru!(vm, BUILTIN_ULIMIT, "ulimit");
2448    reg_passthru!(vm, BUILTIN_LIMIT, "limit");
2449    reg_passthru!(vm, BUILTIN_UNLIMIT, "unlimit");
2450    reg_passthru!(vm, BUILTIN_UMASK, "umask");
2451
2452    // Misc
2453    reg_passthru!(vm, BUILTIN_TIMES, "times");
2454
2455    vm.register_builtin(BUILTIN_CALLER, |vm, argc| {
2456        let args = pop_args(vm, argc);
2457        // c:Bug #475 — `caller` is a bash-only builtin. In `--zsh`
2458        // mode emit the canonical "command not found" diagnostic
2459        // and rc=127 matching zsh's external-command-lookup miss.
2460        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2461            eprintln!("zsh:1: command not found: caller");
2462            let _ = args;
2463            return Value::Status(127);
2464        }
2465        Value::Status(with_executor(|exec| exec.builtin_caller(&args)))
2466    });
2467
2468    vm.register_builtin(BUILTIN_HELP, |vm, argc| {
2469        let args = pop_args(vm, argc);
2470        // c:Bug #475 — `help` is a bash-only builtin. Same gate as
2471        // BUILTIN_CALLER above.
2472        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2473            eprintln!("zsh:1: command not found: help");
2474            let _ = args;
2475            return Value::Status(127);
2476        }
2477        Value::Status(with_executor(|exec| exec.builtin_help(&args)))
2478    });
2479
2480    reg_passthru!(vm, BUILTIN_ENABLE, "enable");
2481    reg_passthru!(vm, BUILTIN_DISABLE, "disable");
2482    reg_passthru!(vm, BUILTIN_TTYCTL, "ttyctl");
2483    reg_passthru!(vm, BUILTIN_SYNC, "sync");
2484    reg_passthru!(vm, BUILTIN_MKDIR, "mkdir");
2485    reg_passthru!(vm, BUILTIN_STRFTIME, "strftime");
2486
2487    vm.register_builtin(BUILTIN_ZSLEEP, |vm, argc| {
2488        let args = pop_args(vm, argc);
2489        // function > builtin: a user `zsleep() { … }` wins.
2490        if let Some(s) = try_user_fn_override("zsleep", &args) {
2491            return Value::Status(s);
2492        }
2493        Value::Status(crate::extensions::ext_builtins::zsleep(&args))
2494    });
2495
2496    reg_passthru!(vm, BUILTIN_ZSYSTEM, "zsystem");
2497
2498    // PCRE
2499    reg_passthru!(vm, BUILTIN_PCRE_COMPILE, "pcre_compile");
2500    reg_passthru!(vm, BUILTIN_PCRE_MATCH, "pcre_match");
2501    reg_passthru!(vm, BUILTIN_PCRE_STUDY, "pcre_study");
2502
2503    // Database (GDBM)
2504    reg_passthru!(vm, BUILTIN_ZTIE, "ztie");
2505    reg_passthru!(vm, BUILTIN_ZUNTIE, "zuntie");
2506    reg_passthru!(vm, BUILTIN_ZGDBMPATH, "zgdbmpath");
2507
2508    // Prompt
2509    vm.register_builtin(BUILTIN_PROMPTINIT, |vm, argc| {
2510        let args = pop_args(vm, argc);
2511        // ACTUALLY A ZSH FUNCTION: promptinit is a contrib FUNCTION
2512        // (autoloaded from $fpath), never a builtin. Command-not-found until
2513        // `autoload -Uz promptinit`; once autoloaded, run the native impl.
2514        if !with_executor(|exec| exec.function_exists("promptinit")) {
2515            eprintln!("zsh:1: command not found: promptinit");
2516            let _ = args;
2517            return Value::Status(127);
2518        }
2519        Value::Status(crate::extensions::ext_builtins::promptinit(&args))
2520    });
2521
2522    vm.register_builtin(BUILTIN_PROMPT, |vm, argc| {
2523        let args = pop_args(vm, argc);
2524        Value::Status(crate::extensions::ext_builtins::prompt(&args))
2525    });
2526
2527    // Async / Parallel (zshrs extensions) — all overridable by a
2528    // same-named user function (function > builtin).
2529    reg_ext_overridable!(vm, BUILTIN_ASYNC, "async", builtin_async);
2530    reg_ext_overridable!(vm, BUILTIN_AWAIT, "await", builtin_await);
2531    reg_ext_overridable!(vm, BUILTIN_PMAP, "pmap", builtin_pmap);
2532    reg_ext_overridable!(vm, BUILTIN_PGREP, "pgrep", builtin_pgrep);
2533    reg_ext_overridable!(vm, BUILTIN_PEACH, "peach", builtin_peach);
2534    reg_ext_overridable!(vm, BUILTIN_BARRIER, "barrier", builtin_barrier);
2535
2536    // Intercept (AOP)
2537    reg_ext_overridable!(vm, BUILTIN_INTERCEPT, "intercept", builtin_intercept);
2538    reg_ext_overridable!(
2539        vm,
2540        BUILTIN_INTERCEPT_PROCEED,
2541        "intercept_proceed",
2542        builtin_intercept_proceed
2543    );
2544
2545    // Debug / Profile
2546    reg_ext_overridable!(vm, BUILTIN_DOCTOR, "doctor", builtin_doctor);
2547    reg_ext_overridable!(vm, BUILTIN_DBVIEW, "dbview", builtin_dbview);
2548    reg_ext_overridable!(vm, BUILTIN_PROFILE, "profile", builtin_profile);
2549
2550    reg_passthru!(vm, BUILTIN_ZPROF, "zprof");
2551
2552    // ═══════════════════════════════════════════════════════════════════════
2553    // Coreutils builtins (anti-fork, gated by !posix_mode)
2554    //
2555    // All of these are routinely wrapped by user functions in real
2556    // dotfiles (zpwr, oh-my-zsh, etc.) — `cat() { ... }`, `ls() { ... }`,
2557    // `find() { ... }`. Each handler MUST consult try_user_fn_override
2558    // first (via reg_overridable!) so the user definition wins, matching
2559    // zsh's alias → function → builtin dispatch order.
2560    // ═══════════════════════════════════════════════════════════════════════
2561
2562    reg_overridable!(vm, BUILTIN_CAT, "cat", builtin_cat);
2563    reg_overridable!(vm, BUILTIN_HEAD, "head", builtin_head);
2564    reg_overridable!(vm, BUILTIN_TAIL, "tail", builtin_tail);
2565    reg_overridable!(vm, BUILTIN_WC, "wc", builtin_wc);
2566    reg_overridable!(vm, BUILTIN_BASENAME, "basename", builtin_basename);
2567    reg_overridable!(vm, BUILTIN_DIRNAME, "dirname", builtin_dirname);
2568    reg_overridable!(vm, BUILTIN_TOUCH, "touch", builtin_touch);
2569    reg_overridable!(vm, BUILTIN_REALPATH, "realpath", builtin_realpath);
2570    reg_overridable!(vm, BUILTIN_SORT, "sort", builtin_sort);
2571    reg_overridable!(vm, BUILTIN_FIND, "find", builtin_find);
2572    reg_overridable!(vm, BUILTIN_UNIQ, "uniq", builtin_uniq);
2573    reg_overridable!(vm, BUILTIN_CUT, "cut", builtin_cut);
2574    reg_overridable!(vm, BUILTIN_TR, "tr", builtin_tr);
2575    reg_overridable!(vm, BUILTIN_SEQ, "seq", builtin_seq);
2576    reg_overridable!(vm, BUILTIN_REV, "rev", builtin_rev);
2577    reg_overridable!(vm, BUILTIN_TEE, "tee", builtin_tee);
2578    reg_overridable!(vm, BUILTIN_SLEEP, "sleep", builtin_sleep);
2579    reg_overridable!(vm, BUILTIN_WHOAMI, "whoami", builtin_whoami);
2580    reg_overridable!(vm, BUILTIN_ID, "id", builtin_id);
2581
2582    reg_overridable!(vm, BUILTIN_HOSTNAME, "hostname", builtin_hostname);
2583    reg_overridable!(vm, BUILTIN_UNAME, "uname", builtin_uname);
2584    reg_overridable!(vm, BUILTIN_DATE, "date", builtin_date);
2585    reg_overridable!(vm, BUILTIN_MKTEMP, "mktemp", builtin_mktemp);
2586    // `cp` — zshrs extension (NOT in upstream zsh; upstream's
2587    // zsh/files module ships `ln`/`mv`/`rm`/`chmod`/`chown` but no
2588    // `cp`). In-process implementation in
2589    // `ext_builtins::cp_impl` — recursive copy with -r/-R, -f, -i,
2590    // -n, -p (chown + utimensat), -v. ID 263 is the first slot
2591    // past fusevm's built-in range (260-262) and before BUILTIN_MAX
2592    // (280).
2593    /// `BUILTIN_CP` constant.
2594    pub const BUILTIN_CP: u16 = 263;
2595    reg_overridable!(vm, BUILTIN_CP, "cp", builtin_cp);
2596
2597    // Pipeline execution — bytecode-native fork-per-stage. Pops N sub-chunk
2598    // indices, forks N children with stdin/stdout wired through N-1 pipes,
2599    // each child runs its stage's compiled bytecode and exits. Parent waits
2600    // and returns the last stage's status.
2601    //
2602    // Caveats: post-fork in a multi-threaded program, only async-signal-safe
2603    // ops are POSIX-safe. We violate this (running the bytecode VM after fork
2604    // touches mutexes like REGEX_CACHE). In practice, most pipeline stages
2605    // don't touch shared mutex state — externals fork/exec away, builtins do
2606    // pure I/O. Risks are bounded; if a stage does touch a held mutex, the
2607    // child deadlocks.
2608    vm.register_builtin(BUILTIN_RUN_PIPELINE, |vm, argc| {
2609        let n = argc as usize;
2610        if n == 0 {
2611            return Value::Status(0);
2612        }
2613
2614        // c:Src/exec.c — every pipeline stage forks from the current
2615        // shell state, so each stage observes the pre-pipeline $? until
2616        // it runs its own command. Stage sub-VMs start fresh with
2617        // last_status=0, so seed them with the parent's lastval; without
2618        // this `false; echo $? | cat` prints 0 instead of zsh's 1.
2619        let parent_status = vm.last_status;
2620
2621        // Pop N sub-chunk indices (LIFO → reverse to stage order)
2622        let mut indices: Vec<u16> = Vec::with_capacity(n);
2623        for _ in 0..n {
2624            indices.push(vm.pop().to_int() as u16);
2625        }
2626        indices.reverse();
2627
2628        // Clone each stage's sub-chunk
2629        let stages: Vec<fusevm::Chunk> = indices
2630            .iter()
2631            .filter_map(|&i| vm.chunk.sub_chunks.get(i as usize).cloned())
2632            .collect();
2633        if stages.len() != n {
2634            return Value::Status(1);
2635        }
2636
2637        // Single stage — no pipe, just run inline
2638        if n == 1 {
2639            let stage = stages.into_iter().next().unwrap();
2640            crate::fusevm_disasm::maybe_print_stdout("pipeline:single", &stage);
2641            let mut stage_vm = fusevm::VM::new(stage);
2642            stage_vm.last_status = parent_status;
2643            register_builtins(&mut stage_vm);
2644            let _ = stage_vm.run();
2645            return Value::Status(stage_vm.last_status);
2646        }
2647
2648        // Build N-1 pipes
2649        let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(n - 1);
2650        for _ in 0..n - 1 {
2651            let mut fds = [0i32; 2];
2652            if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
2653                // Cleanup any pipes we already created
2654                for (r, w) in &pipes {
2655                    unsafe {
2656                        libc::close(*r);
2657                        libc::close(*w);
2658                    }
2659                }
2660                return Value::Status(1);
2661            }
2662            pipes.push((fds[0], fds[1]));
2663        }
2664
2665        // zsh runs the LAST stage of a pipeline in the CURRENT shell
2666        // (not a forked child) so a trailing `read x` keeps its
2667        // assignment in the parent. Other shells (bash) fork every
2668        // stage. Honor zsh by leaving stage N-1 inline. Forks the
2669        // first N-1 stages with fork(); runs the last in this process
2670        // with stdin dup2'd to the last pipe's read end and stdout
2671        // restored after.
2672        let last_idx = n - 1;
2673        let stages_vec: Vec<fusevm::Chunk> = stages.into_iter().collect();
2674
2675        let mut child_pids: Vec<libc::pid_t> = Vec::with_capacity(n - 1);
2676        for (i, chunk) in stages_vec.iter().take(last_idx).enumerate() {
2677            match unsafe { libc::fork() } {
2678                -1 => {
2679                    // fork failed — kill any children we already started
2680                    for pid in &child_pids {
2681                        unsafe { libc::kill(*pid, libc::SIGTERM) };
2682                    }
2683                    for (r, w) in &pipes {
2684                        unsafe {
2685                            libc::close(*r);
2686                            libc::close(*w);
2687                        }
2688                    }
2689                    return Value::Status(1);
2690                }
2691                0 => {
2692                    // Reset SIGPIPE to default so a broken-pipe write
2693                    // kills the child cleanly instead of triggering a
2694                    // Rust println! panic. The parent shell ignores
2695                    // SIGPIPE so it can handle EPIPE itself, but child
2696                    // pipeline stages should die quietly when their
2697                    // downstream stage closes early (e.g. `seq | head -3`).
2698                    unsafe {
2699                        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
2700                    }
2701                    // c:Src/exec.c — pipeline children are forked
2702                    // subshells; their EXIT trap context is reset so
2703                    // the parent's `trap '...' EXIT` doesn't fire when
2704                    // the child exits. Mirror by dropping EXIT from
2705                    // the inherited traps_table inside the child.
2706                    if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
2707                        t.remove("EXIT");
2708                    }
2709                    // c:Src/exec.c:2862 → 1219 — pipeline children run
2710                    // entersubsh with ESUB_PGRP, which clears the job
2711                    // table (clearjobtab, Src/jobs.c:1780). Without
2712                    // this, `sleep 5 & jobs -p | wc -l` reports 1 in
2713                    // the forked stage where zsh reports 0. The fork
2714                    // already copy-isolates the statics, so mutating
2715                    // them here can't leak to the parent.
2716                    with_executor(|exec| {
2717                        let monitor =
2718                            crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
2719                        crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
2720                    });
2721                    *crate::ported::jobs::THISJOB
2722                        .get_or_init(|| std::sync::Mutex::new(-1))
2723                        .lock()
2724                        .unwrap() = -1;
2725                    // c:Src/exec.c:3720-3724 — the stage's own fds go
2726                    // onto 0/1 only AFTER its argument words have been
2727                    // expanded (prefork c:3304 / globlist c:3702), so
2728                    // park them and let the stage chunk's
2729                    // BUILTIN_PIPE_FDS_INSTALL do the dup2 at the
2730                    // C-faithful point. `print -rl -- c a b |
2731                    // print -r -- "[$(cat)]" | cat` therefore prints
2732                    // `[]` — the middle stage's `$(cat)` reads the
2733                    // shell's stdin, not the pipe.
2734                    let in_fd = if i > 0 { pipes[i - 1].0 } else { -1 };
2735                    let out_fd = pipes[i].1;
2736                    // (Pipe-output MULTIOS marking — c:Src/exec.c:3724 —
2737                    // is emitted INTO the stage chunk by compile_pipe
2738                    // via BUILTIN_PIPE_OUTPUT_MARK, gated on the stage's
2739                    // top-level command actually carrying redirects, so
2740                    // a nested `{ echo a > f; } | cat` body redirect
2741                    // does not wrongly join the pipe.)
2742                    // Close every pipe fd this stage doesn't need. The
2743                    // two it does keep are closed by the install op
2744                    // right after their dup2.
2745                    for (r, w) in &pipes {
2746                        unsafe {
2747                            if *r != in_fd && *r != out_fd {
2748                                libc::close(*r);
2749                            }
2750                            if *w != in_fd && *w != out_fd {
2751                                libc::close(*w);
2752                            }
2753                        }
2754                    }
2755                    stage_fds_park(in_fd, out_fd);
2756
2757                    // Run this stage's bytecode on a fresh VM
2758                    crate::fusevm_disasm::maybe_print_stdout(
2759                        &format!("pipeline:child:stage:{i}"),
2760                        chunk,
2761                    );
2762                    let mut stage_vm = fusevm::VM::new(chunk.clone());
2763                    stage_vm.last_status = parent_status;
2764                    register_builtins(&mut stage_vm);
2765                    let _ = stage_vm.run();
2766                    // Flush any buffered output before exiting
2767                    let _ = std::io::stdout().flush();
2768                    let _ = std::io::stderr().flush();
2769                    std::process::exit(stage_vm.last_status);
2770                }
2771                pid => {
2772                    child_pids.push(pid);
2773                }
2774            }
2775        }
2776
2777        // Parent runs the LAST stage inline. Save stdin, park the last
2778        // pipe's read end for the chunk's BUILTIN_PIPE_FDS_INSTALL
2779        // (c:Src/exec.c:3722 `addfd(..., 0, input, 0, NULL)` — after
2780        // the stage's args are expanded, so `… | print -r -- "[$(cat)]"`
2781        // has its `$(cat)` read the shell's stdin, not the pipe), run
2782        // the chunk, restore stdin. Close every other pipe fd so the
2783        // producer side gets EOF when the last upstream stage exits.
2784        // Shell-internal save — keep it out of the script's fd range (movefd,
2785        // c:Src/exec.c:2425).
2786        let saved_stdin = unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD, 10) };
2787        let last_in_fd = if last_idx > 0 {
2788            pipes[last_idx - 1].0
2789        } else {
2790            -1
2791        };
2792        // Close all pipe fds in the parent except the one the last
2793        // stage still has to install. (Children already have their own
2794        // copies; the install op closes the read end after its dup2.)
2795        for (r, w) in &pipes {
2796            unsafe {
2797                if *r != last_in_fd {
2798                    libc::close(*r);
2799                }
2800                libc::close(*w);
2801            }
2802        }
2803        let outer_stage_fds = stage_fds_park(last_in_fd, -1);
2804
2805        // Run the last stage's bytecode on a sub-VM with the host wired up.
2806        // By default (zsh semantics) the sub-VM runs IN THIS PROCESS so the
2807        // last stage's reads/assignments update the parent's state directly
2808        // (`echo x | read v` sets $v; `cmd | mapfile arr` sets arr).
2809        //
2810        // !!! BASH-MODE GATE !!! bash forks EVERY pipeline stage (unless
2811        // `shopt -s lastpipe`), so the last stage runs in a SUBSHELL and its
2812        // variable/array assignments do NOT persist — `echo x | read v; echo
2813        // $v` prints an empty line, `cmd | mapfile arr` leaves arr unset.
2814        // Fork the last stage under `--bash` to match. The parent's existing
2815        // `stage_fds_take()` below closes its `last_in_fd` copy; the forked
2816        // child inherits the parked pipe fd and installs it onto stdin, and
2817        // the writer stages (already forked) supply its input.
2818        let last_stage_status = if crate::dash_mode::bash_mode() {
2819            let last_chunk = stages_vec.into_iter().last().unwrap();
2820            crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
2821            match unsafe { libc::fork() } {
2822                -1 => 1,
2823                0 => {
2824                    // Subshell child: run the last stage, then _exit with its
2825                    // status. Reset SIGPIPE + drop the EXIT trap like the
2826                    // other pipeline children above.
2827                    unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) };
2828                    if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
2829                        t.remove("EXIT");
2830                    }
2831                    let mut stage_vm = fusevm::VM::new(last_chunk);
2832                    stage_vm.last_status = parent_status;
2833                    register_builtins(&mut stage_vm);
2834                    stage_vm.set_shell_host(Box::new(ZshrsHost));
2835                    let _ = stage_vm.run();
2836                    let st = stage_vm.last_status;
2837                    let _ = std::io::stdout().flush();
2838                    let _ = std::io::stderr().flush();
2839                    unsafe { libc::_exit(st) };
2840                }
2841                pid => {
2842                    let mut status: i32 = 0;
2843                    unsafe { libc::waitpid(pid, &mut status, 0) };
2844                    if libc::WIFEXITED(status) {
2845                        libc::WEXITSTATUS(status)
2846                    } else if libc::WIFSIGNALED(status) {
2847                        128 + libc::WTERMSIG(status)
2848                    } else {
2849                        1
2850                    }
2851                }
2852            }
2853        } else {
2854            let last_chunk = stages_vec.into_iter().last().unwrap();
2855            crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
2856            let mut stage_vm = fusevm::VM::new(last_chunk);
2857            stage_vm.last_status = parent_status;
2858            register_builtins(&mut stage_vm);
2859            stage_vm.set_shell_host(Box::new(ZshrsHost));
2860            let _ = stage_vm.run();
2861            let _ = std::io::stdout().flush();
2862            let _ = std::io::stderr().flush();
2863            stage_vm.last_status
2864        };
2865
2866        // Reclaim the read end if the stage chunk never reached its
2867        // install op (an expansion error aborted it, or the stage was
2868        // a shape that dispatches without one), then restore the outer
2869        // stage's still-pending fds for a nested pipeline.
2870        let (leftover_in, _) = stage_fds_take();
2871        if leftover_in >= 0 {
2872            unsafe { libc::close(leftover_in) };
2873        }
2874        stage_fds_park(outer_stage_fds.0, outer_stage_fds.1);
2875
2876        // Restore stdin
2877        if saved_stdin >= 0 {
2878            unsafe {
2879                libc::dup2(saved_stdin, libc::STDIN_FILENO);
2880                libc::close(saved_stdin);
2881            }
2882        }
2883
2884        // Wait for all forked stages, capture per-stage statuses for PIPESTATUS.
2885        let mut pipestatus: Vec<i32> = Vec::with_capacity(n);
2886        for pid in child_pids {
2887            let mut status: i32 = 0;
2888            unsafe {
2889                libc::waitpid(pid, &mut status, 0);
2890            }
2891            let s = if libc::WIFEXITED(status) {
2892                libc::WEXITSTATUS(status)
2893            } else if libc::WIFSIGNALED(status) {
2894                128 + libc::WTERMSIG(status)
2895            } else {
2896                1
2897            };
2898            pipestatus.push(s);
2899        }
2900        // Append the in-parent last-stage status so `pipestatus` ends
2901        // with N entries (one per stage).
2902        pipestatus.push(last_stage_status);
2903        // Pipeline exit status: by default, the LAST stage's status.
2904        // With `setopt pipefail` (or `set -o pipefail`), use the
2905        // first non-zero stage status (so failures earlier in the
2906        // pipeline propagate even if the last stage succeeded).
2907        let pipefail_on = with_executor(|exec| opt_state_get("pipefail").unwrap_or(false));
2908        let last_status = if pipefail_on {
2909            pipestatus
2910                .iter()
2911                .copied()
2912                .rfind(|&s| s != 0)
2913                .or_else(|| pipestatus.last().copied())
2914                .unwrap_or(0)
2915        } else {
2916            *pipestatus.last().unwrap_or(&0)
2917        };
2918
2919        // c:Src/params.c:265,438 — only `pipestatus` (lowercase) is the
2920        // zsh special parameter; bash's `PIPESTATUS` doesn't exist in
2921        // zsh's special-params table. Prior port also populated
2922        // `PIPESTATUS` "for portability" — but that's a real divergence
2923        // from zsh: a script doing `[[ -z $PIPESTATUS ]]` to detect
2924        // zsh-vs-bash would mis-classify. Bug #64 in docs/BUGS.md.
2925        with_executor(|exec| {
2926            let strs: Vec<String> = pipestatus.iter().map(|s| s.to_string()).collect();
2927            exec.set_array("pipestatus".to_string(), strs);
2928        });
2929
2930        Value::Status(last_status)
2931    });
2932
2933    // Array→String join. Pops one value; if it's an Array (e.g. from Op::Glob),
2934    // joins string-coerced elements with a single space. Pass-through for
2935    // non-arrays so the op is safe to chain after any String-or-Array producer.
2936    // Scalar coercion of an assembled word: pop a Value; if it's an
2937    // Array (produced by a splice segment like `"$@"` / `"${arr[@]}"`),
2938    // IFS[0]-join it to a single scalar; a scalar passes through. This
2939    // is the assignment-context coercion C zsh applies in multsub when
2940    // the expansion is the RHS of a SCALAR assignment (Src/subst.c
2941    // c:3032 sepjoin under ssub) — `v="$@"` joins the positionals with
2942    // ${IFS[1]} rather than leaving an array whose splat would lose all
2943    // but the first element. Joins via sepjoin so a custom / empty IFS
2944    // is honored (not a hardcoded space).
2945    vm.register_builtin(BUILTIN_ARRAY_JOIN, |vm, _argc| {
2946        let val = vm.pop();
2947        match val {
2948            Value::Array(items) => {
2949                let strs: Vec<String> = items.iter().map(|v| v.to_str()).collect();
2950                Value::str(crate::ported::utils::sepjoin(&strs, None))
2951            }
2952            other => other,
2953        }
2954    });
2955
2956    // `cmd &` background execution. Compile_list emits this for any item
2957    // followed by ListOp::Amp: the job text + the cmd's sub-chunk index are
2958    // pushed, then this builtin pops both, looks up the chunk, forks. The
2959    // child detaches via setsid (so SIGINT to the foreground job doesn't kill
2960    // it), runs the bytecode on a fresh VM with builtins re-registered, exits
2961    // with the last status. The parent registers the job in the canonical
2962    // JOBTAB (c:Src/exec.c::execpline Z_ASYNC arm) and returns Status(0).
2963    vm.register_builtin(BUILTIN_RUN_BG, |vm, _argc| {
2964        // `&|` / `&!` set disown → the job is dropped from the table (no
2965        // `[N] pid` announcement, no `[N] done`), matching C exec.c:1752-1758.
2966        let disown = vm.pop().to_int() != 0;
2967        let sub_idx = vm.pop().to_int() as usize;
2968        let job_text = vm.pop().to_str();
2969        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
2970            Some(c) => c,
2971            None => return Value::Status(1),
2972        };
2973
2974        match unsafe { libc::fork() } {
2975            -1 => Value::Status(1),
2976            0 => {
2977                // Child: detach and run.
2978                unsafe { libc::setsid() };
2979                crate::fusevm_disasm::maybe_print_stdout("background_job", &chunk);
2980                let mut bg_vm = fusevm::VM::new(chunk);
2981                register_builtins(&mut bg_vm);
2982                let _ = bg_vm.run();
2983                let _ = std::io::stdout().flush();
2984                let _ = std::io::stderr().flush();
2985                std::process::exit(bg_vm.last_status);
2986            }
2987            pid => {
2988                // Parent: record the PID into `$!` (most recent
2989                // backgrounded job's pid). zsh exposes this for any
2990                // script that needs `wait $!`. Also register the
2991                // bare-pid job so a no-args `wait` can synchronize.
2992                // c:Src/jobs.c:73 — `lastpid = pid;` after a
2993                // background fork. zshrs's `$!` getter
2994                // (params.rs::lookup_special_var "!") reads from
2995                // the same atomic, so a single store here is the
2996                // canonical writer.
2997                crate::ported::modules::clone::lastpid
2998                    .store(pid, std::sync::atomic::Ordering::Relaxed);
2999                // c:Src/exec.c:1700 — `thisjob = newjob = initjob()`:
3000                // allocate the canonical jobtab slot. c:Src/exec.c:2950
3001                // zfork path → addproc(pid, text, 0, &bgtime, ...) hangs
3002                // the proc entry (with its display text) off the job.
3003                // c:Src/exec.c:1744-1746 — `clearoldjobtab();
3004                // jobtab[thisjob].stat |= STAT_NOSTTY;` then c:1758
3005                // `spawnjob()` promotes it to curjob (top-level shell
3006                // only), marks STAT_LOCKED and resets thisjob.
3007                {
3008                    use crate::ported::jobs;
3009                    use std::sync::Mutex;
3010                    let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
3011                    let idx = {
3012                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
3013                        let idx = jobs::initjob(&mut tab); // c:exec.c:1700
3014                        jobs::addproc(
3015                            &mut tab[idx],
3016                            pid,
3017                            &job_text,
3018                            false,
3019                            Some(std::time::Instant::now()),
3020                            -1,
3021                            -1,
3022                        ); // c:exec.c:2950 addproc
3023                        tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
3024                        idx
3025                    };
3026                    jobs::clearoldjobtab(); // c:exec.c:1744
3027                    if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
3028                        *tj = idx as i32;
3029                    }
3030                    if disown {
3031                        // c:exec.c:1752-1755 — `pipecleanfilelist(...);
3032                        // deletejob(jobtab + thisjob, 1); thisjob = -1;` — a
3033                        // disowned job leaves the table entirely, so neither
3034                        // spawnjob's `[N] pid` nor the later `[N] done` prints.
3035                        // This is what keeps zinit-turbo's `… &|` completion
3036                        // jobs silent (they load inside a `zle -F` handler).
3037                        {
3038                            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
3039                            jobs::pipecleanfilelist(&mut tab[idx], false); // c:1753
3040                            jobs::deletejob(&mut tab[idx], true); // c:1754
3041                        }
3042                        if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock()
3043                        {
3044                            *tj = -1; // c:1755
3045                        }
3046                    } else {
3047                        jobs::spawnjob(); // c:exec.c:1758
3048                    }
3049                }
3050                with_executor(|exec| {
3051                    exec.jobs
3052                        .add_pid_job(pid, job_text.clone(), JobState::Running);
3053                });
3054                Value::Status(0)
3055            }
3056        }
3057    });
3058
3059    // ── Indexed-array storage ─────────────────────────────────────────────
3060    //
3061    // Stack: pushed values then name (LAST). `arr=(a b c)` → 4 args
3062    // (a, b, c, arr). `arr=($(cmd))` → 2 args (FlatArray, arr).
3063    //
3064    // PURE PASSTHRU: pop name + values, dispatch to canonical
3065    // `setaparam` / `sethparam` (C port of `Src/params.c:3595/3602`).
3066    // assignaparam already handles PM_UNIQUE dedupe, type-flag flip,
3067    // PM_NAMEREF rejection, ASSPM_AUGMENT prepend, and createparam
3068    // for fresh names.
3069    vm.register_builtin(BUILTIN_SET_ARRAY, |vm, argc| {
3070        // `${~spec}` carrier: an assignment statement is a word-
3071        // pipeline boundary too — restore the user's GLOB_SUBST
3072        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
3073        // ${options[globsubst]}` must read the user value).
3074        consume_tilde_globsubst_carrier();
3075        let n = argc as usize;
3076        let mut popped: Vec<Value> = Vec::with_capacity(n);
3077        for _ in 0..n {
3078            popped.push(vm.pop());
3079        }
3080        popped.reverse();
3081        if popped.is_empty() {
3082            return Value::Status(1);
3083        }
3084        let name = popped.pop().unwrap().to_str();
3085        let mut values: Vec<String> = Vec::new();
3086        for v in popped {
3087            flatten_array_value(v, &mut values);
3088        }
3089        // Bash sparse: a full `a=(...)` reassign resets the array to dense
3090        // (drops any prior holes from subscript-assign / unset).
3091        if crate::dash_mode::bash_mode() {
3092            crate::bash_arrays::clear(&name);
3093        }
3094        let blocked = with_executor(|exec| {
3095            // Assoc init `typeset -A m; m=(k v k v ...)` — route to
3096            // canonical sethparam (Src/params.c:3602) which parses the
3097            // flat (k,v) pair list internally.
3098            if exec.assoc(&name).is_some() {
3099                // `[k]=v` / `[k]+=v` elements arrive from the compiler
3100                // as Marker / key / value triples (compile_zsh's port
3101                // of keyvalpairelement, c:Src/subst.c:49-79).
3102                let marker = crate::ported::zsh_h::Marker;
3103                let values = if values.iter().any(|e| e.starts_with(marker)) {
3104                    // c:Src/params.c:3544-3560 — under ASSPM_KEY_VALUE
3105                    // assocs strictly enforce `[key]=value`: every
3106                    // stride-of-3 element must be a Marker. Mixing
3107                    // plain pairs with kv triads is an error.
3108                    let mut i = 0usize;
3109                    while i < values.len() {
3110                        if !values[i].starts_with(marker) {
3111                            crate::ported::utils::zerr(
3112                                "bad [key]=value syntax for associative array",
3113                            );
3114                            crate::ported::utils::errflag.fetch_or(
3115                                crate::ported::zsh_h::ERRFLAG_ERROR,
3116                                std::sync::atomic::Ordering::Relaxed,
3117                            );
3118                            exec.set_last_status(1);
3119                            return true;
3120                        }
3121                        i += 3;
3122                    }
3123                    if values.len() % 3 != 0 {
3124                        // c:Src/params.c:4124-4131 arrhashsetfn — a
3125                        // truncated triad leaves an odd non-Marker
3126                        // count → "bad set of key/value pairs".
3127                        crate::ported::utils::zerr(
3128                            "bad set of key/value pairs for associative array",
3129                        );
3130                        crate::ported::utils::errflag.fetch_or(
3131                            crate::ported::zsh_h::ERRFLAG_ERROR,
3132                            std::sync::atomic::Ordering::Relaxed,
3133                        );
3134                        exec.set_last_status(1);
3135                        return true;
3136                    }
3137                    // c:Src/params.c:4136-4168 arrhashsetfn — whole
3138                    // assignment builds a FRESH table; a `Marker +`
3139                    // triad (`[k]+=v`) appends to the value inserted
3140                    // EARLIER IN THIS SAME LITERAL (assignstrvalue
3141                    // with eltflags=ASSPM_AUGMENT against the new ht),
3142                    // so `h=([k]=a [k]+=b)` yields "ab". Resolve the
3143                    // appends here, then hand flat pairs to sethparam.
3144                    let mut order: Vec<String> = Vec::new();
3145                    let mut map: std::collections::HashMap<String, String> =
3146                        std::collections::HashMap::new();
3147                    for ch in values.chunks(3) {
3148                        let elt_append = ch[0].chars().nth(1) == Some('+');
3149                        let k = ch[1].clone();
3150                        let v = ch[2].clone();
3151                        let nv = if elt_append {
3152                            format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
3153                        } else {
3154                            v
3155                        };
3156                        if !map.contains_key(&k) {
3157                            order.push(k.clone());
3158                        }
3159                        map.insert(k, nv);
3160                    }
3161                    order
3162                        .into_iter()
3163                        .flat_map(|k| {
3164                            let v = map.get(&k).cloned().unwrap_or_default();
3165                            [k, v]
3166                        })
3167                        .collect()
3168                } else {
3169                    values
3170                };
3171                // Odd-count rejection lives in the canonical chain:
3172                // sethparam → setarrvalue (c:3651/c:2920) →
3173                // arrhashsetfn's zerr "bad set of key/value pairs"
3174                // (Src/params.c:4128-4131). zerr sets ERRFLAG_ERROR,
3175                // which aborts the remaining list at the next command
3176                // boundary (BUILTIN_ERREXIT_CHECK trigger 4) —
3177                // matching `zsh -fc 'typeset -A m; m=(odd); print x'`
3178                // printing nothing after the error. Like C's sethparam
3179                // (c:3652-3653 returns v->pm regardless), the Rust
3180                // port returns Some on the odd-count path — the
3181                // failure travels via errflag, so check BOTH.
3182                let pre_err = crate::ported::utils::errflag
3183                    .load(std::sync::atomic::Ordering::Relaxed)
3184                    & crate::ported::zsh_h::ERRFLAG_ERROR;
3185                let res = crate::ported::params::sethparam(&name, values.clone());
3186                let now_err = crate::ported::utils::errflag
3187                    .load(std::sync::atomic::Ordering::Relaxed)
3188                    & crate::ported::zsh_h::ERRFLAG_ERROR;
3189                if res.is_none() || (pre_err == 0 && now_err != 0) {
3190                    // c:Src/exec.c:2632-2633 addvars — `if
3191                    // (!assignaparam(name, arr, myflags)) lastval = 1;`
3192                    // — failed assignment sets lastval so the errflag
3193                    // abort exits 1 (init.c loop() breaks, zsh_main
3194                    // returns lastval).
3195                    exec.set_last_status(1);
3196                    return true;
3197                }
3198                #[cfg(feature = "recorder")]
3199                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3200                    let ctx = exec.recorder_ctx();
3201                    let attrs = exec.recorder_attrs_for(&name);
3202                    let mut pairs: Vec<(String, String)> = Vec::with_capacity(values.len() / 2);
3203                    let mut iter = values.iter().cloned();
3204                    while let Some(k) = iter.next() {
3205                        if let Some(v) = iter.next() {
3206                            pairs.push((k, v));
3207                        }
3208                    }
3209                    crate::recorder::emit_assoc_assign(&name, pairs, attrs, false, ctx);
3210                }
3211                return false;
3212            }
3213            // Indexed-array: setaparam (Src/params.c:3766) wraps
3214            // assignaparam with ASSPM_WARN — handles PM_UNIQUE dedupe,
3215            // type-flag flip, PM_READONLY rejection.
3216            //
3217            // `[k]=v` elements arrive as Marker / key / value triples
3218            // (compile_zsh's keyvalpairelement port). Mirror
3219            // c:Src/exec.c:2552-2553 — `if (prefork_ret &
3220            // PREFORK_KEY_VALUE) myflags |= ASSPM_KEY_VALUE;` — so
3221            // assignaparam runs its kv-resolution block (sparse fill
3222            // for PM_ARRAY, c:3447-3541; strict-triad enforcement for
3223            // special PM_HASHED targets like `options`, c:3544-3560).
3224            let values = values;
3225            let has_kv = values
3226                .iter()
3227                .any(|e| e.starts_with(crate::ported::zsh_h::Marker));
3228            // The tied-array mirror to a PM_TIED scalar
3229            // (`typeset -T PATH path`) lives canonically in
3230            // setarrvalue's dispatch in C zsh; until that wires
3231            // through assignaparam, mirror here so PATH stays in sync
3232            // after `path=(/x)`.
3233            //
3234            // The mirrored value must be the array AS STORED, which for a
3235            // PM_UNIQUE tie means deduped. c:4066-4076 arrsetfn fixes the
3236            // order:
3237            //     if (pm->node.flags & PM_UNIQUE) uniqarray(x);
3238            //     pm->u.arr = x;
3239            //     if (pm->ename && x) arrfixenv(pm->ename, x);
3240            // — the dedupe happens FIRST, so the scalar publishes the same
3241            // list the array holds and the two halves of a tie always agree.
3242            // Mirroring the raw `values` broke exactly that:
3243            //     typeset -U path; path=(/a /b /a)
3244            //       $path → /a /b        (right)
3245            //       $PATH → /a:/b:/a     (wrong; zsh gives /a:/b)
3246            // i.e. `typeset -U path`, the standard PATH-dedup idiom in
3247            // essentially every .zshrc. assignaparam's own arrfixenv does not
3248            // rescue it: that call is gated on the param having a gsu_a wired,
3249            // and `path` has none.
3250            //
3251            // The dedupe is applied here rather than by reading the array back
3252            // after assignaparam, because this mirror must stay BEFORE it.
3253            // `exec.set_scalar` is heavier than C's arrfixenv — arrfixenv only
3254            // rewrites the environment string, while set_scalar re-derives the
3255            // ARRAY from the scalar. Running it afterwards makes `path=()`
3256            // publish PATH="" and then re-split that back into a one-element
3257            // `path=("")`, where zsh leaves 0 elements.
3258            if let Some((scalar_name, sep)) = exec.tied_array_to_scalar.get(&name).cloned() {
3259                let uniq = crate::ported::params::paramtab()
3260                    .read()
3261                    .ok()
3262                    .and_then(|t| t.get(&name).map(|p| p.node.flags))
3263                    .map(|f| (f as u32 & crate::ported::zsh_h::PM_UNIQUE) != 0)
3264                    .unwrap_or(false);
3265                let mirror = if uniq {
3266                    crate::ported::params::simple_arrayuniq(values.clone()) // c:4068
3267                } else {
3268                    values.clone()
3269                };
3270                exec.set_scalar(scalar_name, mirror.join(&sep)); // c:4074-4075
3271            }
3272            // c:Src/exec.c:2632-2633 addvars — `if (!assignaparam(...))
3273            // lastval = 1;` — a failed assignment (bad subscript, bad
3274            // [key]=value syntax, readonly) exits 1 and the errflag
3275            // abort stops the remaining list. Track errflag pre/post
3276            // like the assoc branch above.
3277            let kv_flag = if has_kv {
3278                crate::ported::zsh_h::ASSPM_KEY_VALUE
3279            } else {
3280                0
3281            };
3282            let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3283                & crate::ported::zsh_h::ERRFLAG_ERROR;
3284            let res = crate::ported::params::assignaparam(
3285                &name,
3286                values.clone(),
3287                crate::ported::zsh_h::ASSPM_WARN | kv_flag,
3288            );
3289            let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3290                & crate::ported::zsh_h::ERRFLAG_ERROR;
3291            if res.is_none() && pre_err == 0 && now_err != 0 {
3292                exec.set_last_status(1);
3293                return true;
3294            }
3295            // Bash sparse: an explicit-index array literal `a=([2]=x [5]=y)`
3296            // leaves the un-indexed slots as HOLES (bash: count 2, indices
3297            // {2,5}), not dense empties. assignaparam has already placed each
3298            // value at its 0-based index; mark every OTHER slot a hole. Gated
3299            // to a PURE indexed literal (every element a `[idx]=val` triple) —
3300            // a mixed positional/indexed literal (`a=(x [3]=y z)`) needs the
3301            // positional-counter replay we don't model, so it stays dense.
3302            if crate::dash_mode::bash_mode() && has_kv {
3303                let marker = crate::ported::zsh_h::Marker;
3304                let pure_indexed = !values.is_empty()
3305                    && values.len() % 3 == 0
3306                    && values.chunks(3).all(|ch| ch[0].starts_with(marker));
3307                if pure_indexed {
3308                    let mut explicit: std::collections::BTreeSet<usize> =
3309                        std::collections::BTreeSet::new();
3310                    for ch in values.chunks(3) {
3311                        if let Ok(i) = ch[1].trim().parse::<usize>() {
3312                            explicit.insert(i);
3313                        }
3314                    }
3315                    let len = exec.array(&name).map(|a| a.len()).unwrap_or(0);
3316                    for i in 0..len {
3317                        if !explicit.contains(&i) {
3318                            crate::bash_arrays::note_unset(&name, i);
3319                        }
3320                    }
3321                }
3322            }
3323            #[cfg(feature = "recorder")]
3324            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3325                let ctx = exec.recorder_ctx();
3326                let attrs = exec.recorder_attrs_for(&name);
3327                emit_path_or_assign(&name, &values, attrs, false, &ctx);
3328            }
3329            false
3330        });
3331        let status = if blocked { 1 } else { 0 };
3332        // c:Src/jobs.c:1748-1757 waitonejob — in C an array-assignment
3333        // simple command goes through execpline → waitjobs; with no
3334        // procs the else-branch stores `pipestats[0] = lastval;
3335        // numpipestats = 1`. Bare SCALAR assignments never create a
3336        // job (no waitjobs), so this clobber is array/assoc-assignment
3337        // specific: `false|true; x=(1 2); echo $pipestatus` → `0` in
3338        // zsh while `x=1` preserves `1 0`. Bug #373.
3339        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
3340        let mut synth = crate::ported::zsh_h::job::default();
3341        crate::ported::jobs::waitonejob(&mut synth);
3342        Value::Status(status)
3343    });
3344    // `arr+=(d e f)` — array append. Same calling conventions as SET_ARRAY.
3345    //
3346    // PURE PASSTHRU shape: pop name + values, dispatch through the
3347    // canonical assoc / array setter. assignaparam's ASSPM_AUGMENT
3348    // flag handles the C-source-equivalent "preserve prior value"
3349    // semantics; for now we read the current array, extend with
3350    // new values, write through set_array (which routes to
3351    // setaparam → assignaparam where PM_UNIQUE dedupe lands).
3352    vm.register_builtin(BUILTIN_APPEND_ARRAY, |vm, argc| {
3353        let n = argc as usize;
3354        let mut popped: Vec<Value> = Vec::with_capacity(n);
3355        for _ in 0..n {
3356            popped.push(vm.pop());
3357        }
3358        popped.reverse();
3359        if popped.is_empty() {
3360            return Value::Status(1);
3361        }
3362        let name = popped.pop().unwrap().to_str();
3363        let mut values: Vec<String> = Vec::new();
3364        for v in popped {
3365            flatten_array_value(v, &mut values);
3366        }
3367        let blocked = with_executor(|exec| -> bool {
3368            // Assoc append `m+=(k1 v1 ...)`: merge the (k,v) pairs into
3369            // the existing map and write back via canonical sethparam
3370            // (Src/params.c:3602). The canonical C path would go
3371            // assignaparam(ASSPM_AUGMENT) → arrhashsetfn(ASSPM_AUGMENT)
3372            // at Src/params.c:3850, but the zshrs port of
3373            // arrhashsetfn doesn't yet implement value-storage
3374            // (pending Param.u_hash backend wireup) — until that
3375            // lands, do the augment + write here so the storage
3376            // actually mutates.
3377            if exec.assoc(&name).is_some() {
3378                // `[k]=v` / `[k]+=v` elements arrive as Marker / key /
3379                // value triples (compile_zsh's port of
3380                // keyvalpairelement, c:Src/subst.c:49-79).
3381                let marker = crate::ported::zsh_h::Marker;
3382                let mut map = exec.assoc(&name).unwrap_or_default();
3383                if values.iter().any(|e| e.starts_with(marker)) {
3384                    // c:Src/params.c:3544-3560 — strict triad rule.
3385                    let mut i = 0usize;
3386                    while i < values.len() {
3387                        if !values[i].starts_with(marker) {
3388                            crate::ported::utils::zerr(
3389                                "bad [key]=value syntax for associative array",
3390                            );
3391                            crate::ported::utils::errflag.fetch_or(
3392                                crate::ported::zsh_h::ERRFLAG_ERROR,
3393                                std::sync::atomic::Ordering::Relaxed,
3394                            );
3395                            exec.set_last_status(1);
3396                            return true;
3397                        }
3398                        i += 3;
3399                    }
3400                    if values.len() % 3 != 0 {
3401                        // c:Src/params.c:4124-4131 — odd pair count.
3402                        crate::ported::utils::zerr(
3403                            "bad set of key/value pairs for associative array",
3404                        );
3405                        crate::ported::utils::errflag.fetch_or(
3406                            crate::ported::zsh_h::ERRFLAG_ERROR,
3407                            std::sync::atomic::Ordering::Relaxed,
3408                        );
3409                        exec.set_last_status(1);
3410                        return true;
3411                    }
3412                    // c:Src/params.c:4133-4168 arrhashsetfn with
3413                    // ASSPM_AUGMENT — ht = the EXISTING table, so
3414                    // `[k]+=v` appends to the current value
3415                    // (assignstrvalue eltflags=ASSPM_AUGMENT,
3416                    // c:4144-4150) and `[k]=v` overwrites.
3417                    for ch in values.chunks(3) {
3418                        let elt_append = ch[0].chars().nth(1) == Some('+');
3419                        let k = ch[1].clone();
3420                        let v = ch[2].clone();
3421                        let nv = if elt_append {
3422                            format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
3423                        } else {
3424                            v
3425                        };
3426                        map.insert(k, nv);
3427                    }
3428                } else {
3429                    let mut it = values.iter().cloned();
3430                    while let Some(k) = it.next() {
3431                        if let Some(v) = it.next() {
3432                            map.insert(k, v);
3433                        }
3434                    }
3435                }
3436                exec.set_assoc(name, map);
3437                return false;
3438            }
3439            // Indexed-array append `arr+=(d e f)` — route directly
3440            // through canonical assignaparam with ASSPM_AUGMENT
3441            // (`Src/params.c:3570-3585` append-on-array branch).
3442            // assignaparam reads the prior array internally and
3443            // appends the new values, so the bridge no longer needs
3444            // to pre-concat manually. Marker triples from `[k]=v`
3445            // elements add ASSPM_KEY_VALUE (c:Src/exec.c:2552-2553)
3446            // so the kv sparse-fill block (c:Src/params.c:3447-3541)
3447            // resolves them against the existing elements.
3448            let kv_flag = if values
3449                .iter()
3450                .any(|e| e.starts_with(crate::ported::zsh_h::Marker))
3451            {
3452                crate::ported::zsh_h::ASSPM_KEY_VALUE
3453            } else {
3454                0
3455            };
3456            let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3457                & crate::ported::zsh_h::ERRFLAG_ERROR;
3458            let res = crate::ported::params::assignaparam(
3459                &name,
3460                values.clone(),
3461                crate::ported::zsh_h::ASSPM_AUGMENT | kv_flag,
3462            );
3463            let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3464                & crate::ported::zsh_h::ERRFLAG_ERROR;
3465            if res.is_none() && pre_err == 0 && now_err != 0 {
3466                // c:Src/exec.c:2632-2633 — failed assignment → lastval 1.
3467                exec.set_last_status(1);
3468                return true;
3469            }
3470            #[cfg(feature = "recorder")]
3471            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3472                let ctx = exec.recorder_ctx();
3473                let attrs = exec.recorder_attrs_for(&name);
3474                emit_path_or_assign(&name, &values, attrs, true, &ctx);
3475            }
3476            // Tied-scalar mirror — TODO faithful: should live in
3477            // setarrvalue's gsu dispatch once boot_ paramtab wiring
3478            // lands (Task #16). Re-read the canonical post-augment
3479            // array so the joined scalar matches.
3480            let tied_scalar = exec.tied_array_to_scalar.get(&name).cloned();
3481            if let Some((scalar_name, sep)) = tied_scalar {
3482                let merged = exec.array(&name).unwrap_or_default();
3483                let joined = merged.join(&sep);
3484                exec.set_scalar(scalar_name.clone(), joined.clone());
3485                let _ = crate::ported::params::zputenv(&format!("{}={}", &scalar_name, &joined));
3486                // c:Src/params.c:5354
3487            }
3488            false
3489        });
3490        // c:Src/jobs.c:1748-1757 waitonejob — `arr+=(...)` is an
3491        // array-assignment simple command and clobbers pipestats to
3492        // `[lastval]` exactly like `arr=(...)` above. Bug #373.
3493        let status = if blocked { 1 } else { 0 };
3494        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
3495        let mut synth = crate::ported::zsh_h::job::default();
3496        crate::ported::jobs::waitonejob(&mut synth);
3497        Value::Status(status)
3498    });
3499    // `name[@]=(...)` / `name[*]=(...)` — whole-array SET with the assoc
3500    // guard (c:Src/params.c:3324-3327). Stack: [v0..vn, name].
3501    vm.register_builtin(BUILTIN_SET_ARRAY_AT, |vm, argc| {
3502        let (name, values) = pop_array_args_with_name(vm, argc);
3503        let status = with_executor(|exec| {
3504            if exec.assoc(&name).is_some() {
3505                // c:Src/params.c:3324-3327 — `[@]` (any slice) on a
3506                // PM_HASHED target is an error.
3507                crate::ported::utils::zerr(&format!(
3508                    "{}: attempt to set slice of associative array",
3509                    name
3510                ));
3511                crate::ported::utils::errflag.fetch_or(
3512                    crate::ported::zsh_h::ERRFLAG_ERROR,
3513                    std::sync::atomic::Ordering::Relaxed,
3514                );
3515                exec.set_last_status(1);
3516                return 1;
3517            }
3518            exec.set_array(name, values); // whole replace (c:3528 setarrvalue)
3519            0
3520        });
3521        Value::Status(status)
3522    });
3523    // `name[@]+=(...)` / `name[*]+=(...)` — whole-array APPEND (push) with
3524    // the same assoc guard.
3525    vm.register_builtin(BUILTIN_APPEND_ARRAY_AT, |vm, argc| {
3526        let (name, values) = pop_array_args_with_name(vm, argc);
3527        let status = with_executor(|exec| {
3528            if exec.assoc(&name).is_some() {
3529                crate::ported::utils::zerr(&format!(
3530                    "{}: attempt to set slice of associative array",
3531                    name
3532                ));
3533                crate::ported::utils::errflag.fetch_or(
3534                    crate::ported::zsh_h::ERRFLAG_ERROR,
3535                    std::sync::atomic::Ordering::Relaxed,
3536                );
3537                exec.set_last_status(1);
3538                return 1;
3539            }
3540            let mut cur = exec.array(&name).unwrap_or_default();
3541            cur.extend(values);
3542            exec.set_array(name, cur); // c:3511-3528 AUGMENT on array → push
3543            0
3544        });
3545        Value::Status(status)
3546    });
3547    vm.register_builtin(BUILTIN_RUN_SELECT, |vm, argc| {
3548        if argc < 2 {
3549            return Value::Status(1);
3550        }
3551        let n = argc as usize;
3552        let mut popped: Vec<Value> = Vec::with_capacity(n);
3553        for _ in 0..n {
3554            popped.push(vm.pop());
3555        }
3556        // popped: [sub_idx, name, word_N, ..., word_1] (popping from top)
3557        let sub_idx_val = popped.remove(0);
3558        let name_val = popped.remove(0);
3559        // c:Src/loop.c — `select` flattens Array values (from `$@`,
3560        // `${arr[@]}`, etc.) into the menu. Without per-element
3561        // splice, `select x do ... done` (bare, iterating $@)
3562        // collapsed all positionals into one joined entry.
3563        let mut words: Vec<String> = Vec::new();
3564        for v in popped.into_iter().rev() {
3565            match v {
3566                Value::Array(items) => {
3567                    for item in items {
3568                        words.push(item.to_str());
3569                    }
3570                }
3571                other => words.push(other.to_str()),
3572            }
3573        }
3574
3575        let sub_idx = sub_idx_val.to_int() as usize;
3576        let name = name_val.to_str();
3577
3578        // c:Src/loop.c:248-252 — `if (!args || empty(args)) {
3579        // state->pc = end; ... return 0; }`. An empty option list
3580        // skips the body entirely; without this gate the prompt loop
3581        // runs indefinitely (or twice on the EOF stdin case before
3582        // exiting). Bug #401.
3583        if words.is_empty() {
3584            return Value::Status(0);
3585        }
3586
3587        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
3588            Some(c) => c,
3589            None => return Value::Status(1),
3590        };
3591
3592        let prompt =
3593            with_executor(|exec| exec.scalar("PROMPT3").unwrap_or_else(|| "?# ".to_string()));
3594
3595        let stdin = std::io::stdin();
3596        let mut reader = stdin.lock();
3597        let mut last_status: i32 = 0;
3598
3599        // c:Src/loop.c:264 — `more = selectlist(args, 0);` renders the menu
3600        // ONCE, BEFORE the selection loop. C reprints it ONLY when the user
3601        // enters an EMPTY line (c:290, inside the inner read loop) — never per
3602        // body iteration. This was a single conflated loop that re-rendered at
3603        // the top of every pass, so `printf "1\n2\n" | select x in a b; do
3604        // print $x; done` redrew the list before each prompt where zsh prints
3605        // it once.
3606        //
3607        // (`selectlist` is also ported at src/ported/loop.rs:127, but that copy
3608        // derives its row budget from adjustlines()/adjustcolumns() — an ioctl
3609        // on fd 1 — and so renders nothing when stdout is not a tty, which is
3610        // exactly this path. Keeping the working inline render here.)
3611        let render_menu = || {
3612            // Direct port of zsh's selectlist from
3613            // src/zsh/Src/loop.c:347-409. Layout is column-major
3614            // ("down columns, then across") — NOT row-major. With
3615            // 6 items in 3 cols zsh produces:
3616            //   1  3  5
3617            //   2  4  6
3618            // The previous Rust impl walked row-major which
3619            // produced 1 2 3 / 4 5 6 (visually similar but wrong
3620            // for prompts that mention ordering and breaks scripts
3621            // that rely on column count == ceil(N/rows)).
3622            //
3623            // C variable mapping:
3624            //   ct      -> word count (n)
3625            //   longest -> max item width + 1, then plus digits-of-ct
3626            //   fct     -> column count
3627            //   fw      -> per-column width
3628            //   colsz   -> row count = ceil(ct / fct)
3629            //   t1      -> row index, walks 0..colsz
3630            //   ap      -> item pointer; advances by colsz to step
3631            //              DOWN a column.
3632            let term_width: usize = env::var("COLUMNS")
3633                .ok()
3634                .and_then(|v| v.parse().ok())
3635                .unwrap_or(80);
3636            let ct = words.len();
3637            // loop.c:354-363 — find longest item width.
3638            let mut longest = 1usize;
3639            for w in &words {
3640                let aplen = w.chars().count();
3641                if aplen > longest {
3642                    longest = aplen;
3643                }
3644            }
3645            // loop.c:365-367 — `longest++` then add digits of `ct`.
3646            longest += 1;
3647            let mut t0 = ct;
3648            while t0 > 0 {
3649                t0 /= 10;
3650                longest += 1;
3651            }
3652            // loop.c:369-373 — fct = (cols - 1) / (longest + 3); if
3653            // 0, fct = 1; else fw = (cols - 1) / fct.
3654            let raw_fct = (term_width.saturating_sub(1)) / (longest + 3);
3655            let (fct, fw) = if raw_fct == 0 {
3656                (1, longest + 3)
3657            } else {
3658                (raw_fct, (term_width.saturating_sub(1)) / raw_fct)
3659            };
3660            // loop.c:374 — colsz = (ct + fct - 1) / fct.
3661            let colsz = ct.div_ceil(fct);
3662            // loop.c:375-395 — for each row t1, walk down columns.
3663            for t1 in 0..colsz {
3664                let mut ap_idx = t1;
3665                while ap_idx < ct {
3666                    let w = &words[ap_idx];
3667                    let n = ap_idx + 1;
3668                    let _ = write!(std::io::stderr(), "{}) {}", n, w);
3669                    let mut t2 = w.chars().count() + 2;
3670                    let mut t3 = n;
3671                    while t3 > 0 {
3672                        t2 += 1;
3673                        t3 /= 10;
3674                    }
3675                    // Pad to fw (loop.c:389-390).
3676                    while t2 < fw {
3677                        let _ = write!(std::io::stderr(), " ");
3678                        t2 += 1;
3679                    }
3680                    ap_idx += colsz;
3681                }
3682                let _ = writeln!(std::io::stderr());
3683            }
3684        };
3685        render_menu(); // c:264 — once, before the loop
3686
3687        'select: loop {
3688            // c:266-290 — inner read loop: prompt and read until a NON-EMPTY
3689            // line arrives; each empty line reprints the menu and re-reads.
3690            let trimmed = loop {
3691                let _ = write!(std::io::stderr(), "{}", prompt);
3692                let _ = std::io::stderr().flush();
3693
3694                let mut line = String::new();
3695                match reader.read_line(&mut line) {
3696                    Ok(0) => {
3697                        // c:277-285 — EOF (user pressed Ctrl+D): REPLY="",
3698                        // a newline to stderr, then leave the construct.
3699                        with_executor(|exec| {
3700                            exec.set_scalar("REPLY".to_string(), String::new());
3701                        });
3702                        let _ = writeln!(std::io::stderr());
3703                        let _ = std::io::stderr().flush();
3704                        break 'select;
3705                    }
3706                    Ok(_) => {}
3707                    Err(_) => break 'select,
3708                }
3709                let t = line.trim_end_matches(['\n', '\r'][..].as_ref()).to_string();
3710                // c:288-289 — `if (*str) break;`
3711                if !t.is_empty() {
3712                    break t;
3713                }
3714                // c:290 — `more = selectlist(args, more);` on an empty line.
3715                render_menu();
3716            };
3717            // c:291 `setsparam("REPLY", ztrdup(str));` — REPLY is set once the
3718            // inner loop yields a non-empty line. An empty line never reaches
3719            // here: c:290 reprints and re-reads instead.
3720            with_executor(|exec| {
3721                exec.set_scalar("REPLY".to_string(), trimmed.clone());
3722            });
3723
3724            // c:293 `i = atoi(str);` — atoi(3) reads a LEADING integer:
3725            // optional blanks, optional sign, then digits, ignoring whatever
3726            // trails, and yields 0 when there are no digits at all.
3727            // `parse::<usize>()` is strict and rejected `1 2` / `1abc` / `+2`
3728            // / ` 1`, so a reply with anything after the number selected
3729            // NOTHING where zsh selects the leading number's item.
3730            let i: i64 = {
3731                let b = trimmed.as_bytes();
3732                let mut p = 0;
3733                while p < b.len() && (b[p] == b' ' || b[p] == b'\t') {
3734                    p += 1;
3735                }
3736                let neg = p < b.len() && b[p] == b'-';
3737                if p < b.len() && (b[p] == b'-' || b[p] == b'+') {
3738                    p += 1;
3739                }
3740                let mut v: i64 = 0;
3741                while p < b.len() && b[p].is_ascii_digit() {
3742                    v = v.saturating_mul(10).saturating_add((b[p] - b'0') as i64);
3743                    p += 1;
3744                }
3745                if neg {
3746                    -v
3747                } else {
3748                    v
3749                }
3750            };
3751            // c:294-301 — `if (!i) str = "";` else walk i-1 nodes and take
3752            // that word; running off the end leaves "". A NEGATIVE i walks
3753            // until the list is exhausted (`n && i`), which also lands on "".
3754            let chosen = if i <= 0 {
3755                String::new()
3756            } else {
3757                words.get((i - 1) as usize).cloned().unwrap_or_default()
3758            };
3759
3760            with_executor(|exec| {
3761                exec.set_scalar(name.clone(), chosen);
3762            });
3763
3764            // Reset canonical BREAKS/CONTFLAG before running the body
3765            // so a stale value from a sibling construct doesn't leak in.
3766            crate::ported::builtin::BREAKS.store(0, SeqCst);
3767            crate::ported::builtin::CONTFLAG.store(0, SeqCst);
3768
3769            // c:Src/loop.c — `select` increments LOOPS for the body so
3770            // `break` / `continue` inside the body see loops > 0 and
3771            // don't emit `not in while, until, select, or repeat loop`.
3772            // Mirrors execwhile/execrepeat's `LOOPS.fetch_add` pattern.
3773            // The decrement happens after the body call so a body that
3774            // explicitly returns / errors still leaves the counter
3775            // balanced for the next iteration.
3776            crate::ported::builtin::LOOPS.fetch_add(1, SeqCst);
3777
3778            crate::fusevm_disasm::maybe_print_stdout("select:body", &chunk);
3779            let mut body_vm = fusevm::VM::new(chunk.clone());
3780            register_builtins(&mut body_vm);
3781            let _ = body_vm.run();
3782            last_status = body_vm.last_status;
3783
3784            crate::ported::builtin::LOOPS.fetch_sub(1, SeqCst);
3785
3786            // Drain the canonical BREAKS/CONTFLAG counters. Mirrors
3787            // loop.c:529-534's `if (breaks) { breaks--; if (breaks ||
3788            // !contflag) break; contflag = 0; }` drain pattern.
3789            // The legacy `BREAK_SELECT=1` env-var sentinel is still
3790            // honored for backward compat.
3791            let break_legacy = with_executor(|exec| {
3792                let v = exec.scalar("BREAK_SELECT");
3793                exec.unset_scalar("BREAK_SELECT");
3794                v.map(|s| s != "0" && !s.is_empty()).unwrap_or(false)
3795            });
3796            use std::sync::atomic::Ordering::SeqCst;
3797            let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
3798            if breaks > 0 {
3799                let cont = crate::ported::builtin::CONTFLAG.load(SeqCst);
3800                crate::ported::builtin::BREAKS.fetch_sub(1, SeqCst);
3801                if breaks - 1 > 0 || cont == 0 {
3802                    break;
3803                }
3804                crate::ported::builtin::CONTFLAG.store(0, SeqCst);
3805                continue;
3806            }
3807            if break_legacy {
3808                break;
3809            }
3810        }
3811
3812        Value::Status(last_status)
3813    });
3814
3815    // Magic special-parameter assoc lookup. Synthesizes values from
3816    // shell state for zsh's shell-introspection assocs:
3817    //   commands, aliases, galiases, saliases, dis_aliases, dis_galiases,
3818    //   dis_saliases, functions, dis_functions, builtins, dis_builtins,
3819    //   reswords, options, parameters, jobtexts, jobdirs, jobstates,
3820    //   nameddirs, userdirs, modules.
3821    // Returns None if `name` isn't a recognized magic name.
3822
3823    // `${arr[idx]}` — pop name, then idx_str. zsh is 1-based for positive
3824    // indices; we honor that. `@`/`*` return the whole array as Value::Array
3825    // so Op::Exec splice produces N argv slots. For `${foo[key]}` where foo
3826    // is an assoc, the idx is a string key — we check assoc_arrays first
3827    // when the idx isn't `@`/`*` and the name has an assoc binding.
3828    // BUILTIN_ARRAY_INDEX — `${name[idx]}` paramsubst dispatch.
3829    // PURE PASSTHRU: pops the idx + name, hands the canonical
3830    // `${name[idx]}` form to `subst::paramsubst` (C port of
3831    // `Src/subst.c::paramsubst`). All subscript-flag dispatch
3832    // ((I)pat / (R)pat / (i)/(r)/(K)/(k), range slices `[N,M]`,
3833    // negative indices, magic-assoc shape lookup, DQ-join collapse)
3834    // lives inside paramsubst → fetchvalue → getarg in params.rs.
3835    //
3836    // Outer-flag dispatch (`(@)` / `(@k)` / `(v)NAME[(I)pat]` / etc.)
3837    // routes through BUILTIN_BRIDGE_BRACE_ARRAY at the compile path
3838    // (canonical paramsubst flag parser owns dispatch at Src/subst.c:2147+),
3839    // so BUILTIN_ARRAY_INDEX receives clean name+key with no sentinel
3840    // prefixes.
3841    vm.register_builtin(BUILTIN_ARRAY_INDEX, |vm, _argc| {
3842        let idx = vm.pop().to_str();
3843        let name = vm.pop().to_str();
3844        array_index_lookup(&name, &idx)
3845    });
3846    // BUILTIN_ARRAY_INDEX_UNBRACED — bare `$name[idx]` (no braces).
3847    // Same subscript dispatch as BUILTIN_ARRAY_INDEX when KSHARRAYS
3848    // is unset, but under KSHARRAYS the UNBRACED form does NOT
3849    // subscript at all:
3850    //   c:Src/subst.c:2800-2802 — fetchvalue's bracket-parse arg is
3851    //     `(unset(KSHARRAYS) || inbrace) ? 1 : -1`; -1 inhibits
3852    //     subscript parsing for the bare form under KSHARRAYS.
3853    //   c:Src/subst.c:2867 — the bracket-consuming loop only runs
3854    //     `while (v || ((inbrace || (unset(KSHARRAYS) && vunset)) &&
3855    //     isbrack(*s)))` — bare + KSHARRAYS leaves `[...]` as literal
3856    //     trailing text.
3857    // The bare `$name` expands (first element for identifier-named
3858    // arrays per c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`),
3859    // the literal `[idx]` (+ any literal suffix) joins the last word,
3860    // and the word undergoes filename generation: `[...]` is a glob
3861    // char class, so unquoted it hits the c:Src/glob.c:1873-1886
3862    // nomatch/nullglob dispatch (reused via exec.expand_glob).
3863    // Operands: [name, idx, suffix, quoted] — `quoted` set when the
3864    // word carries DQ markers (no filename generation in DQ; zsh 5.9:
3865    // `setopt ksharrays; a=(x y z); print "$a[0]"` → `x[0]`).
3866    // Verbatim zsh 5.9 ground truth for the unquoted form:
3867    //   `setopt ksharrays; a=(x y z); print -- $a[0]` →
3868    //   stderr `zsh:1: no matches found: x[0]`, rc=1, empty stdout.
3869    vm.register_builtin(BUILTIN_ARRAY_INDEX_UNBRACED, |vm, _argc| {
3870        let quoted = vm.pop().to_str() == "1";
3871        let suffix = vm.pop().to_str();
3872        let idx = vm.pop().to_str();
3873        let name = vm.pop().to_str();
3874        if !crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
3875            let v = array_index_lookup(&name, &idx);
3876            if suffix.is_empty() {
3877                return v;
3878            }
3879            // Mirrors the previous compile-shape `ARRAY_INDEX +
3880            // Op::Concat` exactly: Concat stringifies via as_str_cow
3881            // (fusevm value.rs:132-146, arrays join with " ").
3882            return Value::str(format!("{}{}", v.to_str(), suffix));
3883        }
3884        // KSHARRAYS bare form: no subscript. Bare-`$name` words +
3885        // literal `[idx]suffix` glued onto the last word.
3886        let mut words = ksharrays_bare_words(&name);
3887        let last = format!("{}[{}]{}", words.pop().unwrap_or_default(), idx, suffix);
3888        if quoted {
3889            // DQ context — no filename generation, bracket text stays
3890            // literal.
3891            words.push(last);
3892            return Value::str(words.join(" "));
3893        }
3894        // c:Src/glob.c:1873-1886 — expand_glob handles nullglob /
3895        // NOMATCH (zerr "no matches found" + errflag + the
3896        // current_command_glob_failed cell consumed at the command
3897        // dispatch boundary) / literal passthrough for glob-free text.
3898        let matches = with_executor(|exec| exec.expand_glob(&last));
3899        let mut out: Vec<Value> = words.into_iter().map(Value::str).collect();
3900        out.extend(matches.into_iter().map(Value::str));
3901        if out.len() == 1 {
3902            return out.pop().unwrap();
3903        }
3904        Value::Array(out)
3905    });
3906    // BUILTIN_ASSOC_HAS_KEY — `${(k)assoc[name]}` key-existence query.
3907    // Pops [assoc_name, key]; returns key (Str) if present in the
3908    // assoc, empty Str otherwise. Mirrors zsh's `${(k)h[name]}`
3909    // documented semantics in zshparam(1) "Parameter Expansion Flags".
3910    // Distinct from BUILTIN_ARRAY_INDEX (which returns the VALUE) and
3911    // from `${+h[name]}` (which returns "0"/"1"). Bug #145.
3912    vm.register_builtin(BUILTIN_ASSOC_HAS_KEY, |vm, _argc| {
3913        let key = vm.pop().to_str();
3914        let name = vm.pop().to_str();
3915        // c:Src/params.c getindex — the subscript text is substituted
3916        // (singsub) before the lookup; `${(k)H[$k]}` must resolve $k.
3917        // The compiler hands this opcode the RAW subscript text, so a
3918        // dynamic key arrived literally ("$k") and matched nothing.
3919        // singsub is identity for plain keys.
3920        let key = if key.contains('$') || key.contains('`') || key.contains('\u{8c}') {
3921            crate::ported::subst::singsub(&key)
3922        } else {
3923            key
3924        };
3925        // c:Src/params.c:3131 gethkparam covers ordinary PM_HASHED
3926        // paramtab entries only. Special/magic hashes (`parameters`,
3927        // `options`, … — the zsh/parameter module's partab-backed
3928        // params, Src/Modules/parameter.c) aren't in that storage, so
3929        // a None here doesn't mean "no such assoc". Route those
3930        // through paramsubst, whose assoc materialization handles the
3931        // magic hashes and whose getarg port (c:Src/params.c:1591 +
3932        // Src/subst.c:2922) returns the KEY for `(k)` on a plain
3933        // subscript. zsh 5.9: `${(k)parameters[PATH]}` → "PATH".
3934        match crate::ported::params::gethkparam(&name) {
3935            Some(keys) => {
3936                if keys.iter().any(|k| k == &key) {
3937                    Value::str(key)
3938                } else {
3939                    Value::str("")
3940                }
3941            }
3942            None => paramsubst_to_value(&format!("${{(k){}[{}]}}", name, key)),
3943        }
3944    });
3945    vm.register_builtin(BUILTIN_BRIDGE_BRACE_ARRAY, |vm, _argc| {
3946        // Inner body of `${(...)...}` (already stripped of `${`/`}` by
3947        // the caller). The compiler optionally prefixes Qstring
3948        // (\u{8c}) to signal "expanded in DQ context" — strip it
3949        // here and bump in_dq_context for the paramsubst call so the
3950        // SUB_ZIP and other qt-aware paths fire.
3951        let body = vm.pop().to_str();
3952        let (dq, inner) = if let Some(rest) = body.strip_prefix('\u{8c}') {
3953            (true, rest.to_string())
3954        } else {
3955            (false, body)
3956        };
3957        if dq {
3958            with_executor(|exec| exec.in_dq_context += 1);
3959        }
3960        let v = paramsubst_to_value(&format!("${{{}}}", inner));
3961        if dq {
3962            with_executor(|exec| exec.in_dq_context -= 1);
3963        }
3964        v
3965    });
3966
3967    // BUILTIN_PARAM_FLAG — `${(flags)name}` paramsubst dispatch.
3968    // PURE PASSTHRU: pops sentinel-tagged flags + name, hands the
3969    // canonical `${(flags)name}` form to `subst::paramsubst` (C port
3970    // of `Src/subst.c::paramsubst`). The bridge does no flag
3971    // walking, no DQ-context branching, no array/scalar shape
3972    // selection — all of that lives inside paramsubst. Compile-time
3973    // context (DQ / scalar-assign-RHS) flows through executor cells
3974    // (in_dq_context, in_scalar_assign) bumped by BUILTIN_EXPAND_TEXT.
3975    vm.register_builtin(BUILTIN_PARAM_FLAG, |vm, _argc| {
3976        let flags = vm.pop().to_str();
3977        let name = vm.pop().to_str();
3978        let body = format!("${{({}){}}}", flags, name);
3979        paramsubst_to_value(&body)
3980    });
3981
3982    // `foo[key]=val` — single-key set on an assoc array. Stack: [name, key, value].
3983    // PURE PASSTHRU: assignsparam with `name[key]` form (C port of
3984    // `Src/params.c::assignsparam` subscript path at c:3210-3231)
3985    // already does the indexed-array vs assoc decision, PM_HASHED
3986    // auto-vivification, numeric-subscript bounds handling, and
3987    // PM_READONLY rejection.
3988    vm.register_builtin(BUILTIN_SET_ASSOC, |vm, _argc| {
3989        // `${~spec}` carrier: an assignment statement is a word-
3990        // pipeline boundary too — restore the user's GLOB_SUBST
3991        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
3992        // ${options[globsubst]}` must read the user value).
3993        consume_tilde_globsubst_carrier();
3994        // argc 4 = compile flagged the subscript as DYNAMIC (`H[$k]`):
3995        // an EXPANDED-empty key is then a legal assoc key (C's
3996        // assignsparam isident gate sees the raw `$k` text and the
3997        // empty key stores at getindex time — zinit's
3998        // ZINIT_SICE[$1…$2] relies on it). argc 3 = source-literal
3999        // key; `H[]` stays the "not an identifier" error.
4000        let key_is_dynamic = if _argc == 4 {
4001            vm.pop().to_int() != 0
4002        } else {
4003            false
4004        };
4005        let value = vm.pop().to_str();
4006        let key = vm.pop().to_str();
4007        let name = vm.pop().to_str();
4008        // Bash sparse-array tracking for `a[i]=v` (scalar single-index). A set
4009        // that pads the dense Vec past its old end leaves old_len..i as holes;
4010        // on an undefined array, `a[5]=q` leaves only index 5 (count 1). Only
4011        // for INDEXED arrays (assoc keys are strings), bash mode, numeric key.
4012        // Captured before the assign; applied after (on the array path).
4013        let sparse_track: Option<(String, usize, usize)> =
4014            if crate::dash_mode::bash_mode() && !key.contains(',') {
4015                key.trim().parse::<usize>().ok().and_then(|i| {
4016                    with_executor(|exec| {
4017                        if !exec.has_assoc(&name) {
4018                            let old_len = exec.array(&name).map(|a| a.len()).unwrap_or(0);
4019                            Some((name.clone(), old_len, i))
4020                        } else {
4021                            None
4022                        }
4023                    })
4024                })
4025            } else {
4026                None
4027            };
4028        if key_is_dynamic && key.is_empty() {
4029            with_executor(|exec| {
4030                let _ = exec;
4031            });
4032            // Mirror assignsparam's PM_HASHED tail directly (the
4033            // textual `name[]` reconstruction can't pass isident).
4034            if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
4035                let entry = store.entry(name.clone()).or_default();
4036                let newval = if let Some(old) = entry.get("") {
4037                    // `+=` arrives pre-concatenated by the compile
4038                    // read-modify-write; plain `=` overwrites.
4039                    let _ = old;
4040                    value.clone()
4041                } else {
4042                    value.clone()
4043                };
4044                entry.insert(String::new(), newval);
4045            }
4046            return Value::Status(0);
4047        }
4048        with_executor(|exec| {
4049            #[cfg(feature = "recorder")]
4050            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
4051                let ctx = exec.recorder_ctx();
4052                let attrs = exec.recorder_attrs_for(&name);
4053                crate::recorder::emit_assoc_assign(
4054                    &name,
4055                    vec![(key.clone(), value.clone())],
4056                    attrs,
4057                    true,
4058                    ctx,
4059                );
4060            }
4061            let _ = exec;
4062        });
4063        // Build `name[key]=value` shape for assignsparam's subscript
4064        // dispatch. Arith-evaluate numeric subscripts on an existing
4065        // indexed array (`a[i+1]=v` form) before handing off — the
4066        // canonical port currently only handles literal int / string
4067        // keys, so pre-resolve here.
4068        let resolved_key = with_executor(|exec| {
4069            // Existence probes only — use the non-cloning `has_*`
4070            // checks. `exec.assoc()` / `exec.array()` return owned
4071            // clones of the whole map/vector, so probing `.is_some()`
4072            // here copied the entire associative array on every
4073            // `h[k]=v` (O(n) per store → O(n²) for a fill loop). The
4074            // profiler flagged `ShellExecutor::assoc → IndexMap::clone`
4075            // as the dominant cost.
4076            let is_indexed = exec.has_array(&name);
4077            let is_assoc = exec.has_assoc(&name);
4078            let is_scalar = !is_indexed && !is_assoc && exec.has_scalar(&name);
4079            // c:Src/params.c::getindex — `(i)pat` / `(I)pat` / `(R)pat`
4080            // / `(r)pat` subscript flags on an indexed array LHS resolve
4081            // to a numeric index (first / last match of pat). On a
4082            // SCALAR LHS the same flags resolve to a CHAR position
4083            // (1-based first/last match of pat in the scalar string)
4084            // for the c:2748+ char-splice assignment. zshrs's
4085            // read-form `${a[(i)pat]}` already implements both shapes;
4086            // the LHS assignment path silently stored the literal
4087            // "(i)pat" as an assoc key (for scalar: auto-vivified to
4088            // PM_HASHED via the assignsparam unknown-subscript
4089            // fallback). Bug #293 (array) / scalar sibling.
4090            //
4091            // Detect the `(flags)pat` shape and resolve to a numeric
4092            // index before assignsparam.
4093            if is_indexed || is_scalar {
4094                if let Some(rest) = key.strip_prefix('(') {
4095                    if let Some(close) = rest.find(')') {
4096                        let flags = &rest[..close];
4097                        let pat = &rest[close + 1..];
4098                        if !flags.is_empty()
4099                            && flags
4100                                .chars()
4101                                .all(|c| matches!(c, 'I' | 'R' | 'i' | 'r' | 'n' | 'e'))
4102                        {
4103                            // Resolve via the array's contents.
4104                            if let Some(arr) = exec.array(&name) {
4105                                let return_index = true; // LHS write — index needed
4106                                let down = flags.contains('I') || flags.contains('R');
4107                                let exact = flags.contains('e');
4108                                let iter: Box<dyn Iterator<Item = (usize, &String)>> = if down {
4109                                    Box::new(arr.iter().enumerate().rev())
4110                                } else {
4111                                    Box::new(arr.iter().enumerate())
4112                                };
4113                                let mut found: Option<usize> = None;
4114                                for (idx, elem) in iter {
4115                                    let matched = if exact {
4116                                        elem == pat
4117                                    } else {
4118                                        crate::ported::pattern::patcompile(
4119                                            &{
4120                                                let mut __pat_tok = (pat).to_string();
4121                                                crate::ported::glob::tokenize(&mut __pat_tok);
4122                                                __pat_tok
4123                                            },
4124                                            crate::ported::zsh_h::PAT_HEAPDUP as i32,
4125                                            None,
4126                                        )
4127                                        .map_or(false, |p| crate::ported::pattern::pattry(&p, elem))
4128                                    };
4129                                    if matched {
4130                                        found = Some(idx);
4131                                        break;
4132                                    }
4133                                }
4134                                let _ = return_index;
4135                                // (i)/(r) return 1-based index of match,
4136                                // arr.len()+1 (or 1 for I/R) on miss
4137                                // per zsh docs. We mirror the read-form
4138                                // semantics from subst.rs.
4139                                let idx_1based = match found {
4140                                    Some(i) => (i + 1) as i64,
4141                                    None => (arr.len() + 1) as i64,
4142                                };
4143                                return idx_1based.to_string();
4144                            }
4145                            // Scalar LHS — resolve to a CHAR position
4146                            // (1-based first/last match of pat in the
4147                            // string). c:Src/params.c:1411-1418 — the
4148                            // scalar path returns the char index from
4149                            // sliding-window pattern match against
4150                            // pm.u_str. Same algorithm as the read-form
4151                            // at subst.rs:5283-5306. Bug (scalar
4152                            // sibling of #293): `a=hello; a[(I)l]=X`
4153                            // previously auto-vivified `a` into
4154                            // PM_HASHED with key "(I)l" instead of
4155                            // splicing X at the last 'l' position
4156                            // (yielding "helXo").
4157                            if is_scalar {
4158                                let s = exec.scalar(&name).unwrap_or_default();
4159                                let s_chars: Vec<char> = s.chars().collect();
4160                                let n = s_chars.len();
4161                                let want_last = flags.contains('I') || flags.contains('R');
4162                                let exact = flags.contains('e');
4163                                let mut found: Option<usize> = None;
4164                                'outer: for start in 0..=n {
4165                                    let lengths: Box<dyn Iterator<Item = usize>> = if want_last {
4166                                        Box::new((1..=(n - start)).rev())
4167                                    } else {
4168                                        Box::new(1..=(n - start))
4169                                    };
4170                                    for len in lengths {
4171                                        let cand: String =
4172                                            s_chars[start..start + len].iter().collect();
4173                                        let matched = if exact {
4174                                            cand == pat
4175                                        } else {
4176                                            crate::ported::pattern::patcompile(
4177                                                &{
4178                                                    let mut __pat_tok = (pat).to_string();
4179                                                    crate::ported::glob::tokenize(&mut __pat_tok);
4180                                                    __pat_tok
4181                                                },
4182                                                crate::ported::zsh_h::PAT_HEAPDUP as i32,
4183                                                None,
4184                                            )
4185                                            .map_or(false, |p| {
4186                                                crate::ported::pattern::pattry(&p, &cand)
4187                                            })
4188                                        };
4189                                        if matched {
4190                                            found = Some(start);
4191                                            if !want_last {
4192                                                break 'outer;
4193                                            }
4194                                            break;
4195                                        }
4196                                    }
4197                                }
4198                                // (I)/(R): scan again to find LAST.
4199                                if want_last {
4200                                    let mut last_found: Option<usize> = found;
4201                                    for start in (0..=n).rev() {
4202                                        for len in 1..=(n - start) {
4203                                            let cand: String =
4204                                                s_chars[start..start + len].iter().collect();
4205                                            let matched = if exact {
4206                                                cand == pat
4207                                            } else {
4208                                                crate::ported::pattern::patcompile(
4209                                                    &{
4210                                                        let mut __pat_tok = (pat).to_string();
4211                                                        crate::ported::glob::tokenize(
4212                                                            &mut __pat_tok,
4213                                                        );
4214                                                        __pat_tok
4215                                                    },
4216                                                    crate::ported::zsh_h::PAT_HEAPDUP as i32,
4217                                                    None,
4218                                                )
4219                                                .map_or(false, |p| {
4220                                                    crate::ported::pattern::pattry(&p, &cand)
4221                                                })
4222                                            };
4223                                            if matched {
4224                                                last_found = Some(start);
4225                                                break;
4226                                            }
4227                                        }
4228                                        if last_found.is_some() && last_found.unwrap() >= start {
4229                                            break;
4230                                        }
4231                                    }
4232                                    found = last_found;
4233                                }
4234                                let idx_1based = match found {
4235                                    Some(i) => (i + 1) as i64,
4236                                    // (i) miss → len+1 (one past end).
4237                                    None => (n + 1) as i64,
4238                                };
4239                                return idx_1based.to_string();
4240                            }
4241                        }
4242                    }
4243                }
4244            }
4245            if is_indexed && key.trim().parse::<i64>().is_err() {
4246                crate::ported::math::mathevali(&crate::ported::subst::singsub(&key))
4247                    .map(|n| n.to_string())
4248                    .unwrap_or(key.clone())
4249            } else {
4250                key.clone()
4251            }
4252        });
4253        // c:Src/params.c getindex — C parses the subscript from the
4254        // TOKENIZED source word, so a `]`/`}` that arrived via `$key`
4255        // expansion is plain data and can never terminate the
4256        // subscript. The textual `name[key]` rebuild below re-parses
4257        // the FLAT string, where an expanded `]` splits the key at the
4258        // first bracket (`c[$k]=5` with k='x]y' stored key "x" and
4259        // spilled junk — zpwr expandstats died on the spill in a later
4260        // math expr). For a PM_HASHED target the compile-time split
4261        // already isolated the exact key: store it directly via the
4262        // canonical hashed storage (same mechanism as the
4263        // dynamic-empty-key arm above / assignsparam's PM_HASHED
4264        // tail), with the readonly guard assignsparam would apply.
4265        let target_flags = with_executor(|exec| exec.param_flags(&name));
4266        // PM_SPECIAL exclusion: the zsh/parameter magic assocs
4267        // (functions / aliases / galiases / saliases / options / …)
4268        // have per-key setfns with SIDE EFFECTS — `functions[x]=body`
4269        // must parse the body into shfunctab (Src/Modules/
4270        // parameter.c:296 setfunction), `aliases[x]=v` must write
4271        // aliastab. The direct hashed-storage store below silently
4272        // swallowed those: zinit's tmp-subst wrappers
4273        // (`functions[autoload]=':zinit-tmp-subst-autoload "$@";'`)
4274        // never became real functions, so every
4275        // `.zinit-tmp-subst-off` spammed `unfunction: no such hash
4276        // table element: autoload/compdef/bindkey/…`. Route specials
4277        // through assignsparam's canonical per-name arms instead.
4278        if (target_flags as u32 & crate::ported::zsh_h::PM_HASHED) != 0
4279            && (target_flags as u32 & crate::ported::zsh_h::PM_SPECIAL) == 0
4280        {
4281            if (target_flags as u32 & crate::ported::zsh_h::PM_READONLY) != 0 {
4282                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
4283                return Value::Status(1);
4284            }
4285            if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
4286                store
4287                    .entry(name.clone())
4288                    .or_default()
4289                    .insert(resolved_key.clone(), value.clone());
4290            }
4291            return Value::Status(0);
4292        }
4293        let subscripted = format!("{}[{}]", name, resolved_key);
4294        crate::ported::params::assignsparam(&subscripted, &value, crate::ported::zsh_h::ASSPM_WARN);
4295        if let Some((nm, old_len, i)) = sparse_track {
4296            crate::bash_arrays::note_subscript_set(&nm, old_len, i);
4297        }
4298        Value::Status(0)
4299    });
4300
4301    // Brace expansion. Routes through executor.xpandbraces (already
4302    // implemented for the pre-fusevm executor). Returns Value::Array.
4303    // BUILTIN_ARRAY_DROP_EMPTY — filter out empty Value::Str entries
4304    // from a Value::Array on the stack. Used by `for x in $@` /
4305    // `for x in $*` unquoted forms which drop empty positionals
4306    // (POSIX-like) but do NOT IFS-split each element internally
4307    // (zsh-specific — scalar word splitting is off by default).
4308    // Distinct from BUILTIN_WORD_SPLIT which routes through
4309    // multsub PREFORK_SPLIT (full IFS-split). Bug #166.
4310    vm.register_builtin(BUILTIN_ARRAY_DROP_EMPTY, |vm, _argc| {
4311        let v = vm.pop();
4312        match v {
4313            Value::Array(items) => {
4314                let filtered: Vec<Value> = items
4315                    .into_iter()
4316                    .filter(|x| !x.to_str().is_empty())
4317                    .collect();
4318                Value::Array(filtered)
4319            }
4320            Value::Str(s) if s.is_empty() => Value::Array(Vec::new()),
4321            other => other,
4322        }
4323    });
4324
4325    // BUILTIN_QUOTEDZPUTS — re-wrap top-of-stack scalar via the
4326    // canonical quotedzputs (Src/utils.c:6464). Non-printable bytes
4327    // come back as `$'…'` C-string form so the cond xtrace prefix
4328    // line preserves the source-quoting form for `[[ -n $'\C-[OP' ]]`
4329    // instead of leaking raw ESC + "OP" bytes through the terminal.
4330    vm.register_builtin(BUILTIN_QUOTEDZPUTS, |vm, _argc| {
4331        let s = vm.pop().to_str();
4332        Value::str(crate::ported::utils::quotedzputs(&s))
4333    });
4334
4335    // BUILTIN_QUOTE_TOKENIZED_OUTPUT — char-aware mirror of
4336    // c:Src/exec.c:2114 `quote_tokenized_output`. The canonical
4337    // port at exec::quote_tokenized_output operates on bytes
4338    // (zsh's metafied encoding); zshrs strings are UTF-8 so
4339    // `\u{87}` Star is `[0xC2, 0x87]`, and a byte walk writes
4340    // 0xC2 raw (invalid UTF-8 lead → U+FFFD on lossy decode).
4341    // Walk by char and dispatch the same switch the byte port
4342    // uses, but with the token chars matching the UTF-8 form.
4343    vm.register_builtin(BUILTIN_QUOTE_TOKENIZED_OUTPUT, |vm, _argc| {
4344        let s = vm.pop().to_str();
4345        let mut out = String::with_capacity(s.len());
4346        let chars: Vec<char> = s.chars().collect();
4347        let mut i = 0;
4348        while i < chars.len() {
4349            let c = chars[i];
4350            // c:2120 — Meta-quoted byte: emit `*++s ^ 32`.
4351            // In UTF-8 strings Meta is `\u{83}`; the next char is
4352            // the metafied payload.
4353            if c == '\u{83}' {
4354                if let Some(&n) = chars.get(i + 1) {
4355                    if (n as u32) < 0x80 {
4356                        out.push(((n as u8) ^ 32) as char);
4357                    } else {
4358                        out.push(n);
4359                    }
4360                    i += 2;
4361                    continue;
4362                }
4363                i += 1;
4364                continue;
4365            }
4366            // c:2124 — Nularg: skip.
4367            if c == '\u{a1}' {
4368                i += 1;
4369                continue;
4370            }
4371            // c:2128-2143 — ASCII specials get backslash-prefixed
4372            // then fall through to emit the literal char.
4373            match c {
4374                '\\' | '<' | '>' | '(' | '|' | ')' | '^' | '#' | '~' | '[' | ']' | '*' | '?'
4375                | '$' | ' ' => {
4376                    out.push('\\');
4377                    out.push(c);
4378                    i += 1;
4379                    continue;
4380                }
4381                '\t' => {
4382                    out.push_str("$'\\t'");
4383                    i += 1;
4384                    continue;
4385                }
4386                '\n' => {
4387                    out.push_str("$'\\n'");
4388                    i += 1;
4389                    continue;
4390                }
4391                '\r' => {
4392                    out.push_str("$'\\r'");
4393                    i += 1;
4394                    continue;
4395                }
4396                '=' => {
4397                    if i == 0 {
4398                        out.push('\\');
4399                    }
4400                    out.push(c);
4401                    i += 1;
4402                    continue;
4403                }
4404                _ => {}
4405            }
4406            // c:2163 — `if (itok(*s)) putc(ztokens[*s - Pound]);`
4407            // Map zsh token chars (`\u{84}`..`\u{a1}` range, the
4408            // ones the lexer emits for `#$^*()…`) back to their
4409            // source ASCII via the `ztokens` table.
4410            let cp = c as u32;
4411            if (0x84..=0xa1).contains(&cp) {
4412                let idx = (cp - 0x84) as usize;
4413                let ztokens = crate::ported::lex::ztokens.as_bytes();
4414                if idx < ztokens.len() {
4415                    out.push(ztokens[idx] as char);
4416                    i += 1;
4417                    continue;
4418                }
4419            }
4420            out.push(c);
4421            i += 1;
4422        }
4423        Value::str(out)
4424    });
4425
4426    // BUILTIN_WORD_SPLIT — `${=var}` IFS-split runtime.
4427    // PURE PASSTHRU: route through canonical `subst::multsub` with
4428    // PREFORK_SPLIT flag (C port of `Src/subst.c::multsub` at c:544
4429    // — the IFS-split walker with whitespace-vs-non-whitespace
4430    // gating, quote-aware parsing, and empty-field handling).
4431    vm.register_builtin(BUILTIN_WORD_SPLIT, |vm, _argc| {
4432        let s = vm.pop().to_str();
4433        let (_joined, parts, _isarr, _flags) =
4434            crate::ported::subst::multsub(&s, crate::ported::zsh_h::PREFORK_SPLIT);
4435        // Empty single-string special case → empty Array (drop empty arg).
4436        if parts.len() == 1 && parts[0].is_empty() {
4437            return Value::Array(Vec::new());
4438        }
4439        nodes_to_value(parts)
4440    });
4441
4442    // BUILTIN_FORCE_SPLIT — `${=name}` / SH_WORD_SPLIT forced split.
4443    // c:Src/subst.c:3920-3928 —
4444    //     if (force_split && !isarr) {
4445    //         aval = sepsplit(val, spsep, 0, 1);
4446    //         if (!aval || !aval[0])   val = dupstring("");
4447    //         else if (!aval[1])       val = aval[0];
4448    //         else                     isarr = nojoin ? 1 : 2;
4449    //     }
4450    // with spsep == NULL for the `=` flag, so sepsplit falls through to
4451    // Src/utils.c:3711 spacesplit(s, allownull=0). See BUILTIN_FORCE_SPLIT's
4452    // doc comment for the empty-field rule and the argc contract.
4453    vm.register_builtin(BUILTIN_FORCE_SPLIT, |vm, argc| {
4454        let s = vm.pop().to_str();
4455        let keep_empties = argc == 1;
4456        // c:3921 — `sepsplit(val, spsep, 0, 1)`; spsep NULL → spacesplit.
4457        let raw = crate::ported::utils::sepsplit(&s, None, false);
4458        // c:Src/subst.c:36 `char nulstring[] = {Nularg, '\0'};` — spacesplit
4459        // emits this for an empty field delimited by IFS-NON-whitespace
4460        // (c:Src/utils.c:3732 / :3752); it survives prefork's empty-node
4461        // delete and remnulargs (c:Src/glob.c:3649) turns it back into "".
4462        // A plain "" field (c:3734 / :3757) is what a skipped run of
4463        // IFS-WHITESPACE leaves behind, and prefork DOES delete that one.
4464        let nulstring = crate::ported::zsh_h::Nularg.to_string();
4465        let mut out: Vec<String> = Vec::with_capacity(raw.len());
4466        for w in raw {
4467            if w == nulstring {
4468                out.push(String::new());
4469            } else if w.is_empty() {
4470                if keep_empties {
4471                    out.push(String::new());
4472                }
4473            } else {
4474                out.push(w);
4475            }
4476        }
4477        if out.is_empty() {
4478            // c:3922-3923 — `if (!aval || !aval[0]) val = dupstring("");`:
4479            // the split produced nothing, so the value is the empty SCALAR.
4480            // Quoted, that is one empty word (c:4465 `if (qt && !*y) y =
4481            // dupstring(nulstring);` → `a=( "${=v}" )` has one element);
4482            // unquoted, prefork deletes it and the word vanishes.
4483            if keep_empties {
4484                return Value::str(String::new());
4485            }
4486            note_empty_is_scalar(true);
4487            return Value::Array(Vec::new());
4488        }
4489        if out.len() == 1 {
4490            // c:3924 — `else if (!aval[1]) val = aval[0];` — a one-field
4491            // split stays a SCALAR (this is why `${#${(f)v}}` counts
4492            // characters when the split yields a single line).
4493            return Value::str(out.into_iter().next().unwrap());
4494        }
4495        // c:3927 — `isarr = nojoin ? 1 : 2;`
4496        Value::Array(out.into_iter().map(Value::str).collect())
4497    });
4498
4499    vm.register_builtin(BUILTIN_BRACE_EXPAND, |vm, _argc| {
4500        // c:Src/glob.c::xpandbraces — brace expansion runs per word.
4501        // When the upstream produced an array (e.g. `${a:e}` splat),
4502        // expand braces on each element separately so the splat
4503        // survives. `pop().to_str()` would join with space and lose
4504        // the array shape. Parity bug #28 cousin: the BRACE_EXPAND
4505        // emit always fires for any word containing `{` (including
4506        // `${...}` param-expansion braces), so its collapse hit even
4507        // pure-paramsubst args.
4508        let raw = vm.pop();
4509        // c:Src/options.c — `no_brace_expand` (negated braceexpand)
4510        // disables brace expansion entirely. When set, `{a,b}` stays
4511        // literal. Mirror by short-circuiting xpandbraces; pass the
4512        // input through unchanged.
4513        let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
4514        let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
4515        let inputs: Vec<String> = match raw {
4516            Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
4517            other => vec![other.to_str()],
4518        };
4519        if !brace_expand {
4520            return nodes_to_value(inputs);
4521        }
4522        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
4523        for s in inputs {
4524            for w in crate::ported::glob::xpandbraces(&s, brace_ccl) {
4525                out.push(w);
4526            }
4527        }
4528        nodes_to_value(out)
4529    });
4530
4531    // `*(qual)` glob qualifier filter. Stack: [pattern, qualifier].
4532    // Pattern is glob-expanded normally, then each result is filtered by the
4533    // qualifier predicate. Common qualifiers:
4534    //   .  — regular files only
4535    //   /  — directories only
4536    //   @  — symlinks
4537    //   x  — executable
4538    //   r/w/x — readable/writable/executable
4539    //   N  — nullglob (no error if no match)
4540    //   L+N / L-N — size > N / size < N (in bytes)
4541    //   mh-N / mh+N — modified within N hours / older than N hours
4542    //   md-N / md+N — modified within N days / older than N days
4543    //   on/On — sort by name asc/desc (default)
4544    //   oL/OL — sort by length
4545    //   om/Om — sort by mtime
4546    // Pop a scalar pattern, run expand_glob, push Value::Array. Used
4547    // by the segment-concat compile path for `$D/*`-style words.
4548    vm.register_builtin(BUILTIN_GLOB_EXPAND, |vm, _argc| {
4549        // c:Src/glob.c:1872 — honour `setopt noglob` / `noglob CMD`
4550        // precommand. When the option is on, the word stays literal
4551        // (zsh skips the glob expansion entirely). Without this, the
4552        // segment-fast-path BUILTIN_GLOB_EXPAND fired even after
4553        // `noglob` set the option, so `noglob echo *.xyz` saw the
4554        // NOMATCH error instead of the literal pass-through.
4555        let raw = vm.pop();
4556        let noglob =
4557            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
4558        glob_expand_word_value(raw, noglob)
4559    });
4560    // Redirect-target variant of BUILTIN_GLOB_EXPAND. c:Src/glob.c:
4561    // 2161-2167 xpandredir — `prefork(&fake, isset(MULTIOS) ? 0 :
4562    // PREFORK_SINGLE, NULL)` then "Globbing is only done for
4563    // multios.": a redirect target word is only globbed when the
4564    // MULTIOS option is set. With it unset, `echo hi > *.txt`
4565    // creates the literal file `*.txt`, and `wc -c < *.txt` errors
4566    // "no such file or directory: *.txt". Bug #36 follow-up in
4567    // docs/BUGS.md.
4568    vm.register_builtin(BUILTIN_REDIR_GLOB_EXPAND, |vm, _argc| {
4569        let raw = vm.pop();
4570        let noglob =
4571            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
4572        let multios = opt_state_get("multios").unwrap_or(true);
4573        glob_expand_word_value(raw, noglob || !multios)
4574    });
4575    // Clear the default-word glob-pending carrier before the word's
4576    // expansion runs, so a flag set by a prior word never leaks in.
4577    vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB_RESET, |_vm, _argc| {
4578        crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| c.set(false));
4579        Value::Status(0)
4580    });
4581    // After the word is assembled, run filename generation ONLY if the
4582    // default/alternate paramsubst arm flagged a source-glob default
4583    // (DEFAULT_WORD_GLOB_PENDING). Otherwise pass the word through
4584    // literally — a parameter VALUE must not glob. c:Src/subst.c globlist.
4585    vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB, |vm, _argc| {
4586        let raw = vm.pop();
4587        let pending = crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| {
4588            let v = c.get();
4589            c.set(false); // read + clear
4590            v
4591        });
4592        if !pending {
4593            return raw;
4594        }
4595        let noglob =
4596            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
4597        glob_expand_word_value(raw, noglob)
4598    });
4599
4600    // `break`/`continue` from a sub-VM body. The compile path emits
4601    // these when the keyword appears at chunk top-level (no enclosing
4602    // for/while in the current chunk's patch lists). Outer-loop
4603    // builtins (BUILTIN_RUN_SELECT and any future loop-via-builtin
4604    // construct) drain canonical BREAKS/CONTFLAG after each iteration.
4605    //
4606    // Writes match `bin_break`'s c:5836+ pattern:
4607    //   continue: contflag = 1; breaks++   (Src/builtin.c::bin_break)
4608    //   break:    breaks++
4609    vm.register_builtin(BUILTIN_SET_BREAK, |_vm, _argc| {
4610        use std::sync::atomic::Ordering::SeqCst;
4611        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
4612        Value::Status(0)
4613    });
4614    vm.register_builtin(BUILTIN_SET_CONTINUE, |_vm, _argc| {
4615        use std::sync::atomic::Ordering::SeqCst;
4616        crate::ported::builtin::CONTFLAG.store(1, SeqCst);
4617        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
4618        Value::Status(0)
4619    });
4620
4621    // `break N`/`continue N` with a RUNTIME level count. Pops [count,
4622    // name]; math-evaluates count (c:builtin.c:5811 `mathevali`); on
4623    // count <= 0 emits `argument is not positive: N` via zerrnam (sets
4624    // errflag → abort, c:5813) and pushes Int(0) (matches no jump-table
4625    // entry → control falls through to the errflag abort). Otherwise
4626    // pushes Int(count) for the compiled jump table to dispatch on.
4627    vm.register_builtin(BUILTIN_BREAK_COUNT_VALIDATE, |vm, _argc| {
4628        let name = vm.pop().to_str();
4629        let count_s = vm.pop().to_str();
4630        let count = crate::ported::math::mathevali(&count_s).unwrap_or(0);
4631        if count <= 0 {
4632            crate::ported::utils::zerrnam(&name, &format!("argument is not positive: {count}"));
4633            return Value::Int(0);
4634        }
4635        Value::Int(count)
4636    });
4637
4638    // `${arr[*]}` — join array elements with the first IFS char into
4639    // a single string. Matches zsh: in DQ context this preserves the
4640    // join; in array context too the result is one Value::Str.
4641    // Set or clear a shell option directly. Used by `noglob CMD ...`
4642    // precommand wrapping — the compiler emits SET_RAW_OPT to flip the
4643    // option ON before compiling the inner words and OFF after, so glob
4644    // expansion of the inner args sees the temporary state.
4645    vm.register_builtin(BUILTIN_SET_RAW_OPT, |vm, _argc| {
4646        let on = vm.pop().to_int() != 0;
4647        let opt = vm.pop().to_str();
4648        // Pure passthru: canonical port lives in
4649        // src/ported/options.rs::opt_state_set_via_alias and
4650        // handles negation-alias resolution per c:Src/options.c.
4651        crate::ported::options::opt_state_set_via_alias(&opt, on);
4652        Value::Status(0)
4653    });
4654
4655    // c:Src/options.c GLOB_SUBST — runtime glob expansion of
4656    // substituted words. Pop a Value (Str or Array); when
4657    // GLOB_SUBST is ON, run expand_glob on each string element;
4658    // when OFF, pass through unchanged. Bug #119 in docs/BUGS.md.
4659    vm.register_builtin(BUILTIN_GLOB_SUBST_EXPAND, |vm, _argc| {
4660        let raw = vm.pop();
4661        let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
4662        if !glob_subst {
4663            return raw;
4664        }
4665        // Collect input strings (Str → vec![s]; Array → multiple).
4666        let inputs: Vec<String> = match raw {
4667            Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
4668            other => vec![other.to_str()],
4669        };
4670        // Run expand_glob on each. Empty matches collapse to a
4671        // single literal pass-through to mirror nullglob-off default.
4672        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
4673        for pattern in inputs {
4674            // c:Src/subst.c — GLOB_SUBST subjects the value to the FULL
4675            // filename-generation pipeline: `filesub` (tilde/`=` expansion)
4676            // BEFORE globbing (prefork runs filesub then globlist). zshrs
4677            // globbed but skipped filesub, so `${~x}` / `setopt globsubst`
4678            // left `~/foo` un-expanded. filesubstr matches the Tilde TOKEN,
4679            // so shtokenize the value first (`~`→Tilde, glob metas active),
4680            // run filesub, then untokenize the surviving glob metas back to
4681            // raw for expand_glob (which re-tokenizes internally).
4682            let pattern = if pattern.contains('~') || pattern.contains('=') {
4683                let mut tok = pattern.clone();
4684                crate::ported::glob::shtokenize(&mut tok);
4685                let fs = crate::ported::subst::filesub(&tok, 0);
4686                crate::ported::lex::untokenize(&fs)
4687            } else {
4688                pattern
4689            };
4690            let matches = with_executor(|exec| exec.expand_glob(&pattern));
4691            if matches.is_empty() {
4692                // No match: keep the literal (like nullglob off).
4693                out.push(pattern);
4694            } else {
4695                for m in matches {
4696                    out.push(m);
4697                }
4698            }
4699        }
4700        if out.len() == 1 {
4701            Value::str(out.into_iter().next().unwrap())
4702        } else {
4703            Value::Array(out.into_iter().map(Value::str).collect())
4704        }
4705    });
4706
4707    // c:Src/math.c:337 — `getmathparam` for ArithCompiler pre-load.
4708    // Pop a variable name, return its math-coerced value. Mirrors
4709    // the routing in math::getmathparam: try i64, then f64, then
4710    // recursive arith-eval, else 0. Bug #118 in docs/BUGS.md.
4711    vm.register_builtin(BUILTIN_GET_MATH_VAR, |vm, _argc| {
4712        let name = vm.pop().to_str();
4713        let raw = crate::ported::params::getsparam(&name).unwrap_or_default();
4714        // Empty / unset → 0.
4715        if raw.is_empty() {
4716            return Value::Int(0);
4717        }
4718        // Direct int / float parse.
4719        if let Ok(n) = raw.parse::<i64>() {
4720            return Value::Int(n);
4721        }
4722        if let Ok(f) = raw.parse::<f64>() {
4723            return Value::Float(f);
4724        }
4725        // Recursive arith eval (matches getmathparam fallback at
4726        // Src/math.c:337). If that fails too, return 0 — C's
4727        // mathevall returns 0 with errflag set on parse failure.
4728        match crate::ported::math::mathevali(&raw) {
4729            Ok(n) => Value::Int(n),
4730            Err(_) => Value::Int(0),
4731        }
4732    });
4733
4734    // c:Src/options.c GLOB_SUBST + Src/cond.c:552 cond_match.
4735    // Pop pattern string; when GLOB_SUBST is OFF, escape every glob
4736    // metachar with `\` so the downstream StrMatch + patcompile
4737    // treat them as literals (matching C's tokenization-based
4738    // gate). When GLOB_SUBST is ON, pass through unchanged.
4739    // See BUILTIN_GLOB_SUBST_GUARD docs above for full rationale.
4740    vm.register_builtin(BUILTIN_GLOB_SUBST_GUARD, |vm, _argc| {
4741        let p = vm.pop().to_str();
4742        let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
4743        if glob_subst {
4744            return Value::str(p);
4745        }
4746        let mut out = String::with_capacity(p.len() * 2);
4747        for c in p.chars() {
4748            match c {
4749                '*' | '?' | '[' | ']' | '(' | ')' | '|' | '<' | '>' | '#' | '^' | '~' | '\\' => {
4750                    out.push('\\');
4751                    out.push(c);
4752                }
4753                _ => out.push(c),
4754            }
4755        }
4756        Value::str(out)
4757    });
4758
4759    vm.register_builtin(BUILTIN_ARRAY_JOIN_STAR, |vm, _argc| {
4760        let name = vm.pop().to_str();
4761        let (joined, ifs_full, in_dq) = with_executor(|exec| {
4762            // c:Src/params.c — `"$*"` joins by IFS[0]. zsh
4763            // distinguishes IFS=unset (→ default `" "`) from
4764            // IFS="" (→ EMPTY separator → fields concatenate).
4765            // chars().next() collapsed both into the default, so
4766            // IFS="" was treated as IFS=" ".
4767            let ifs_full = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
4768            let sep = ifs_full
4769                .chars()
4770                .next()
4771                .map(|c| c.to_string())
4772                .unwrap_or_default();
4773            let in_dq = exec.in_dq_context > 0;
4774            let joined = if let Some(v) = crate::dash_mode::bash_special_array(&name) {
4775                // bash `"${PIPESTATUS[*]}"` / `"${FUNCNAME[*]}"` join.
4776                v.join(&sep)
4777            } else if name == "@" || name == "*" || name == "argv" {
4778                exec.pparams().join(&sep)
4779            } else if let Some(assoc_map) = exec.assoc(&name) {
4780                // c:Src/params.c — assoc-splat values for
4781                // `"${h[@]}"` / `"${h[*]}"`. Bug #109 in
4782                // docs/BUGS.md.
4783                assoc_map.values().cloned().collect::<Vec<_>>().join(&sep)
4784            } else if let Some(arr) = exec.array(&name) {
4785                // bash sparse arrays: `"${a[*]}"` joins only LIVE elements,
4786                // dropping hole slots. No-op in --zsh (no holes tracked).
4787                crate::bash_arrays::compact(&name, arr).join(&sep)
4788            } else {
4789                exec.get_variable(&name)
4790            };
4791            (joined, ifs_full, in_dq)
4792        });
4793        // c:Src/subst.c — UNQUOTED `${name[*]}` (or `$*`) goes
4794        // through the canonical "join via IFS[0], then word-split
4795        // via IFS" pipeline. The fast-path bypassed paramsubst
4796        // entirely so it never word-split, producing one joined
4797        // string instead of N argv entries. Bug #428.
4798        //
4799        // In QUOTED (`"${name[*]}"`) context, the result IS a
4800        // single scalar — return it as Str without splitting.
4801        if in_dq {
4802            return Value::str(joined);
4803        }
4804        if joined.is_empty() {
4805            return Value::Array(Vec::new());
4806        }
4807        // IFS word-split — every IFS char is a separator. Empty
4808        // resulting fields are dropped (the canonical
4809        // "remove empty unquoted words" pass from
4810        // Src/subst.c::prefork c:184-187).
4811        let parts: Vec<String> = joined
4812            .split(|c: char| ifs_full.contains(c))
4813            .filter(|s| !s.is_empty())
4814            .map(String::from)
4815            .collect();
4816        if parts.is_empty() {
4817            Value::Array(Vec::new())
4818        } else if parts.len() == 1 {
4819            Value::str(parts.into_iter().next().unwrap())
4820        } else {
4821            Value::Array(parts.into_iter().map(Value::str).collect())
4822        }
4823    });
4824
4825    vm.register_builtin(BUILTIN_ARRAY_ALL, |vm, _argc| {
4826        let name = vm.pop().to_str();
4827        // c:Src/params.c:2027-2029 — a `[@]`/`[*]` subscript sets
4828        // SCANPM_ISVAR_AT, i.e. `isarr != 0` (c:2915). An empty result is
4829        // therefore an empty ARRAY, and plan9 deletes the whole word
4830        // (c:4362) rather than keeping the surrounding text.
4831        note_empty_is_scalar(false);
4832        // bash `"${PIPESTATUS[@]}"` / `"${FUNCNAME[@]}"` / `"${BASH_VERSINFO[@]}"`
4833        // splat — alias the zsh-native special. No-op in --zsh.
4834        if let Some(v) = crate::dash_mode::bash_special_array(&name) {
4835            return Value::Array(v.into_iter().map(Value::str).collect());
4836        }
4837        with_executor(|exec| {
4838            // Special positional names — splice the positional list.
4839            if name == "@" || name == "*" || name == "argv" {
4840                return Value::Array(exec.pparams().iter().map(Value::str).collect());
4841            }
4842            // c:Src/Modules/parameter.c — funcstack/funcfiletrace/
4843            // funcsourcetrace/functrace are PM_ARRAY|PM_READONLY
4844            // specials backed by the canonical FUNCSTACK Vec.
4845            // `${funcstack[@]}` inside a function call should splat
4846            // the innermost-first names; without this branch the
4847            // runtime fell to the scalar fallback (get_variable
4848            // returns empty for these specials) and `[@]` came out
4849            // empty. Bug #276 in docs/BUGS.md. Mirrors the parallel
4850            // arrays_get handler at src/ported/subst.rs ~10685.
4851            // c:Src/Modules/datetime.c:256 — `epochtime` PM_ARRAY|
4852            // PM_READONLY backed by getcurrenttime(). Same parallel
4853            // arrangement as the FUNCSTACK-backed specials below.
4854            if name == "epochtime" {
4855                let arr = crate::ported::modules::datetime::getcurrenttime();
4856                return Value::Array(arr.into_iter().map(Value::str).collect());
4857            }
4858            if matches!(
4859                name.as_str(),
4860                "funcstack" | "funcfiletrace" | "funcsourcetrace" | "functrace"
4861            ) {
4862                // Route the three trace arrays through the canonical
4863                // ported getfns (Src/Modules/parameter.c:648/:679/:711)
4864                // — the previous inline copy emitted wrong shapes
4865                // (bare filename for funcfiletrace, `name:lineno` for
4866                // functrace instead of `caller:lineno`); same dedup as
4867                // the parallel arrays_get handler in subst.rs.
4868                let vals: Vec<String> = match name.as_str() {
4869                    "funcstack" => crate::ported::modules::parameter::FUNCSTACK
4870                        .lock()
4871                        .map(|f| f.iter().rev().map(|fs| fs.name.clone()).collect())
4872                        .unwrap_or_default(),
4873                    "funcfiletrace" => crate::ported::modules::parameter::funcfiletracegetfn(
4874                        std::ptr::null_mut(),
4875                    ),
4876                    "funcsourcetrace" => crate::ported::modules::parameter::funcsourcetracegetfn(
4877                        std::ptr::null_mut(),
4878                    ),
4879                    _ => crate::ported::modules::parameter::functracegetfn(std::ptr::null_mut()),
4880                };
4881                return Value::Array(vals.into_iter().map(Value::str).collect());
4882            }
4883            // c:Src/params.c — `${assoc[@]}` enumerates VALUES (per
4884            // params.c:1696-1750 hashparam splat). Check assoc
4885            // storage BEFORE the scalar fallback so an associative
4886            // array named X resolves `${X[@]}` to the values, not
4887            // empty. Bug #109 in docs/BUGS.md: `${h[@]}` on an
4888            // assoc routed through BUILTIN_ARRAY_ALL, which only
4889            // consulted `exec.array(name)` (the indexed-array map)
4890            // — that lookup missed for assocs, fell through to
4891            // `get_variable("h")` (also empty for an assoc-only
4892            // name), and returned `Array(vec![])`. zsh's expected
4893            // behavior is to enumerate values.
4894            if let Some(assoc_map) = exec.assoc(&name) {
4895                return Value::Array(
4896                    assoc_map.values().cloned().map(Value::str).collect(),
4897                );
4898            }
4899            match exec.array(&name) {
4900                Some(v) => {
4901                    // bash sparse arrays: `"${a[@]}"` splats only LIVE
4902                    // elements, dropping hole slots (`a[5]=q` padding,
4903                    // `unset a[i]`). No-op in --zsh (no holes tracked).
4904                    let v = crate::bash_arrays::compact(&name, v);
4905                    Value::Array(v.iter().map(Value::str).collect())
4906                }
4907                None => {
4908                    // Fall back to scalar lookup. zsh (unlike bash)
4909                    // does NOT IFS-split a scalar variable in a for
4910                    // list — `for w in $scalar` iterates ONCE with the
4911                    // scalar value. Word-splitting requires either
4912                    // sh_word_split option or explicit `${(s.,.)scalar}`.
4913                    let val = exec.get_variable(&name);
4914                    if val.is_empty() && !exec.has_scalar(&name) && env::var(&name).is_err() {
4915                        // c:Src/subst.c:3480-3485 — `${arr[@]}` on a genuinely
4916                        // UNSET parameter under NO_UNSET is a "parameter not set"
4917                        // error (vunset > 0 && unset(UNSET)), exactly like the
4918                        // scalar `$arr`, the `${arr[*]}` splat, and `${arr[1]}`
4919                        // — all of which already fire it via GET_VAR. The `[@]`
4920                        // splat path returned an empty array silently, so
4921                        // `setopt NO_UNSET; print "${arr[@]}"` exited 0 where zsh
4922                        // exits 1. A DECLARED-but-empty array (`arr=()`) resolves
4923                        // to `Some(vec![])` above and never reaches here, so it
4924                        // still splats to nothing without erroring — matching zsh.
4925                        if opt_state_get("nounset").unwrap_or(false) {
4926                            crate::ported::utils::zerr(&format!("{}: parameter not set", name));
4927                            crate::ported::utils::errflag.fetch_or(
4928                                crate::ported::zsh_h::ERRFLAG_ERROR,
4929                                std::sync::atomic::Ordering::Relaxed,
4930                            );
4931                            exec.set_last_status(1);
4932                        }
4933                        // c:Src/subst.c:3480-3485 — an UNSET parameter takes the
4934                        // `vunset` arm: `val = dupstring("")` with isarr left at
4935                        // 0. That is a SCALAR empty, so plan9 keeps the
4936                        // surrounding text (`setopt rcexpandparam;
4937                        // print -r -- "[${unset[@]}]"` → `[]`), unlike a
4938                        // DECLARED-but-empty array (`arr=()`, matched by the
4939                        // `Some(vec![])` arm above), which sets isarr and gets
4940                        // the word deleted at c:4362.
4941                        note_empty_is_scalar(true);
4942                        Value::Array(vec![])
4943                    } else if opt_state_get("shwordsplit").unwrap_or(false) {
4944                        // c:3921 `aval = sepsplit(val, spsep, 0, 1)` — same
4945                        // splitter as `${=name}` (Src/utils.c:3711 spacesplit),
4946                        // not a naive `split().filter(non-empty)`: only the
4947                        // IFS-WHITESPACE-derived empty fields are elided; the
4948                        // `nulstring` ones an IFS-NON-whitespace separator makes
4949                        // survive (c:Src/subst.c:36).
4950                        let nulstring = crate::ported::zsh_h::Nularg.to_string();
4951                        let parts: Vec<Value> =
4952                            crate::ported::utils::sepsplit(&val, None, false)
4953                                .into_iter()
4954                                .filter_map(|w| {
4955                                    if w == nulstring {
4956                                        Some(Value::str(String::new()))
4957                                    } else if w.is_empty() {
4958                                        None // c:184-187 prefork uremnode
4959                                    } else {
4960                                        Some(Value::str(w))
4961                                    }
4962                                })
4963                                .collect();
4964                        Value::Array(parts)
4965                    } else {
4966                        Value::Array(vec![Value::str(val)])
4967                    }
4968                }
4969            }
4970        })
4971    });
4972
4973    // BUILTIN_ARRAY_FLATTEN(N): pops N values, flattens one level of Array
4974    // nesting, pushes the resulting Array AND its length as a separate Int.
4975    // The two-value return shape lets the caller (for-loop compile path)
4976    // SetSlot the length before SetSlot'ing the array, without re-deriving
4977    // the length from the array via a second builtin call.
4978    // `coproc [name] { body }` — bidirectional pipe to backgrounded body.
4979    // Stack discipline (top first): [name (str, "" for default), sub_idx (int)].
4980    // On success: parent's `executor.arrays[name]` becomes [write_fd, read_fd]
4981    // and Status(0) is returned. The caller writes to the child's stdin via
4982    // write_fd, reads its stdout via read_fd, and closes both when done.
4983    //
4984    // Bash's coproc convention is `${NAME[0]}` = read_fd, `${NAME[1]}` =
4985    // write_fd. We follow that: arrays[name] = [read_fd_str, write_fd_str].
4986    vm.register_builtin(BUILTIN_RUN_COPROC, |vm, _argc| {
4987        let sub_idx = vm.pop().to_int() as usize;
4988        let job_text = vm.pop().to_str();
4989        let raw_name = vm.pop().to_str();
4990        let name = if raw_name.is_empty() {
4991            "COPROC".to_string()
4992        } else {
4993            raw_name
4994        };
4995        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
4996            Some(c) => c,
4997            None => return Value::Status(1),
4998        };
4999
5000        // c:Src/exec.c:1710-1712 — starting a new coproc closes the
5001        // previous one's fds FIRST:
5002        //     if (coprocin >= 0) { zclose(coprocin); zclose(coprocout); }
5003        // The old coproc child then sees EOF on its stdin and exits on
5004        // its own schedule (its job-table entry stays until it's
5005        // reaped) — zsh does NOT deletejob it here. This is also what
5006        // makes the `exec 4<&p; coproc exit; read -u4` EOF idiom work:
5007        // the replacement coproc closes the shell's write end to the
5008        // old one.
5009        {
5010            use std::sync::atomic::Ordering;
5011            let old_in = crate::ported::modules::clone::coprocin.load(Ordering::Relaxed);
5012            if old_in >= 0 {
5013                let old_out = crate::ported::modules::clone::coprocout.load(Ordering::Relaxed);
5014                unsafe {
5015                    libc::close(old_in);
5016                    if old_out >= 0 {
5017                        libc::close(old_out);
5018                    }
5019                }
5020                crate::ported::modules::clone::coprocin.store(-1, Ordering::Relaxed);
5021                crate::ported::modules::clone::coprocout.store(-1, Ordering::Relaxed);
5022            }
5023        }
5024
5025        // (parent_read ← child_stdout)
5026        let mut p2c = [0i32; 2]; // parent writes, child reads
5027        let mut c2p = [0i32; 2]; // child writes, parent reads
5028        if unsafe { libc::pipe(p2c.as_mut_ptr()) } < 0 {
5029            return Value::Status(1);
5030        }
5031        if unsafe { libc::pipe(c2p.as_mut_ptr()) } < 0 {
5032            unsafe {
5033                libc::close(p2c[0]);
5034                libc::close(p2c[1]);
5035            }
5036            return Value::Status(1);
5037        }
5038        // c:Src/exec.c:5160 mpipe — both pipes' fds are moved above
5039        // the user-visible range (movefd → F_DUPFD ≥ 10) so the
5040        // coproc fds never collide with explicit user fds like
5041        // `exec 3>&p`.
5042        for fd in p2c.iter_mut().chain(c2p.iter_mut()) {
5043            *fd = crate::ported::utils::movefd(*fd);
5044        }
5045
5046        match unsafe { libc::fork() } {
5047            -1 => {
5048                unsafe {
5049                    libc::close(p2c[0]);
5050                    libc::close(p2c[1]);
5051                    libc::close(c2p[0]);
5052                    libc::close(c2p[1]);
5053                }
5054                Value::Status(1)
5055            }
5056            0 => {
5057                // Child: stdin from p2c[0], stdout to c2p[1]. Close all
5058                // unused fds. setsid so SIGINT to fg doesn't hit us.
5059                unsafe {
5060                    libc::dup2(p2c[0], libc::STDIN_FILENO);
5061                    libc::dup2(c2p[1], libc::STDOUT_FILENO);
5062                    libc::close(p2c[0]);
5063                    libc::close(p2c[1]);
5064                    libc::close(c2p[0]);
5065                    libc::close(c2p[1]);
5066                    libc::setsid();
5067                }
5068                crate::fusevm_disasm::maybe_print_stdout("coproc:child", &chunk);
5069                let mut co_vm = fusevm::VM::new(chunk);
5070                register_builtins(&mut co_vm);
5071                let _ = co_vm.run();
5072                let _ = std::io::stdout().flush();
5073                let _ = std::io::stderr().flush();
5074                std::process::exit(co_vm.last_status);
5075            }
5076            pid => {
5077                // Parent: close child ends, store [read_fd, write_fd] in NAME.
5078                unsafe {
5079                    libc::close(p2c[0]);
5080                    libc::close(c2p[1]);
5081                }
5082                let read_fd = c2p[0];
5083                let write_fd = p2c[1];
5084                with_executor(|exec| {
5085                    exec.unset_scalar(&name);
5086                    exec.set_array(name, vec![read_fd.to_string(), write_fd.to_string()]);
5087                });
5088                // c:Src/exec.c — `coprocin`/`coprocout` are the
5089                // canonical globals that bin_read's `-p` arm
5090                // (Src/builtin.c:6510) and bin_print's `-p` arm
5091                // (Src/builtin.c:4827) read to find the
5092                // coprocess fds. The Rust port has the atomic
5093                // declarations at src/ported/modules/clone.rs:262
5094                // but the coproc-launch path never updated them,
5095                // so `read -p` / `print -p` always errored with
5096                // "-p: no coprocess" even when a coproc was
5097                // running. Bug #388 in docs/BUGS.md. Update them
5098                // here so the canonical builtins find the live
5099                // pipe.
5100                crate::ported::modules::clone::coprocin
5101                    .store(read_fd, std::sync::atomic::Ordering::Relaxed);
5102                crate::ported::modules::clone::coprocout
5103                    .store(write_fd, std::sync::atomic::Ordering::Relaxed);
5104                // c:Src/exec.c:1725 — `fdtable[coprocin] =
5105                // fdtable[coprocout] = FDT_UNUSED;`: the two kept ends
5106                // are user-reachable (via `>&p` / `<&p`), so they drop
5107                // the FDT_INTERNAL mark movefd gave them.
5108                crate::ported::utils::fdtable_set(read_fd, crate::ported::zsh_h::FDT_UNUSED);
5109                crate::ported::utils::fdtable_set(write_fd, crate::ported::zsh_h::FDT_UNUSED);
5110                // c:Src/exec.c:2837 — `lastpid = (zlong) pid;`. zsh
5111                // sets the `$!` global to the coproc child's PID so
5112                // subsequent `$!` reads return it. The Rust port at
5113                // exec.rs:6773 mirrors this for regular background
5114                // jobs but the coproc launch path was missing the
5115                // assignment, leaving `$!` at 0 after `coproc cmd`.
5116                crate::ported::modules::clone::lastpid
5117                    .store(pid, std::sync::atomic::Ordering::Relaxed);
5118                // c:Src/exec.c:1700-1758 — the coproc rides the SAME
5119                // Z_ASYNC job-table path as `cmd &`: `thisjob = newjob
5120                // = initjob()` (c:1700), addproc hangs the pid+text
5121                // proc entry off the job, `jobtab[thisjob].stat |=
5122                // STAT_NOSTTY` (c:1746), `clearoldjobtab()` (c:1744)
5123                // and `spawnjob()` (c:1758) promote it to curjob. This
5124                // is what makes `jobs` list the coproc as
5125                // `[1]  + running    cat` and `kill %1` resolve it.
5126                // Mirrors the BUILTIN_RUN_BG parent arm exactly.
5127                {
5128                    use crate::ported::jobs;
5129                    use std::sync::Mutex;
5130                    let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
5131                    let idx = {
5132                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
5133                        let idx = jobs::initjob(&mut tab); // c:exec.c:1700
5134                        jobs::addproc(
5135                            &mut tab[idx],
5136                            pid,
5137                            &job_text,
5138                            false,
5139                            Some(std::time::Instant::now()),
5140                            -1,
5141                            -1,
5142                        );
5143                        tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
5144                        idx
5145                    };
5146                    jobs::clearoldjobtab(); // c:exec.c:1744
5147                    if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
5148                        *tj = idx as i32;
5149                    }
5150                    jobs::spawnjob(); // c:exec.c:1758
5151                }
5152                with_executor(|exec| {
5153                    exec.jobs
5154                        .add_pid_job(pid, job_text.clone(), JobState::Running);
5155                });
5156                Value::Status(0)
5157            }
5158        }
5159    });
5160
5161    vm.register_builtin(BUILTIN_ARRAY_FLATTEN, |vm, argc| {
5162        let n = argc as usize;
5163        let start = vm.stack.len().saturating_sub(n);
5164        let raw: Vec<Value> = vm.stack.drain(start..).collect();
5165        let mut flat: Vec<Value> = Vec::with_capacity(raw.len());
5166        for v in raw {
5167            match v {
5168                Value::Array(items) => flat.extend(items),
5169                other => flat.push(other),
5170            }
5171        }
5172        let len = flat.len() as i64;
5173        // Push the array first; the Int(len) becomes the builtin's return
5174        // value (which CallBuiltin already pushes). Caller consumes in
5175        // reverse: SetSlot(len_slot) pops Int, SetSlot(arr_slot) pops Array.
5176        vm.push(Value::Array(flat));
5177        Value::Int(len)
5178    });
5179
5180    // Shell variable get/set — routes through executor.variables so nested
5181    // VMs (function calls) and tree-walker callers see the same storage.
5182    // GET_VAR / GET_VAR_DQ share one body via `get_var_impl`; the only
5183    // difference is `force_dq`, which the compiler sets for QUOTED simple
5184    // reads (`"$name"`) so an array's empty elements are preserved (the
5185    // `in_dq_context` runtime flag is 0 for these compiler-direct reads).
5186    fn get_var_impl(vm: &mut fusevm::VM, argc: u8, force_dq: bool) -> Value {
5187        let args = pop_args(vm, argc);
5188        let name = args.into_iter().next().unwrap_or_default();
5189        let live_status = vm.last_status;
5190        // `$@` and `$*` need splice semantics — return Value::Array of
5191        // positional params so for-loop's BUILTIN_ARRAY_FLATTEN spreads them
5192        // and pop_args splits them into argv slots. zsh's `"$@"` bslashquote-each-
5193        // word semantics matches: each pos-param becomes its own arg.
5194        // Same for arrays accessed by name (e.g. `$arr` in some contexts).
5195        //
5196        // vm.last_status is authoritative: `subshell_end` now returns
5197        // Some(status) and fusevm's `Op::SubshellEnd` writes it into
5198        // vm.last_status, so a deferred subshell `exit N` is visible
5199        // here. Suppressing this sync (as an older revision did, back
5200        // when the host hook returned nothing) made LASTVAL win over
5201        // any status the VM set AFTER SubshellEnd — which dropped the
5202        // `!` negation of `Src/exec.c:1979-1980`
5203        //   if ((slflags & WC_SUBLIST_NOT) && !errflag && !retflag)
5204        //       lastval = !lastval;
5205        // for `! (exit 7)` (emit_negate_status' SetStatus updated
5206        // vm.last_status, then `$?` read the stale LASTVAL=7).
5207        let sync_status = |exec: &mut ShellExecutor| {
5208            exec.set_last_status(live_status);
5209        };
5210        if name == "@" || name == "*" {
5211            // Quoting decides empty-word retention (c:Src/subst.c:
5212            // 184-187): the COMPILE site knows it and emits
5213            // BUILTIN_ARRAY_DROP_EMPTY after this read for the
5214            // unquoted form only — in_dq_context is NOT a valid
5215            // discriminator here (the quoted "$@" fast path emits
5216            // GET_VAR directly without an EXPAND_TEXT wrapper).
5217            return with_executor(|exec| {
5218                sync_status(exec);
5219                Value::Array(exec.pparams().iter().map(Value::str).collect())
5220            });
5221        }
5222        // RC_EXPAND_PARAM: when the option is set and `name` refers to
5223        // an array, return Value::Array so the enclosing word's
5224        // BUILTIN_CONCAT_DISTRIBUTE distributes element-wise. Without
5225        // the option, arrays still join to a space-separated scalar
5226        // (zsh's default unquoted-array-as-scalar semantics).
5227        let rc_expand = with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
5228        // c:Src/subst.c — under KSHARRAYS a bare `$name` (no [@]/[*] subscript;
5229        // this GET_VAR path only handles the bare form) is element 1 ONLY — a
5230        // scalar. RC_EXPAND_PARAM then has a single value to distribute, so
5231        // `$acc` → "p1", NOT the whole array. Skip the whole-array rc_expand
5232        // shortcut when KSHARRAYS is set and fall through to the normal path,
5233        // which applies the element-1 collapse. Without this gate,
5234        // `setopt KSH_ARRAYS rc_expand_param; print -r -- $acc` splatted every
5235        // element while zsh prints just "p1".
5236        let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
5237        if rc_expand && !ksh_arrays {
5238            let arr_val = with_executor(|exec| {
5239                sync_status(exec);
5240                exec.array(&name)
5241            });
5242            if let Some(arr) = arr_val {
5243                // c:4245 — a real array reference (`isarr != 0`). An empty one
5244                // takes plan9's word-removal path (c:4362), so clear the
5245                // scalar bit; a preceding empty-SCALAR expansion in the same
5246                // word would otherwise leave it set and keep the word alive
5247                // (`empty=''; e=(); a=("$empty"); print -rl -- x$e y`).
5248                note_empty_is_scalar(false);
5249                return Value::Array(arr.into_iter().map(Value::str).collect());
5250            }
5251        }
5252        // Magic-assoc fallback FIRST — `${aliases}` / `${functions}`
5253        // / `${commands}` / etc. should return the value list per
5254        // zsh's bare-assoc semantics. Without this, those names fell
5255        // through to `get_variable` which is empty (they live in
5256        // separate executor tables, not `assoc_arrays`). Return as
5257        // a Value::Array so `arr=(${aliases})` distributes into
5258        // multiple elements, matching zsh's array-context word
5259        // splitting for assoc-bare references.
5260        let magic_vals = with_executor(|exec| {
5261            sync_status(exec);
5262            // Canonical PARTAB dispatch (Src/Modules/parameter.c:2235-
5263            // 2298 + SPECIALPMDEFs in mapfile/terminfo/termcap/system/
5264            // zleparameter): PARTAB_ARRAY entries → whole-array getfn;
5265            // PARTAB entries → scan keys + per-key getpm/scanpm fn
5266            // pointers.
5267            let _ = exec;
5268            if let Some(values) = partab_array_get(&name) {
5269                Some(values)
5270            } else if let Some(keys) = partab_scan_keys(&name) {
5271                Some(
5272                    keys.iter()
5273                        .map(|k| partab_get(&name, k).unwrap_or_default())
5274                        .collect::<Vec<_>>(),
5275                )
5276            } else {
5277                None
5278            }
5279        });
5280        if let Some(vals) = magic_vals {
5281            // Distinguish "name IS a magic-assoc with no entries"
5282            // (return Array(empty)) from "name is unknown — fall
5283            // through to get_variable".
5284            // c:Src/params.c:2293-2296 — KSHARRAYS bare reference
5285            // collapses to the FIRST element in scan order
5286            // (`v->end = 1, v->isarr = 0`). For `options` the scan
5287            // order is optiontab bucket order (OPTIONTAB), so zsh 5.9
5288            // prints `off` (posixargzero) for
5289            // `setopt ksharrays; print $options`.
5290            if opt_state_get("ksharrays").unwrap_or(false) {
5291                return Value::str(vals.into_iter().next().unwrap_or_default());
5292            }
5293            return Value::Array(vals.into_iter().map(Value::str).collect());
5294        }
5295        // Indexed-array path: return Value::Array so pop_args splats
5296        // each element into its own argv slot. Direct port of zsh's
5297        // unquoted `$arr` semantics — each element becomes a separate
5298        // word in command-arg position.
5299        //
5300        // DQ context exception: inside `"...$arr..."`, zsh joins with
5301        // the first char of $IFS (default space) so the DQ word stays
5302        // a single argv slot. Detect via in_dq_context (bumped by
5303        // BUILTIN_EXPAND_TEXT mode 1) and return the joined scalar.
5304        // Direct port of Src/subst.c:1759-1813 nojoin/sepjoin: in DQ
5305        // (qt=1) without explicit `(@)`, sepjoin runs and the result
5306        // is one word.
5307        let arr_assoc_data = with_executor(|exec| {
5308            sync_status(exec);
5309            let in_dq = force_dq || exec.in_dq_context > 0;
5310            // KSH_ARRAYS: bare `$arr` returns ONLY arr[0] (zero-
5311            // based first-element-only semantics). Direct port of
5312            // Src/params.c getstrvalue's KSH_ARRAYS gate which
5313            // returns aval[0] instead of the whole array.
5314            let ksh_arrays = opt_state_get("ksharrays").unwrap_or(false);
5315            if let Some(arr) = exec.array(&name) {
5316                if ksh_arrays {
5317                    return Some((vec![arr.first().cloned().unwrap_or_default()], in_dq));
5318                }
5319                return Some((arr.clone(), in_dq));
5320            }
5321            if exec.assoc(&name).is_some() {
5322                // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
5323                // `$assoc` is `${assoc[0]}` (a KEY-"0" lookup), so it is
5324                // EMPTY unless the hash actually has a key "0". This is
5325                // EMULATION-gated, not KSHARRAYS-option-gated: `emulate -L
5326                // ksh; typeset -A h=(a 1 b 2); print $h` is empty, whereas
5327                // `setopt ksharrays; …; print $h` collapses to the bucket-
5328                // first value below.
5329                if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
5330                    let v = crate::ported::subst::assoc_get(&name)
5331                        .and_then(|m| m.get("0").cloned())
5332                        .unwrap_or_default();
5333                    return Some((vec![v], in_dq));
5334                }
5335                // c:Src/hashtable.c scanhashtable — a bare `$assoc` joins its
5336                // VALUES in zsh hash-BUCKET order (the same order `(k)`/`(v)`
5337                // enumerate), NOT sorted or insertion order. `assoc_get`
5338                // rebuilds zsh's bucket layout; use it so `$as` matches
5339                // `${(v)as}` (`as=(zebra 9 apple 1)` → `9 1`, not the
5340                // alphabetical `1 9`). Under KSHARRAYS the bare form collapses
5341                // to the bucket-FIRST value (`9`), matching zsh.
5342                let values: Vec<String> = crate::ported::subst::assoc_get(&name)
5343                    .map(|m| m.values().cloned().collect())
5344                    .unwrap_or_default();
5345                if ksh_arrays {
5346                    return Some((vec![values.into_iter().next().unwrap_or_default()], in_dq));
5347                }
5348                return Some((values, in_dq));
5349            }
5350            None
5351        });
5352        if let Some((items, in_dq)) = arr_assoc_data {
5353            // c:Src/subst.c:184-187 — prefork's `else if (!keep)
5354            // uremnode(list, node)`: UNQUOTED expansion drops empty
5355            // list nodes before they reach argv, so `a=(y '' x);
5356            // print -- $a` passes TWO args in zsh (`y x`), while the
5357            // quoted "${a[@]}" splat keeps the empty slot. The
5358            // paramsubst splat path already does this (Bug #578
5359            // retain); this GET_VAR fast path bypassed it and leaked
5360            // empty argv slots (visible double-space, wrong arg
5361            // counts in `for`/`print -l`).
5362            let items: Vec<String> = if in_dq {
5363                items
5364            } else {
5365                items.into_iter().filter(|s| !s.is_empty()).collect()
5366            };
5367            if in_dq {
5368                // c:Src/utils.c:3936-3945 sepjoin default-sep rule:
5369                // set-but-empty IFS joins with "" (`IFS=""; echo
5370                // "$arr"` concatenates); only unset / space-leading
5371                // IFS yields " ". The previous get_variable read
5372                // couldn't distinguish unset from set-empty.
5373                return Value::str(crate::ported::utils::sepjoin(&items, None));
5374            }
5375            // c:4245 — a real array reference: `isarr != 0`, so an empty
5376            // one takes plan9's word-removal path, not the scalar path.
5377            note_empty_is_scalar(false);
5378            return Value::Array(items.into_iter().map(Value::str).collect());
5379        }
5380        let (val, in_dq, is_known) = with_executor(|exec| {
5381            sync_status(exec);
5382            let v = exec.get_variable(&name);
5383            // For nounset detection: a name is "known" when it has a
5384            // paramtab/array/assoc/env entry. Special chars ($?, $#,
5385            // $@, $*, $-, $$, $!, $_, $0) always count as known
5386            // regardless of value. Pure-digit positional params
5387            // count as known iff index <= $# (set -- has populated
5388            // that slot). c:Src/subst.c:1689 — NOUNSET fires on
5389            // unset positional param too: `set --; echo "$1"` with
5390            // nounset must diagnose.
5391            let is_special_single = name.len() == 1
5392                && matches!(
5393                    name.chars().next().unwrap(),
5394                    '?' | '#' | '@' | '*' | '-' | '$' | '!' | '_' | '0'
5395                );
5396            let is_pure_digit = !name.is_empty() && name.chars().all(|c| c.is_ascii_digit());
5397            let positional_known = if is_pure_digit {
5398                let idx: usize = name.parse().unwrap_or(0);
5399                if idx == 0 {
5400                    true // $0 always set
5401                } else {
5402                    idx <= exec.pparams().len()
5403                }
5404            } else {
5405                false
5406            };
5407            let known = !v.is_empty()
5408                || name.is_empty()
5409                || is_special_single
5410                || positional_known
5411                || crate::ported::params::paramtab()
5412                    .read()
5413                    .ok()
5414                    .map(|t| t.contains_key(&name))
5415                    .unwrap_or(false)
5416                || env::var(&name).is_ok();
5417            (v, force_dq || exec.in_dq_context > 0, known)
5418        });
5419        // c:Src/subst.c:1689 — NO_UNSET / nounset: reading an unset
5420        // parameter fires "parameter not set" diagnostic and aborts
5421        // the substitution. Direct port of the noerrs gate at c:1689
5422        // (zerr + errflag). Matches `set -u` POSIX semantics.
5423        if !is_known && opt_state_get("nounset").unwrap_or(false) {
5424            crate::ported::utils::zerr(&format!("{}: parameter not set", name));
5425            crate::ported::utils::errflag.fetch_or(
5426                crate::ported::zsh_h::ERRFLAG_ERROR,
5427                std::sync::atomic::Ordering::Relaxed,
5428            );
5429            with_executor(|exec| exec.set_last_status(1));
5430            return Value::str("");
5431        }
5432        // Empty unquoted scalar → drop the arg (zsh "remove empty
5433        // unquoted words" rule). Returning empty Value::Array makes
5434        // pop_args contribute zero items. DQ context keeps the empty
5435        // string so "$a" stays a single empty arg. Direct port of
5436        // subst.c's elide-empty pass.
5437        if val.is_empty() && !in_dq {
5438            // c:1650-1656 / c:4437 — a SCALAR parameter has `isarr == 0`,
5439            // so it never reaches plan9's word-removal at c:4362. Flag the
5440            // empty Array below as a scalar so `setopt rcexpandparam;
5441            // v=; print -rl -- x$v y` still emits `x` (only an empty
5442            // ARRAY deletes the word).
5443            note_empty_is_scalar(true);
5444            return Value::Array(Vec::new());
5445        }
5446        // c:Src/subst.c:1759 SH_WORD_SPLIT — when shwordsplit is set and
5447        // we're in unquoted command-arg position (not DQ), split scalar
5448        // value on IFS into multiple words. Matches BUILTIN_ARRAY_ALL's
5449        // shwordsplit arm (fusevm_bridge.rs:2200). Without this, bare
5450        // `$s` in `print $s` stayed a single arg even with the option
5451        // set, breaking POSIX-style scalar word-splitting.
5452        if !in_dq && opt_state_get("shwordsplit").unwrap_or(false) {
5453            // c:1705 — `spbreak = (pf_flags & PREFORK_SHWORDSPLIT) && !qt`,
5454            // then c:3902 `force_split = !ssub && (spbreak || spsep)` and
5455            // c:3921 `aval = sepsplit(val, spsep, 0, 1)`. SH_WORD_SPLIT runs
5456            // the SAME splitter as `${=name}`, so route it through the same
5457            // port. The previous `split(|c| ifs.contains(c)).filter(non-empty)`
5458            // dropped every empty field, but spacesplit (Src/utils.c:3711)
5459            // only elides the ones a run of IFS-WHITESPACE produces — an
5460            // IFS-NON-whitespace separator preserves them as `nulstring`.
5461            // `IFS=x; v=xaxbx; setopt shwordsplit; print -rl -- $v` is four
5462            // words in zsh (``, a, b, ``), not two.
5463            let raw = crate::ported::utils::sepsplit(&val, None, false); // c:3921
5464            let nulstring = crate::ported::zsh_h::Nularg.to_string(); // c:36
5465            let parts: Vec<Value> = raw
5466                .into_iter()
5467                .filter_map(|w| {
5468                    if w == nulstring {
5469                        Some(Value::str(String::new()))
5470                    } else if w.is_empty() {
5471                        // c:184-187 — prefork deletes the truly-empty node.
5472                        None
5473                    } else {
5474                        Some(Value::str(w))
5475                    }
5476                })
5477                .collect();
5478            if parts.is_empty() {
5479                // c:3922 — `val = dupstring("")`: an empty SCALAR, not an
5480                // empty array (see EMPTY_EXPANSION_IS_SCALAR).
5481                note_empty_is_scalar(true);
5482                return Value::Array(Vec::new());
5483            } else if parts.len() == 1 {
5484                // c:3924 — `else if (!aval[1]) val = aval[0];`
5485                return parts.into_iter().next().unwrap();
5486            } else {
5487                return Value::Array(parts); // c:3927
5488            }
5489        }
5490        Value::str(val)
5491    }
5492    vm.register_builtin(BUILTIN_GET_VAR, |vm, argc| get_var_impl(vm, argc, false));
5493    vm.register_builtin(BUILTIN_GET_VAR_DQ, |vm, argc| get_var_impl(vm, argc, true));
5494
5495    // `name+=val` (no parens) — runtime dispatch:
5496    //   - if `name` is in `arrays` → push `val` as new element
5497    //   - if `name` is in `assoc_arrays` → refuse (zsh errors here)
5498    //   - else → scalar concat (existing behavior)
5499    // Stack: [name, value].
5500    vm.register_builtin(BUILTIN_APPEND_SCALAR_OR_PUSH, |vm, argc| {
5501        let args = pop_args(vm, argc);
5502        let mut iter = args.into_iter();
5503        let name = iter.next().unwrap_or_default();
5504        let value = iter.next().unwrap_or_default();
5505        with_executor(|exec| {
5506            // Array form: `arr+=elem` pushes a single element.
5507            // Routes through canonical assignaparam(name, [value],
5508            // ASSPM_AUGMENT) — Src/params.c:3357 c:3402-3412 augment
5509            // path prepends prior scalar / appends to existing array.
5510            // Existence probe uses the non-cloning `has_array` — the
5511            // owning `exec.array()` clone here made `arr+=x` in a loop
5512            // O(n²) (see the assoc-store fix above).
5513            if exec.has_array(&name) {
5514                // c:Src/params.c — under KSHARRAYS a bare array name
5515                // addresses element 0 (ksh), so `a+=X` (scalar augment)
5516                // CONCATENATES onto the first element ("firstlast second"),
5517                // it does NOT push a new element. C routes scalar `+=`
5518                // through assignsparam (which targets the elem-0 value);
5519                // zshrs's APPEND_SCALAR_OR_PUSH would otherwise push.
5520                if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
5521                    let mut arr = exec.array(&name).unwrap_or_default();
5522                    if arr.is_empty() {
5523                        arr.push(value.clone());
5524                    } else {
5525                        arr[0] = format!("{}{}", arr[0], value);
5526                    }
5527                    exec.set_array(name.clone(), arr);
5528                    #[cfg(feature = "recorder")]
5529                    if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5530                        let ctx = exec.recorder_ctx();
5531                        let attrs = exec.recorder_attrs_for(&name);
5532                        emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
5533                    }
5534                    return;
5535                }
5536                let _ = crate::ported::params::assignaparam(
5537                    &name,
5538                    vec![value.clone()],
5539                    crate::ported::zsh_h::ASSPM_AUGMENT,
5540                );
5541                #[cfg(feature = "recorder")]
5542                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5543                    let ctx = exec.recorder_ctx();
5544                    let attrs = exec.recorder_attrs_for(&name);
5545                    emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
5546                }
5547                return;
5548            }
5549            if exec.has_assoc(&name) {
5550                eprintln!("zshrs: {}: cannot use += on assoc without (key val)", name);
5551                return;
5552            }
5553            // Scalar / integer / float form: route through canonical
5554            // assignsparam(name, value, ASSPM_AUGMENT) which
5555            // dispatches PM_TYPE — PM_SCALAR concats, PM_INTEGER
5556            // arith-adds (c:2775-2778), PM_FLOAT float-adds.
5557            let _ = crate::ported::params::assignsparam(
5558                &name,
5559                &value,
5560                crate::ported::zsh_h::ASSPM_AUGMENT,
5561            );
5562            #[cfg(feature = "recorder")]
5563            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
5564                let ctx = exec.recorder_ctx();
5565                let attrs = exec.recorder_attrs_for(&name);
5566                // Re-read the canonical value via get_variable for the
5567                // recorder bundle (assignsparam may have transformed it
5568                // through integer/float arithmetic).
5569                let final_val = exec.get_variable(&name);
5570                let lower = name.to_ascii_lowercase();
5571                if matches!(
5572                    lower.as_str(),
5573                    "path" | "fpath" | "manpath" | "module_path" | "cdpath"
5574                ) {
5575                    emit_path_or_assign(&name, std::slice::from_ref(&final_val), attrs, true, &ctx);
5576                } else {
5577                    crate::recorder::emit_assign_typed(&name, &final_val, attrs, ctx);
5578                }
5579            }
5580        });
5581        Value::Status(0)
5582    });
5583
5584    // BUILTIN_SET_VAR — `name=value` runtime scalar assignment.
5585    // PURE PASSTHRU: hand to canonical `setsparam` (C port of
5586    // `Src/params.c::setsparam`). That walks assignsparam →
5587    // assignstrvalue which already does:
5588    //   - readonly rejection (zerr + errflag at c:2701)
5589    //   - PM_INTEGER math evaluation (mathevali at c:3590)
5590    //   - PM_EFLOAT / PM_FFLOAT float coercion (c:3608)
5591    //   - PM_LOWER / PM_UPPER case fold (via setstrvalue)
5592    //   - GSU special-param dispatch (homesetfn / ifssetfn / etc.)
5593    //   - allexport env mirror via the PM_EXPORTED setfn
5594    //
5595    // Bridge-only concerns kept here:
5596    //   - inline_env_stack (zsh `X=foo cmd` scoped env)
5597    //   - recorder emission (PFA-SMR)
5598    //   - vm.last_status propagation for `a=$(cmd)` exit-code chaining
5599    // Sets the GLOB_ASSIGN-eligibility flag consumed by the NEXT BUILTIN_SET_VAR.
5600    // Emitted only when a scalar-assign RHS had an unquoted glob token. Takes no
5601    // args; its pushed return is discarded by a following Op::Pop.
5602    vm.register_builtin(BUILTIN_MARK_GLOB_ELIGIBLE, |_vm, _argc| {
5603        SET_VAR_GLOB_ELIGIBLE.with(|c| c.set(true));
5604        fusevm::Value::Int(0)
5605    });
5606    vm.register_builtin(BUILTIN_SET_VAR, |vm, argc| {
5607        // `${~spec}` carrier: an assignment statement is a word-
5608        // pipeline boundary too — restore the user's GLOB_SUBST
5609        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
5610        // ${options[globsubst]}` must read the user value).
5611        consume_tilde_globsubst_carrier();
5612        // Snapshot the raw Values BEFORE pop_args's to_str
5613        // flattening — needed to distinguish Int (arith assignment,
5614        // integer-typed param) from Str (scalar assignment).
5615        let mut raw_values: Vec<fusevm::Value> = Vec::with_capacity(argc as usize);
5616        for _ in 0..argc {
5617            raw_values.push(vm.pop());
5618        }
5619        raw_values.reverse();
5620        let name = raw_values.first().map(|v| v.to_str()).unwrap_or_default();
5621        let value_raw = raw_values.get(1).cloned();
5622        let value = value_raw.as_ref().map(|v| v.to_str()).unwrap_or_default();
5623        // c:Src/params.c — when the bytecode hands us an Int value
5624        // (only the arith assignment paths emit this — `(( X = N ))`
5625        // is the canonical site), route through setiparam so the
5626        // param ends up PM_INTEGER + inherits the math layer's
5627        // `lastbase` for display formatting (`(( X = 16#ff ));
5628        // echo \$X` → `16#FF`). Scalar `X=val` and `$((expr))`
5629        // assignments still take the setsparam path below.
5630        let int_assign = matches!(value_raw, Some(fusevm::Value::Int(_)));
5631        let float_assign = matches!(value_raw, Some(fusevm::Value::Float(_)));
5632        let mut assign_failed = false;
5633        with_executor(|exec| {
5634            // c:Src/params.c assignsparam — PM_READONLY rejection
5635            // BEFORE any env mutation. The inline-env-prefix path
5636            // (`X=2 env`) called env::set_var unconditionally before
5637            // the readonly check fired in setsparam, so the OS env
5638            // got X=2 even though the assignment errored. env then
5639            // inherited the polluted env from fork, leaking the
5640            // attempted override past the readonly guard. Mirror
5641            // C's order: readonly check → zerr → bail; only mutate
5642            // env when the assignment is admissible. Bug #551
5643            // (security-relevant).
5644            if exec.is_readonly_param(&name) {
5645                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
5646                return;
5647            }
5648            // Inline-assignment frame tracking (`X=foo cmd` reverts on
5649            // command return).
5650            if !exec.inline_env_stack.is_empty() {
5651                let prev_var = crate::ported::params::getsparam(&name);
5652                let prev_env = env::var(&name).ok();
5653                exec.inline_env_stack
5654                    .last_mut()
5655                    .unwrap()
5656                    .push((name.clone(), prev_var, prev_env));
5657                let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
5658                // c:Src/params.c:5354
5659            }
5660            // Canonical setsparam handles readonly, integer math, case
5661            // fold, GSU dispatch. For Int values (arith assigns) route
5662            // through setiparam so the param is PM_INTEGER + inherits
5663            // the math layer's lastbase for display formatting. For
5664            // Float (arith assigns producing MN_FLOAT) route through
5665            // setnparam so the param is PM_FFLOAT — `(( b = a * 2 ))`
5666            // with scalar `a="3.14"` should create b as typeset -F,
5667            // not a scalar holding "6.28".
5668            if int_assign {
5669                if let Some(fusevm::Value::Int(i)) = value_raw {
5670                    crate::ported::params::setiparam(&name, i);
5671                } else {
5672                    assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
5673                }
5674            } else if float_assign {
5675                if let Some(fusevm::Value::Float(f)) = value_raw {
5676                    // ArithCompiler returns Value::Float whenever any
5677                    // operand came through Str (BUILTIN_GET_VAR yields
5678                    // Value::Str even for integer-shaped scalars). To
5679                    // avoid forcing every `(( b = a + 3 ))` to PM_FFLOAT
5680                    // when `a="5"` (integer-shaped), detect integer-
5681                    // valued floats and route through setiparam instead.
5682                    // True floats (non-integral) reach setnparam →
5683                    // PM_FFLOAT so `typeset -p b` shows `typeset -F …`.
5684                    if f.fract() == 0.0 && f.is_finite() && f.abs() <= i64::MAX as f64 {
5685                        crate::ported::params::setiparam(&name, f as i64);
5686                    } else {
5687                        let mnval = crate::ported::math::mnumber {
5688                            l: 0,
5689                            d: f,
5690                            type_: crate::ported::math::MN_FLOAT,
5691                        };
5692                        crate::ported::params::setnparam(&name, mnval);
5693                    }
5694                } else {
5695                    assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
5696                }
5697            } else {
5698                // c:Src/exec.c:2554-2567 — GLOB_ASSIGN. When the
5699                // `globassign` option is on and the scalar RHS is a glob
5700                // pattern, glob it and recreate the parameter as a scalar
5701                // (≤1 match) or array (>1) — csh-style assignment. The
5702                // bridge hands `value` UNTOKENIZED, so re-tokenize
5703                // (shtokenize) before haswilds/globlist; zsh's wordcode
5704                // value arrives pre-tokenized via `htok`. The
5705                // `isset(GLOBASSIGN)` gate is first and cheap (option off
5706                // by default), so the common path is unchanged.
5707                let mut globbed = false;
5708                // Only glob the RHS when the compiler flagged an UNQUOTED glob
5709                // token in the literal wordcode (SET_VAR_GLOB_ELIGIBLE). zsh's
5710                // GLOB_ASSIGN (Src/exec.c:2554) globs literal patterns only —
5711                // `x="/tmp/*"`, `x='/tmp/*'`, `x=$param`, `x=$(cmd)` all assign
5712                // verbatim. The value arrives here untokenized (DQ-wrapped by
5713                // the compiler), so this compile-time flag is the only surviving
5714                // signal of whether the pattern was quote-protected.
5715                let glob_eligible = SET_VAR_GLOB_ELIGIBLE.with(|c| c.replace(false));
5716                if glob_eligible && crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBASSIGN) {
5717                    let mut tv = value.clone();
5718                    crate::ported::glob::shtokenize(&mut tv);
5719                    if crate::ported::pattern::haswilds(&tv) {
5720                        // Committed to the glob path: never fall back to
5721                        // assigning the literal pattern (zsh errors on
5722                        // no-match instead).
5723                        globbed = true;
5724                        // globlist tokenizes its input internally (for
5725                        // haswilds + glob_path) and prints the ORIGINAL
5726                        // string verbatim in its "no matches found" error,
5727                        // so feed it the UNtokenized value — passing the
5728                        // tokenized form would leak the Star/Quest token
5729                        // bytes into the error message.
5730                        let mut ll: crate::ported::linklist::LinkList<String> = Default::default();
5731                        ll.push_back(value.clone());
5732                        crate::ported::subst::globlist(&mut ll, 0); // c:2556
5733                        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
5734                            == 0
5735                        {
5736                            let matches: Vec<String> = ll
5737                                .nodes
5738                                .iter()
5739                                .map(|s| crate::ported::lex::untokenize(s).to_string())
5740                                .collect();
5741                            crate::ported::params::unsetparam(&name); // c:2562
5742                            if matches.len() <= 1 {
5743                                let v = matches.into_iter().next().unwrap_or_default();
5744                                assign_failed =
5745                                    crate::ported::params::setsparam(&name, &v).is_none();
5746                            } else {
5747                                crate::ported::params::setaparam(&name, matches);
5748                            }
5749                        }
5750                        // errflag set → globlist already reported
5751                        // "no matches found"; leave the param unassigned
5752                        // to match zsh's abort.
5753                    }
5754                }
5755                // c:Src/exec.c addvars — a NULL return from
5756                // assignsparam (e.g. nameref resolving out of scope,
5757                // createparam refusal at c:1108-1118) fails the
5758                // assignment with status 1.
5759                if !globbed {
5760                    assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
5761                }
5762            }
5763            // PM_EXPORTED / allexport env mirror — read AFTER setsparam
5764            // so the flag bit reflects any GSU setfn side-effects.
5765            let allexport = opt_state_get("allexport").unwrap_or(false);
5766            let already_exported =
5767                (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
5768            if allexport || already_exported {
5769                let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
5770                // c:Src/params.c:5354
5771            }
5772            #[cfg(feature = "recorder")]
5773            if crate::recorder::is_enabled()
5774                && exec.local_scope_depth == 0
5775                && !matches!(
5776                    name.as_str(),
5777                    "PPID" | "LINENO" | "ZSH_ARGZERO" | "argv0" | "ARGC" | "?" | "_" | "RANDOM"
5778                )
5779            {
5780                let ctx = exec.recorder_ctx();
5781                let attrs = exec.recorder_attrs_for(&name);
5782                crate::recorder::emit_assign_typed(&name, &value, attrs, ctx);
5783            }
5784        });
5785        Value::Status(vm.last_status)
5786    });
5787
5788    // c:Src/exec.c execfor → Src/params.c:6362 setloopvar — the
5789    // for-loop variable bind. Distinct from BUILTIN_SET_VAR because a
5790    // PM_NAMEREF loop variable REBINDS (new refname) instead of
5791    // assigning through the resolved chain.
5792    vm.register_builtin(BUILTIN_SET_LOOP_VAR, |vm, argc| {
5793        let args = pop_args(vm, argc);
5794        let name = args.first().cloned().unwrap_or_default();
5795        let value = args.get(1).cloned().unwrap_or_default();
5796        if crate::vm_helper::is_nameref(&name) {
5797            let ef_before =
5798                crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
5799            crate::ported::params::setloopvar(&name, &value); // c:6362
5800            let ef_after = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
5801            if (ef_after & crate::ported::utils::ERRFLAG_ERROR) != 0 && ef_after != ef_before {
5802                // zerr fired (read-only reference / invalid self
5803                // reference) — abort the loop, status 1 (C errflag).
5804                vm.last_status = 1;
5805                return Value::Bool(false);
5806            }
5807            return Value::Bool(true);
5808        }
5809        // Plain loop var — canonical scalar path (same shape as
5810        // BUILTIN_SET_VAR's setsparam arm).
5811        with_executor(|exec| {
5812            if exec.is_readonly_param(&name) {
5813                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
5814                return;
5815            }
5816            crate::ported::params::setsparam(&name, &value);
5817            let allexport = opt_state_get("allexport").unwrap_or(false);
5818            let already_exported =
5819                (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
5820            if allexport || already_exported {
5821                let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
5822                // c:Src/params.c:5354
5823            }
5824        });
5825        Value::Bool(true)
5826    });
5827
5828    // Pre-compiled function registration — used by compile_zsh.rs's
5829    // FuncDef path. Stack: [name, base64-bincode-of-Chunk]. We decode
5830    // the base64, deserialize the Chunk, and store directly in
5831    // executor.functions_compiled. Bypasses the ShellCommand JSON layer.
5832    // BUILTIN_VAR_EXISTS — `[[ -v name ]]` set-test.
5833    // PURE PASSTHRU: build `${+name}` and route through canonical
5834    // `subst::paramsubst` which returns "1" for set / "0" for unset
5835    // (C port of `Src/subst.c::paramsubst` plus-prefix arm).
5836    // paramsubst handles all the shapes the 48-line hand-roll did:
5837    //   - bare scalar / array / assoc
5838    //   - subscripted `a[N]` / `h[key]`
5839    //   - positional params (any digit-only name)
5840    //   - env-var fallback (`HOME` set via getsparam → lookup_special_var)
5841    vm.register_builtin(BUILTIN_VAR_EXISTS, |vm, _argc| {
5842        let name = vm.pop().to_str();
5843        // c:Src/cond.c:361 `case 'v': return !issetvar(left)`. `-v` is
5844        // NOT `${+name}` — issetvar (params.c:751) additionally rejects
5845        // trailing chars after the parsed name/subscript (`arr[3]extra`,
5846        // nested `arr[2][1]`) and validates array-slice bounds (an
5847        // out-of-range `(i)`-not-found index is "unset"). `${+}` is
5848        // lenient and reported those as set.
5849        Value::Bool(crate::ported::params::issetvar(&name) != 0)
5850    });
5851
5852    // `time { compound; ... }` — runs the sub-chunk and prints elapsed
5853    // wall-clock time. zsh's full `time` also tracks user/system CPU via
5854    // getrusage on the *child*; we approximate via wall-time only since
5855    // the sub-chunk runs in-process (no fork). Output format matches
5856    // `time simple-cmd` (already implemented elsewhere via exectime).
5857    vm.register_builtin(BUILTIN_TIME_SUBLIST, |vm, argc| {
5858        let sub_idx = vm.pop().to_int() as usize;
5859        // c:Src/jobs.c:1028-1029 — `pn->text` arg to printtime. argc==2
5860        // means the compiler also pushed a desc string (bug #66 fix);
5861        // older callers with argc==1 push only sub_idx and we synthesize
5862        // an empty desc for backward compat with cached bytecode that
5863        // predates the desc-threading patch.
5864        let desc = if argc >= 2 {
5865            vm.pop().to_str().to_string()
5866        } else {
5867            String::new()
5868        };
5869        let chunk_opt = vm.chunk.sub_chunks.get(sub_idx).cloned();
5870        let Some(chunk) = chunk_opt else {
5871            return Value::Status(0);
5872        };
5873        // c:Src/jobs.c:1968 — `getrusage(RUSAGE_CHILDREN, &ti)` before
5874        // and after the timed sublist gives accurate per-stage user/sys
5875        // CPU. Wall-time-only approximation (0.7×/0.1× fudge factors)
5876        // produced bogus user/sys columns and ignored TIMEFMT. Bug #66
5877        // in docs/BUGS.md.
5878        let ru_before: libc::rusage = unsafe {
5879            let mut r: libc::rusage = std::mem::zeroed();
5880            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
5881            r
5882        };
5883        // c:Src/jobs.c — zsh's `time` reports only for JOBS (forked
5884        // work). Builtins/brace-groups/functions run in the shell
5885        // process with no job, so `zsh -fc 'time true'` emits NOTHING.
5886        // Snapshot the fork-event counter; report only if the timed
5887        // body forked (external command or subshell).
5888        let forks_before = crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed);
5889        let start = Instant::now();
5890        crate::fusevm_disasm::maybe_print_stdout("time_sublist", &chunk);
5891        let mut sub_vm = fusevm::VM::new(chunk);
5892        register_builtins(&mut sub_vm);
5893        let _ = sub_vm.run();
5894        let status = sub_vm.last_status;
5895        let elapsed = start.elapsed();
5896        let ru_after: libc::rusage = unsafe {
5897            let mut r: libc::rusage = std::mem::zeroed();
5898            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
5899            r
5900        };
5901        // Delta children rusage = timed work's CPU.
5902        let mut delta = ru_after;
5903        let sub = |a: libc::timeval, b: libc::timeval| -> libc::timeval {
5904            let mut sec = a.tv_sec - b.tv_sec;
5905            let mut usec = a.tv_usec as i64 - b.tv_usec as i64;
5906            if usec < 0 {
5907                sec -= 1;
5908                usec += 1_000_000;
5909            }
5910            libc::timeval {
5911                tv_sec: sec,
5912                tv_usec: usec as libc::suseconds_t,
5913            }
5914        };
5915        delta.ru_utime = sub(ru_after.ru_utime, ru_before.ru_utime);
5916        delta.ru_stime = sub(ru_after.ru_stime, ru_before.ru_stime);
5917        let ti = crate::ported::zsh_h::timeinfo::from_rusage(&delta);
5918        // c:Src/jobs.c:808-809 — `s = getsparam("TIMEFMT"); s ||
5919        // DEFAULT_TIMEFMT`. Honor user-set TIMEFMT, fall back to the
5920        // canonical default.
5921        let fmt = crate::ported::params::getsparam("TIMEFMT")
5922            .unwrap_or_else(|| crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string());
5923        // c:Src/jobs.c:768 `desc` arg — for the `time { sublist }` /
5924        // `time simple-cmd` keyword path, zsh passes the sublist's
5925        // source text (used by %J via printtime). The compiler now
5926        // threads the rendered source text through as the desc operand
5927        // (compile_zsh.rs Time arm, argc==2 form). Bug #66.
5928        // c:Src/jobs.c — no job, no report (see forks_before above).
5929        let forked = crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed)
5930            != forks_before;
5931        if forked {
5932            let line = crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
5933            eprintln!("{}", line);
5934        }
5935        Value::Status(status)
5936    });
5937
5938    // `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocator.
5939    // Stack: [path, varid, op_byte]. Opens path with the appropriate mode
5940    // and stores the resulting fd number in $varid as a string. We use
5941    // a high starting fd (10+) by allocating then dup'ing — matches zsh's
5942    // "fresh fd >= 10" promise so subsequent commands don't collide on
5943    // stdin/out/err.
5944    vm.register_builtin(BUILTIN_OPEN_NAMED_FD, |vm, _argc| {
5945        use std::sync::atomic::Ordering;
5946        let op_byte = vm.pop().to_int() as u8;
5947        let varid = vm.pop().to_str();
5948        let path = vm.pop().to_str();
5949        // Param introspection used by both the open and close forms.
5950        let param_flags = crate::ported::params::paramtab()
5951            .read()
5952            .ok()
5953            .and_then(|t| t.get(&varid).map(|p| p.node.flags));
5954        let param_readonly = param_flags
5955            .map(|f| (f & crate::ported::zsh_h::PM_READONLY as i32) != 0)
5956            .unwrap_or(false);
5957        // `{varid}>&-` / `{varid}<&-` — REDIR_CLOSE with varid.
5958        // Direct port of Src/exec.c:3805-3850.
5959        if matches!(
5960            op_byte,
5961            b if b == fusevm::op::redirect_op::DUP_WRITE
5962                || b == fusevm::op::redirect_op::DUP_READ
5963        ) {
5964            let n = path.trim_start_matches('&');
5965            if n == "-" {
5966                let val = with_executor(|exec| exec.scalar(&varid)).unwrap_or_default();
5967                let fd1 = val.parse::<i32>();
5968                // c:3811-3816 — bad=1: parameter doesn't contain an fd.
5969                let Ok(fd1) = fd1 else {
5970                    crate::ported::utils::zwarn(&format!(
5971                        "parameter {} does not contain a file descriptor",
5972                        varid
5973                    ));
5974                    with_executor(|exec| exec.redirect_failed = true);
5975                    return Value::Status(1);
5976                };
5977                // c:3813-3814 — bad=2: readonly parameter.
5978                if param_readonly {
5979                    crate::ported::utils::zwarn(&format!(
5980                        "can't close file descriptor from readonly parameter {}",
5981                        varid
5982                    ));
5983                    with_executor(|exec| exec.redirect_failed = true);
5984                    return Value::Status(1);
5985                }
5986                // c:3830-3835 — bad=3: fd >= 10 marked FDT_INTERNAL.
5987                if fd1 >= 10
5988                    && fd1 <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
5989                    && crate::ported::utils::fdtable_get(fd1) == crate::ported::zsh_h::FDT_INTERNAL
5990                {
5991                    crate::ported::utils::zwarn(&format!(
5992                        "file descriptor {} used by shell, not closed",
5993                        fd1
5994                    ));
5995                    with_executor(|exec| exec.redirect_failed = true);
5996                    return Value::Status(1);
5997                }
5998                // c:3870-3873 — close; report failure (varid form
5999                // always reports, unlike bare `N>&-`).
6000                if crate::ported::utils::zclose(fd1) < 0 {
6001                    crate::ported::utils::zwarn(&format!(
6002                        "failed to close file descriptor {}: {}",
6003                        fd1,
6004                        std::io::Error::last_os_error()
6005                    ));
6006                    return Value::Status(1);
6007                }
6008                return Value::Status(0);
6009            }
6010            // `{varid}>&N` — dup N to a fresh fd >= 10, store in varid.
6011            if let Ok(src) = n.parse::<i32>() {
6012                if param_readonly {
6013                    crate::ported::utils::zwarn(&format!(
6014                        "can't allocate file descriptor to readonly parameter {}",
6015                        varid
6016                    ));
6017                    with_executor(|exec| exec.redirect_failed = true);
6018                    return Value::Status(1);
6019                }
6020                let dup = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
6021                if dup < 0 {
6022                    crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src));
6023                    with_executor(|exec| exec.redirect_failed = true);
6024                    return Value::Status(1);
6025                }
6026                // c:2404-2412 addfd varid arm — movefd + FDT_EXTERNAL.
6027                let final_fd = crate::ported::utils::movefd(dup);
6028                crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
6029                with_executor(|exec| {
6030                    exec.set_scalar(varid, final_fd.to_string());
6031                });
6032                return Value::Status(0);
6033            }
6034            return Value::Status(1);
6035        }
6036        // `{varid}<<HERE` / `{varid}<<<str` — op byte 255 (zshrs-side
6037        // contract with compile_redir; fusevm's redirect_op stops at
6038        // 8). C path: gethere/getherestr write the body to a temp
6039        // file (Src/exec.c:4660-4682), then addfd's varid arm moves
6040        // the read fd >= 10, marks FDT_EXTERNAL and sets the param
6041        // (c:2402-2412). `path` carries the BODY text here.
6042        if op_byte == 255 {
6043            if param_readonly {
6044                crate::ported::utils::zwarn(&format!(
6045                    "can't allocate file descriptor to readonly parameter {}",
6046                    varid
6047                ));
6048                with_executor(|exec| exec.redirect_failed = true);
6049                return Value::Status(1);
6050            }
6051            let body = format!("{}\n", path.trim_end_matches('\n'));
6052            let mut tmpl: Vec<u8> = b"/tmp/zshrs_hd_XXXXXX\0".to_vec();
6053            let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
6054            if write_fd < 0 {
6055                crate::ported::utils::zwarn(&format!(
6056                    "can't create temp file for here document: {}",
6057                    std::io::Error::last_os_error()
6058                ));
6059                return Value::Status(1);
6060            }
6061            let bytes = body.as_bytes();
6062            let mut off = 0;
6063            while off < bytes.len() {
6064                let n = unsafe {
6065                    libc::write(
6066                        write_fd,
6067                        bytes[off..].as_ptr() as *const libc::c_void,
6068                        bytes.len() - off,
6069                    )
6070                };
6071                if n <= 0 {
6072                    unsafe { libc::close(write_fd) };
6073                    return Value::Status(1);
6074                }
6075                off += n as usize;
6076            }
6077            unsafe { libc::close(write_fd) };
6078            let read_fd =
6079                unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
6080            unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
6081            if read_fd < 0 {
6082                return Value::Status(1);
6083            }
6084            let final_fd = crate::ported::utils::movefd(read_fd);
6085            if final_fd < 0 {
6086                return Value::Status(1);
6087            }
6088            crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
6089            with_executor(|exec| {
6090                exec.set_scalar(varid, final_fd.to_string());
6091            });
6092            return Value::Status(0);
6093        }
6094        // Open form: `{varid}>file` etc.
6095        // c:Src/exec.c:2177-2215 checkclobberparam — gate BEFORE open.
6096        if param_readonly {
6097            // c:2191-2197
6098            crate::ported::utils::zwarn(&format!(
6099                "can't allocate file descriptor to readonly parameter {}",
6100                varid
6101            ));
6102            with_executor(|exec| exec.redirect_failed = true);
6103            return Value::Status(1);
6104        }
6105        // c:2199-2213 — NO_CLOBBER refuses to overwrite a parameter
6106        // already holding an OPEN fd (decimal value, fdtable says
6107        // FDT_EXTERNAL).
6108        if !isset(crate::ported::zsh_h::CLOBBER) && op_byte != fusevm::op::redirect_op::CLOBBER {
6109            if let Some(val) = with_executor(|exec| exec.scalar(&varid)) {
6110                if let Ok(fd) = val.parse::<i32>() {
6111                    if fd >= 0
6112                        && fd <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
6113                        && crate::ported::utils::fdtable_get(fd)
6114                            == crate::ported::zsh_h::FDT_EXTERNAL
6115                    {
6116                        crate::ported::utils::zwarn(&format!(
6117                            "can't clobber parameter {} containing file descriptor {}",
6118                            varid, fd
6119                        ));
6120                        with_executor(|exec| exec.redirect_failed = true);
6121                        return Value::Status(1);
6122                    }
6123                }
6124            }
6125        }
6126        let path_c = match CString::new(path.clone()) {
6127            Ok(c) => c,
6128            Err(_) => return Value::Status(1),
6129        };
6130        let flags = match op_byte {
6131            b if b == fusevm::op::redirect_op::READ => libc::O_RDONLY,
6132            b if b == fusevm::op::redirect_op::WRITE || b == fusevm::op::redirect_op::CLOBBER => {
6133                libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC
6134            }
6135            b if b == fusevm::op::redirect_op::APPEND => {
6136                libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND
6137            }
6138            b if b == fusevm::op::redirect_op::READ_WRITE => libc::O_RDWR | libc::O_CREAT,
6139            _ => return Value::Status(1),
6140        };
6141        let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o666) };
6142        if fd < 0 {
6143            // c:Src/exec.c:3790-3795 — report the open failure and mark the
6144            // redirect failed so the command is SKIPPED, matching the numeric-fd
6145            // (`3< file`) and non-varid (`< file`) paths. Previously this
6146            // silently returned Status(1) with no diagnostic and no
6147            // redirect_failed flag, so `{fd}< /nonexistent` ran the command
6148            // anyway with exit 0 (zsh errors "no such file or directory" +
6149            // skips the command). `%e: %s` = strerror(errno) : filename.
6150            let e = std::io::Error::last_os_error();
6151            let msg = redir_errno_msg(&e);
6152            crate::ported::utils::zwarn(&format!("{}: {}", msg, path));
6153            with_executor(|exec| exec.redirect_failed = true);
6154            return Value::Status(1);
6155        }
6156        // c:2404-2412 addfd varid arm — `fd1 = movefd(fd2);
6157        // fdtable[fd1] = FDT_EXTERNAL; setiparam(varid, fd1);`.
6158        // FDT_EXTERNAL (not INTERNAL): the user owns this fd — the
6159        // NO_CLOBBER gate above and `{fd}>&-` close both key off it.
6160        let final_fd = crate::ported::utils::movefd(fd);
6161        if final_fd < 0 {
6162            crate::ported::utils::zerr(&format!(
6163                "cannot move fd {}: {}",
6164                fd,
6165                std::io::Error::last_os_error()
6166            ));
6167            return Value::Status(1);
6168        }
6169        crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
6170        let _ = Ordering::Relaxed;
6171        with_executor(|exec| {
6172            exec.set_scalar(varid, final_fd.to_string());
6173        });
6174        Value::Status(0)
6175    });
6176
6177    // BUILTIN_SET_TRY_BLOCK_ERROR — capture the try-block's exit
6178    // status into `__zshrs_try_block_saved_status` (a scratch
6179    // scalar) so the always-arm can later restore it. Also set
6180    // `TRY_BLOCK_ERROR` per zsh semantics: it stays at -1 unless
6181    // the try-block fired an explicit error (errflag), per
6182    // c:Src/exec.c execlist's WC_TRYBLOCK arm.
6183    vm.register_builtin(BUILTIN_SET_TRY_BLOCK_ERROR, |vm, _argc| {
6184        use std::sync::atomic::Ordering;
6185        let vm_status = vm.last_status;
6186        // c:Src/exec.c WC_TRYBLOCK — the always-arm runs with a
6187        // clean escape state. Snapshot RETFLAG / BREAKS / CONTFLAG /
6188        // EXIT_PENDING here and clear them; RESTORE_TRY_BLOCK_STATUS
6189        // re-applies them at always-arm exit so the propagation jump
6190        // emitted by compile_zsh fires correctly.
6191        let ret_save = crate::ported::builtin::RETFLAG.swap(0, Ordering::Relaxed); // c:769-770
6192        let brk_save = crate::ported::builtin::BREAKS.swap(0, Ordering::Relaxed); // c:771-772
6193        let cont_save = crate::ported::builtin::CONTFLAG.swap(0, Ordering::Relaxed); // c:773-774
6194        let exit_save = crate::ported::builtin::EXIT_PENDING.swap(0, Ordering::Relaxed);
6195        // c:Src/loop.c:762-763 — `save_try_errflag = try_errflag;
6196        // save_try_interrupt = try_interrupt;`. Restored at c:778-779
6197        // by RESTORE_TRY_BLOCK_STATUS so a nested try block doesn't
6198        // clobber the enclosing one's `$TRY_BLOCK_ERROR`.
6199        let try_err_save = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:762
6200        let try_int_save = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:763
6201        TRY_ESCAPE_SAVE.with(|s| {
6202            s.borrow_mut().push((
6203                ret_save,
6204                brk_save,
6205                cont_save,
6206                exit_save,
6207                try_err_save,
6208                try_int_save,
6209            ));
6210        });
6211        // c:Src/loop.c:764-766 — `try_errflag = (zlong)(errflag &
6212        // ERRFLAG_ERROR); try_interrupt = (zlong)((errflag &
6213        // ERRFLAG_INT) ? 1 : 0);`. Both are the RAW FLAG BITS, not the
6214        // try-list's exit status: ERRFLAG_ERROR is 1 (zsh.h:2972), so
6215        // `$TRY_BLOCK_ERROR` is 1-or-0 in zsh regardless of what the
6216        // failing command's `$?` was. The try-list's status is carried
6217        // separately in `__zshrs_try_block_saved_status`.
6218        let live_errflag = crate::ported::utils::errflag.load(Ordering::Relaxed);
6219        let try_err = (live_errflag & crate::ported::zsh_h::ERRFLAG_ERROR) as i64; // c:765
6220        let try_int = if (live_errflag & crate::ported::zsh_h::ERRFLAG_INT) != 0 {
6221            1i64
6222        } else {
6223            0i64
6224        }; // c:766
6225        crate::ported::r#loop::try_errflag.store(try_err, Ordering::Relaxed); // c:765
6226        crate::ported::r#loop::try_interrupt.store(try_int, Ordering::Relaxed); // c:766
6227        // c:Src/loop.c:755 — `endval = lastval ? lastval : errflag;`.
6228        // The status of the WHOLE `{…} always {…}` construct, captured
6229        // BEFORE the always-list runs (exectry returns it at c:801) and
6230        // deliberately including the errflag fallback: a try-list that
6231        // failed with `lastval == 0` but raised errflag still reports 1.
6232        let endval = if vm_status != 0 {
6233            vm_status
6234        } else {
6235            live_errflag
6236        }; // c:755
6237        with_executor(|exec| {
6238            // flags=0 (not setsparam's ASSPM_WARN): VM-internal scratch —
6239            // must never surface as a WARN_CREATE_GLOBAL diagnostic inside
6240            // a user function running `{...} always {...}` (f-sy-h's
6241            // `_zsh_highlight` does exactly that under warncreateglobal).
6242            crate::ported::params::assignsparam(
6243                "__zshrs_try_block_saved_status",
6244                &endval.to_string(),
6245                0,
6246            );
6247            let _ = exec;
6248            // Mirror into paramtab so `${parameters[TRY_BLOCK_ERROR]}`
6249            // and the PM_INTEGER `u_val` shadow agree with the atomic
6250            // the special-var getter reads. (setsparam → intsetfn's
6251            // TRY_BLOCK_ERROR arm re-stores the same value.)
6252            exec.set_scalar("TRY_BLOCK_ERROR".to_string(), try_err.to_string());
6253            exec.set_scalar("TRY_BLOCK_INTERRUPT".to_string(), try_int.to_string());
6254        });
6255        // c:Src/loop.c:768 — `errflag = 0;` ("We need to reset all
6256        // errors to allow the block to execute"). C clears the WHOLE
6257        // word, not just ERRFLAG_ERROR.
6258        crate::ported::utils::errflag.store(0, Ordering::Relaxed); // c:768
6259        Value::Status(0)
6260    });
6261
6262    // BUILTIN_BEGIN_INLINE_ENV / END_INLINE_ENV — wrap an
6263    // inline-assignment-prefixed command (`X=foo Y=bar cmd`):
6264    // BEGIN pushes a save frame; SET_VAR fires for each assign and
6265    // ALSO env::set_var's the value (visible to cmd's child); the
6266    // command runs; END pops the frame and restores both shell-var
6267    // and process-env state. Direct port of zsh's addvars() →
6268    // execute_simple → restore-after-exec contract.
6269    vm.register_builtin(BUILTIN_BEGIN_INLINE_ENV, |_vm, _argc| {
6270        with_executor(|exec| {
6271            exec.inline_env_stack.push(Vec::new());
6272        });
6273        Value::Status(0)
6274    });
6275    vm.register_builtin(BUILTIN_END_INLINE_ENV, |_vm, _argc| {
6276        with_executor(|exec| {
6277            if let Some(frame) = exec.inline_env_stack.pop() {
6278                for (name, prev_var, prev_env) in frame.into_iter().rev() {
6279                    match prev_var {
6280                        Some(v) => {
6281                            exec.set_scalar(name.clone(), v);
6282                        }
6283                        None => {
6284                            exec.unset_scalar(&name);
6285                        }
6286                    }
6287                    match prev_env {
6288                        Some(v) => env::set_var(&name, &v),
6289                        None => env::remove_var(&name),
6290                    }
6291                }
6292            }
6293        });
6294        Value::Status(0)
6295    });
6296    // c:Src/exec.c:3969-3976 — bare-exec assignment epilogue: see the
6297    // const's doc block. POSIX_BUILTINS → assignments persist (pop the
6298    // frame, discard the saved state); otherwise → restore_params
6299    // (same walk as END_INLINE_ENV).
6300    vm.register_builtin(BUILTIN_EXEC_INLINE_ENV_DONE, |_vm, _argc| {
6301        let persist = isset(crate::ported::zsh_h::POSIXBUILTINS);
6302        with_executor(|exec| {
6303            if let Some(frame) = exec.inline_env_stack.pop() {
6304                if persist {
6305                    return; // c:3971 — no save/restore under POSIX_BUILTINS
6306                }
6307                for (name, prev_var, prev_env) in frame.into_iter().rev() {
6308                    match prev_var {
6309                        Some(v) => {
6310                            exec.set_scalar(name.clone(), v);
6311                        }
6312                        None => {
6313                            exec.unset_scalar(&name);
6314                        }
6315                    }
6316                    match prev_env {
6317                        Some(v) => env::set_var(&name, &v),
6318                        None => env::remove_var(&name),
6319                    }
6320                }
6321            }
6322        });
6323        Value::Status(0)
6324    });
6325
6326    // BUILTIN_RESTORE_TRY_BLOCK_STATUS — emitted at the end of an
6327    // `always` arm. Per zshmisc, the exit status of the entire
6328    // `{ try } always { finally }` construct is the try-list's
6329    // status, regardless of what happens in the always-list (the
6330    // exception is `return`/`exit` inside always, which short-
6331    // circuits and the cleanup is the only thing that runs). So
6332    // restore TRY_BLOCK_ERROR unconditionally — the always-list's
6333    // exit status is discarded for the construct.
6334    vm.register_builtin(BUILTIN_RESTORE_TRY_BLOCK_STATUS, |_vm, _argc| {
6335        use std::sync::atomic::Ordering;
6336        // c:Src/loop.c:801 — `return endval;`. The construct's exit
6337        // status is the try-list's (captured at c:755 by
6338        // SET_TRY_BLOCK_ERROR), never the always-list's.
6339        let saved = with_executor(|exec| {
6340            exec.scalar("__zshrs_try_block_saved_status")
6341                .and_then(|s| s.parse::<i32>().ok())
6342                .unwrap_or(0)
6343        });
6344        // c:Src/exec.c:1375 — `lastval = lv;` on the exectry return.
6345        // The always-list's own commands left their status in LASTVAL
6346        // (`always { : }` → 0); without this store the errflag re-raise
6347        // below aborts the shell with the always-list's 0 instead of
6348        // the try-list's failure status.
6349        crate::ported::builtin::LASTVAL.store(saved, Ordering::Relaxed); // c:1375
6350        // c:Src/loop.c:774-777 — the error RE-RAISE. This is the
6351        // whole point of TRY_BLOCK_ERROR being writable:
6352        //
6353        //     if (try_errflag)  errflag |= ERRFLAG_ERROR;
6354        //     else              errflag &= ~ERRFLAG_ERROR;
6355        //     if (try_interrupt) errflag |= ERRFLAG_INT;
6356        //     else               errflag &= ~ERRFLAG_INT;
6357        //
6358        // SET_TRY_BLOCK_ERROR cleared errflag (c:768) so the always-arm
6359        // could run; the try-block's error is PARKED in `try_errflag`
6360        // and re-raised HERE unless the always-arm zeroed it
6361        // (`TRY_BLOCK_ERROR=0`, the documented swallow idiom — routed
6362        // to the atomic by intsetfn's IPDEF6 arm, params.rs).
6363        //
6364        // zshrs used to just drop the parked error, so
6365        // `f() { { typeset -r ro=1; ro=2 } always { … }; print reached }`
6366        // kept running and exited 0, where zsh aborts f with status 1.
6367        let te = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:774
6368        let ti = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:776
6369        if te != 0 {
6370            crate::ported::utils::errflag
6371                .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed); // c:775
6372        } else {
6373            crate::ported::utils::errflag
6374                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed); // c:777
6375        }
6376        if ti != 0 {
6377            crate::ported::utils::errflag
6378                .fetch_or(crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed); // c:779
6379        } else {
6380            crate::ported::utils::errflag
6381                .fetch_and(!crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed); // c:781
6382        }
6383        // Re-apply the escape flags captured by SET_TRY_BLOCK_ERROR.
6384        // If the always-arm itself fired return/break/continue/exit,
6385        // its handler already overwrote the canonical atomics; let
6386        // those win — the always-arm's own escape always takes
6387        // priority over the try-block's deferred one.
6388        if let Some((ret, brk, cont, exit_p, try_err_save, try_int_save)) =
6389            TRY_ESCAPE_SAVE.with(|s| s.borrow_mut().pop())
6390        {
6391            // c:Src/loop.c:782-783 — `try_errflag = save_try_errflag;
6392            // try_interrupt = save_try_interrupt;`
6393            crate::ported::r#loop::try_errflag.store(try_err_save, Ordering::Relaxed); // c:782
6394            crate::ported::r#loop::try_interrupt.store(try_int_save, Ordering::Relaxed);
6395            // c:783
6396            if crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) == 0 {
6397                crate::ported::builtin::RETFLAG.store(ret, Ordering::Relaxed);
6398            }
6399            if crate::ported::builtin::BREAKS.load(Ordering::Relaxed) == 0 {
6400                crate::ported::builtin::BREAKS.store(brk, Ordering::Relaxed);
6401            }
6402            if crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed) == 0 {
6403                crate::ported::builtin::CONTFLAG.store(cont, Ordering::Relaxed);
6404            }
6405            if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) == 0 {
6406                crate::ported::builtin::EXIT_PENDING.store(exit_p, Ordering::Relaxed);
6407            }
6408        }
6409        Value::Status(saved)
6410    });
6411
6412    // `[[ -r/-w/-x file ]]` — the cond path must use access(2) (the
6413    // C-faithful doaccess), NOT fusevm's generic Op::TestFile which only
6414    // checks existence for -r/-w (so a `chmod 000` file read as readable;
6415    // C02cond.ztst:13). Stack: [path, mode]; mode is the access(2) bit
6416    // (R_OK=4, W_OK=2, X_OK=1). Mirrors cond.rs:232/238/267 `doaccess`.
6417    vm.register_builtin(BUILTIN_COND_ACCESS, |vm, _argc| {
6418        let mode = vm.pop().to_int() as i32;
6419        let path = vm.pop().to_str();
6420        Value::Bool(crate::ported::cond::doaccess(&path, mode) != 0)
6421    });
6422
6423    // `[[ -prefix PAT ]]` / `-suffix` / `-after` / `-between` module condition.
6424    // Stack (pushed by the ModCond compile arm): arg0 … argN-1, then the
6425    // operator word last. argc = N+1.
6426    vm.register_builtin(BUILTIN_COND_MOD, |vm, argc| {
6427        use crate::ported::zle::complete::{
6428            cond_psfix, cond_range, CVT_PREPAT, CVT_SUFPAT,
6429        };
6430        let op = vm.pop().to_str(); // operator word (pushed last → popped first)
6431        let n = (argc as usize).saturating_sub(1);
6432        let mut args: Vec<String> = Vec::with_capacity(n);
6433        for _ in 0..n {
6434            args.push(vm.pop().to_str());
6435        }
6436        args.reverse(); // restore arg0 … argN-1 order
6437        // Dispatch the module/completion condition (C evalcond COND_MOD path:
6438        // condtab lookup + arity check, cond.c:149-185, over the four cotab[]
6439        // entries at complete.c:1697-1702). Handlers return 1=match/true.
6440        let name: String = op
6441            .trim_start_matches(|c: char| c == '-' || c == '\u{9b}')
6442            .to_string();
6443        let (min, max): (usize, usize) = match name.as_str() {
6444            "prefix" | "suffix" => (1, 2),
6445            "after" => (1, 1),
6446            "between" => (2, 2),
6447            _ => {
6448                crate::ported::utils::zerr(&format!(
6449                    "unknown condition: {}",
6450                    op.replace('\u{9b}', "-")
6451                ));
6452                return Value::Bool(false);
6453            }
6454        };
6455        if args.len() < min || args.len() > max {
6456            crate::ported::utils::zerr(&format!(
6457                "unknown condition: {}",
6458                op.replace('\u{9b}', "-")
6459            ));
6460            return Value::Bool(false);
6461        }
6462        let r = match name.as_str() {
6463            "prefix" => cond_psfix(&args, CVT_PREPAT),
6464            "suffix" => cond_psfix(&args, CVT_SUFPAT),
6465            "after" => cond_range(&args, 0),
6466            "between" => cond_range(&args, 1),
6467            _ => 0,
6468        };
6469        Value::Bool(r == 1)
6470    });
6471
6472    vm.register_builtin(BUILTIN_IS_TTY, |vm, _argc| {
6473        let fd_str = vm.pop().to_str();
6474        let fd: i32 = fd_str.trim().parse().unwrap_or(-1);
6475        let is_tty = if fd < 0 {
6476            false
6477        } else {
6478            unsafe { libc::isatty(fd) != 0 }
6479        };
6480        Value::Bool(is_tty)
6481    });
6482
6483    // c:Src/exec.c:4918/5040/5069 — a process substitution used inside a
6484    // `[[ … ]]` cond operand errors "process substitution %s cannot be
6485    // used here" (getoutputfile/getproc run with thisjob == -1). Emitted
6486    // by the compiler in place of ProcessSubIn/Out when in_cond_operand.
6487    vm.register_builtin(BUILTIN_PROCSUB_COND_ERROR, |_vm, _argc| {
6488        let cmd = _vm.pop().to_str();
6489        crate::ported::utils::zerr(&format!(
6490            "process substitution {} cannot be used here",
6491            cmd
6492        ));
6493        // c:getoutputfile returns NULL with errflag set → the enclosing
6494        // statement aborts (empty stdout, exit 1), rather than the cond
6495        // merely evaluating false.
6496        crate::ported::utils::errflag.fetch_or(
6497            crate::ported::zsh_h::ERRFLAG_ERROR,
6498            std::sync::atomic::Ordering::Relaxed,
6499        );
6500        with_executor(|exec| exec.set_last_status(1));
6501        _vm.last_status = 1;
6502        Value::str("")
6503    });
6504
6505    // Set $LINENO before executing the next statement. Direct
6506    // port of zsh's `lineno` global tracking from Src/input.c
6507    // (`if ((inbufflags & INP_LINENO) || !strin) && c == '\n')
6508    // lineno++;`). The compiler emits one of these before each
6509    // top-level pipe in `compile_sublist`, carrying the line
6510    // number captured by the parser at `ZshPipe.lineno`. Pops
6511    // [n], updates `$LINENO` in the variable table.
6512    vm.register_builtin(BUILTIN_SET_LINENO, |vm, _argc| {
6513        let n = vm.pop().to_int();
6514        // c:Src/exec.c:lineno = N — direct write to the param's
6515        // u_val. Cannot go through setsparam because LINENO carries
6516        // PM_READONLY (so `(t)LINENO` reads `integer-readonly-special`
6517        // per zsh); setsparam → assignstrvalue's PM_READONLY guard
6518        // would reject the internal write. C zsh handles this via the
6519        // PM_SPECIAL GSU vtable's setfn callback which bypasses the
6520        // generic readonly check; the Rust port writes the canonical
6521        // field directly instead.
6522        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
6523            if let Some(pm) = tab.get_mut("LINENO") {
6524                pm.u_val = n;
6525                pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
6526            }
6527        }
6528        // Mirror to the file-static `lineno` (utils.c:121) that
6529        // zerrmsg reads at utils.c:301 for the `:N: msg` prefix.
6530        crate::ported::utils::set_lineno(n as i32);
6531        // Also drive lex::LEX_LINENO — zerrmsg (utils.rs:376) reads
6532        // THAT counter for the `name:N:` prefix. C zsh interleaves
6533        // parse and execute per top-level list, so its single
6534        // `lineno` global serves both; zshrs compiles the whole
6535        // script before running, leaving LEX_LINENO parked at EOF.
6536        // Without this write, every runtime zwarn/zerr reported the
6537        // script's LAST line instead of the failing statement's.
6538        crate::ported::lex::set_lineno(n as u64);
6539        // DAP hook — checks breakpoints / step mode / pause-request
6540        // for the line we just landed on. O(1) no-op when DAP is off
6541        // (single atomic load on a OnceLock). Inside `--dap` mode
6542        // this is the call that blocks the executor on a Condvar
6543        // until the IDE sends `continue`. Mirrors strykelang's
6544        // `debugger.should_stop(line) → debugger.prompt(...)` flow.
6545        crate::extensions::dap::check_line(n as u32);
6546        Value::Status(0)
6547    });
6548
6549    // Direct port of Src/prompt.c:1623 cmdpush. Token is a `CS_*`
6550    // value (zsh.h:2775-2806) emitted by compile_zsh around each
6551    // compound command (if/while/[[…]]/((…))/$(…)) and consumed by
6552    // `%_` in PS4 / prompt expansion.
6553    vm.register_builtin(BUILTIN_CMD_PUSH, |vm, _argc| {
6554        let token = vm.pop().to_int() as u8;
6555        // Route through canonical cmdpush (Src/prompt.c:1623). The
6556        // prompt expander reads from the file-static `CMDSTACK` at
6557        // `prompt.rs:2006`, not `exec.cmd_stack` — without this,
6558        // `%_` in PS4 saw an empty stack during xtrace.
6559        if (token as i32) < crate::ported::zsh_h::CS_COUNT {
6560            crate::ported::prompt::cmdpush(token);
6561        }
6562        Value::Status(0)
6563    });
6564
6565    // Direct port of Src/prompt.c:1631 cmdpop.
6566    vm.register_builtin(BUILTIN_CMD_POP, |_vm, _argc| {
6567        crate::ported::prompt::cmdpop();
6568        Value::Status(0)
6569    });
6570
6571    vm.register_builtin(BUILTIN_OPTION_SET, |vm, _argc| {
6572        let name = vm.pop().to_str();
6573        // Direct port of `optison(char *name, char *s)` at Src/cond.c:502 — `[[ -o NAME ]]`
6574        // reads through the same `opts[]` array that `setopt NAME`
6575        // writes via `dosetopt`. Earlier code read a duplicate Executor
6576        // HashMap which never saw `bin_setopt`'s writes (those land in
6577        // `OPTS_LIVE` via `opt_state_set`). Routing through the canonical
6578        // C port restores the single-store invariant: one `opts[]`,
6579        // shared between setopt/unsetopt and `[[ -o ]]`.
6580        let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
6581        match r {
6582            0 => Value::Bool(true),  // c:cond.c:520 set
6583            1 => Value::Bool(false), // c:cond.c:518/520 unset
6584            _ => {
6585                // c:cond.c:514 — unknown option. optison already emitted the
6586                // diagnostic (via zwarn, now that fromtest=NULL for
6587                // `[[ -o ]]`); re-printing here double-emitted for
6588                // `[[ ! -o bad ]]` / `[[ -o a || -o b ]]`.
6589                Value::Bool(false)
6590            }
6591        }
6592    });
6593    // Tri-state `-o` for compile_cond's direct status path. Returns
6594    // 0 / 1 / 3 as a Value::Int that compile_cond consumes via
6595    // Op::SetStatus. Mirrors zsh's `[[ -o invalid ]]` returning $?=3.
6596    vm.register_builtin(BUILTIN_OPTION_CHECK_TRISTATE, |vm, _argc| {
6597        let name = vm.pop().to_str();
6598        let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
6599                                                             // optison itself prints the diagnostic via zwarnnam when r=3
6600                                                             // and POSIXBUILTINS is unset (the canonical path). Don't
6601                                                             // double-emit here. r is already 0/1/3.
6602        Value::Int(r as i64)
6603    });
6604
6605    // BUILTIN_PARAM_FILTER — `${var:#pat}` / `${var:|name}` etc.
6606    // PURE PASSTHRU: rebuild `${name:#pat}` and route to paramsubst.
6607    vm.register_builtin(BUILTIN_PARAM_FILTER, |vm, _argc| {
6608        let pattern = vm.pop().to_str();
6609        let name = vm.pop().to_str();
6610        let body = format!("${{{}:#{}}}", name, pattern);
6611        paramsubst_to_value(&body)
6612    });
6613
6614    // `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()`
6615    // — subscripted-array assign with array RHS. Stack pushed by
6616    // compile_assign as: [elem0, elem1, …, elemN-1, name, key].
6617    vm.register_builtin(BUILTIN_SET_SUBSCRIPT_RANGE, |vm, argc| {
6618        let n = argc as usize;
6619        let mut popped: Vec<Value> = Vec::with_capacity(n);
6620        for _ in 0..n {
6621            popped.push(vm.pop());
6622        }
6623        popped.reverse();
6624        if popped.len() < 3 {
6625            return Value::Status(1);
6626        }
6627        // c:Src/params.c:3511-3526 — trailing append flag. The ARRAY
6628        // subscript path (`a[N]+=(v)` / `a[lo,hi]+=(v)`) sets it so the
6629        // AUGMENT transform below collapses the range to an empty range
6630        // after the slice end and inserts ONLY the new value. The scalar
6631        // path pre-concats the old slice (ARRAY_INDEX+Concat) and passes
6632        // 0, so it keeps plain-replace semantics.
6633        let append = popped.pop().map_or(false, |v| v.to_str() == "1");
6634        let key = popped.pop().unwrap().to_str();
6635        let name = popped.pop().unwrap().to_str();
6636        let mut values: Vec<String> = Vec::new();
6637        for v in popped {
6638            match v {
6639                Value::Array(items) => {
6640                    for it in items {
6641                        values.push(it.to_str());
6642                    }
6643                }
6644                other => values.push(other.to_str()),
6645            }
6646        }
6647        // Bash sparse-array tracking: a single-index `a[i]=v` that pads the
6648        // dense Vec past its old end leaves indices old_len..i as HOLES (not
6649        // real elements), so `${#a[@]}`/`${!a[@]}` skip them like bash. Only
6650        // in bash mode, only for a plain non-append single index (0-based
6651        // under ksharrays). Captured before the assign; applied after.
6652        let sparse_track: Option<(String, usize, usize)> = if crate::dash_mode::bash_mode()
6653            && !append
6654            && !key.contains(',')
6655        {
6656            key.trim().parse::<usize>().ok().map(|i| {
6657                let old_len = with_executor(|exec| exec.array(&name).map(|a| a.len()).unwrap_or(0));
6658                (name.clone(), old_len, i)
6659            })
6660        } else {
6661            None
6662        };
6663        // c:Src/params.c:3383-3389 — a subscripted ARRAY assignment to an
6664        // associative array is an error, whatever the subscript looks like:
6665        //     if (v && PM_TYPE(v->pm->node.flags) == PM_HASHED) {
6666        //         unqueue_signals();
6667        //         zerr("%s: attempt to set slice of associative array",
6668        //              v->pm->node.nam);
6669        //         freearray(val);
6670        //         errflag |= ERRFLAG_ERROR;
6671        //         return NULL;
6672        //     }
6673        // assignaparam (params.rs) ports this, but a single-key `h[k]=(1 2)`
6674        // never reaches it: the VM lowers subscripted assignment to this
6675        // builtin instead. The comma form `h[a,b]=(1 2)` DID error — it takes a
6676        // different route — so the gap looked like a subscript-parsing quirk
6677        // when it was really "the check lives on a path this form doesn't
6678        // take". Untreated, the assignment was SILENTLY DISCARDED: rc=0 and
6679        // `${h[k]}` still read its old value.
6680        //
6681        // Only array-valued assignment is rejected; `h[k]=x` is a scalar
6682        // element store and stays legal.
6683        {
6684            let is_hashed = crate::ported::params::paramtab()
6685                .read()
6686                .ok()
6687                .and_then(|t| {
6688                    t.get(&name)
6689                        .map(|pm| crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32) == crate::ported::zsh_h::PM_HASHED)
6690                })
6691                .unwrap_or(false);
6692            if is_hashed {
6693                crate::ported::utils::zerr(&format!(
6694                    "{name}: attempt to set slice of associative array" // c:3385
6695                ));
6696                crate::ported::utils::errflag.fetch_or(
6697                    crate::ported::zsh_h::ERRFLAG_ERROR,
6698                    std::sync::atomic::Ordering::Relaxed,
6699                ); // c:3387
6700                return Value::Status(1); // c:3388
6701            }
6702        }
6703
6704        with_executor(|exec| {
6705            // Parse subscript: slice `lo,hi` or single index `i`.
6706            // setarrvalue (Src/params.c:2895) expects 1-based start/
6707            // end inclusive where start==end means replace one
6708            // element. Negative bounds translate to len+n+1 (1-based).
6709            //
6710            // c:Src/params.c — the END side accepts 0 as a valid value
6711            // that signals "insert BEFORE start position" (the canonical
6712            // `a[N,N-1]=val` prepend / mid-insert idiom). Bug #275 in
6713            // docs/BUGS.md: the previous Rust port clamped end up to 1,
6714            // collapsing `a[1,0]=(X Y)` into `a[1,1]=(X Y)` which
6715            // OVERWRITES position 1 instead of prepending. Provide two
6716            // translators — start_translate clamps to 1 (1-based);
6717            // end_translate keeps 0 intact so the splice in
6718            // setarrvalue (start_idx=0..end_idx=0) inserts at the front.
6719            // Bug #589: for scalars (no array), use the scalar's char
6720            // count as `len` so negative-index translation (`a[2,-1]`)
6721            // computes against the actual string length, not 0.
6722            let len = exec
6723                .array(&name)
6724                .map(|a| a.len() as i64)
6725                .or_else(|| {
6726                    crate::ported::params::paramtab().read().ok().and_then(|t| {
6727                        t.get(&name).and_then(|pm| {
6728                            if crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
6729                                == crate::ported::zsh_h::PM_SCALAR
6730                            {
6731                                pm.u_str.as_ref().map(|s| s.chars().count() as i64)
6732                            } else {
6733                                None
6734                            }
6735                        })
6736                    })
6737                })
6738                .unwrap_or(0);
6739            let start_translate = |raw: i64| -> i32 {
6740                if raw < 0 {
6741                    (len + raw + 1).max(1) as i32
6742                } else {
6743                    raw.max(1) as i32
6744                }
6745            };
6746            let end_translate = |raw: i64| -> i32 {
6747                if raw < 0 {
6748                    (len + raw + 1).max(0) as i32
6749                } else {
6750                    raw.max(0) as i32
6751                }
6752            };
6753            // c:Src/params.c — KSH_ARRAYS option flips array subscripts
6754            // from 1-based to 0-based. setarrvalue expects 1-based
6755            // inclusive bounds, so under KSH_ARRAYS we shift positive
6756            // inputs by +1 before translation. Negative bounds left
6757            // alone (count from end). Sibling of #610/#611/#612.
6758            // Bug #613.
6759            let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
6760            let ksh_shift = |raw: i64| -> i64 {
6761                if ksh_arrays && raw >= 0 {
6762                    raw + 1
6763                } else {
6764                    raw
6765                }
6766            };
6767            // c:Src/params.c getindex — subscript bounds are MATH
6768            // expressions, not bare integers: `a[(( ${#a}+1 ))]=(x)`,
6769            // `a[n+1]=(x)`. Plain `parse::<i64>()` returned 0 on any
6770            // arithmetic subscript, which the `i == 0` guard turned into
6771            // a silent no-op (computed-index append never landed). Parse
6772            // the literal fast-path first, then fall back to mathevali
6773            // (which handles `(( ))` grouping, var refs, and operators).
6774            let eval_bound = |s: &str| -> i64 {
6775                let t = s.trim();
6776                t.parse::<i64>()
6777                    .ok()
6778                    .or_else(|| crate::ported::math::mathevali(t).ok())
6779                    .unwrap_or(0)
6780            };
6781            let (start, end) = if let Some((s_str, e_str)) = key.split_once(',') {
6782                let s = ksh_shift(eval_bound(s_str));
6783                let e = ksh_shift(eval_bound(e_str));
6784                (start_translate(s), end_translate(e))
6785            } else {
6786                let i = ksh_shift(eval_bound(&key));
6787                if i == 0 {
6788                    return;
6789                }
6790                let n = start_translate(i);
6791                (n, n)
6792            };
6793            // c:Src/params.c:3518-3520 (assignaparam ASSPM_AUGMENT) — a
6794            // subscripted `+=` to an array does NOT prepend the old slice;
6795            // it collapses the range to an EMPTY range positioned right
6796            // AFTER the slice end (`v->start = v->end--`) and splices in
6797            // ONLY the new value: `a[2]+=(d)` on (a b c) → (a b d c);
6798            // `a[2,3]+=(x)` on (1 2 3 4) → (1 2 3 x 4). In setarrvalue's
6799            // 1-based convention here that means start = end+1 (so
6800            // start_idx == end_idx == end → splice arr[end..end]).
6801            let (start, end) = if append && end > 0 {
6802                (end + 1, end)
6803            } else {
6804                (start, end)
6805            };
6806            // c:Src/params.c:392-430 IPDEF9("argv"/"@"/"*", &pparams) —
6807            // the positional parameters live in the `pparams` vector, NOT
6808            // paramtab, so a subscript splice (`argv[2]=(X Y Z)`,
6809            // `2=(X Y Z)`) must read/write pparams. Splice a synthetic
6810            // array param holding the current positionals via the
6811            // canonical setarrvalue, then store the result back to
6812            // pparams — mirroring assignaparam's argv/@/* special-case
6813            // (params.rs:6937) for the whole-array form.
6814            if name == "argv" || name == "@" || name == "*" {
6815                let mut pm = {
6816                    crate::ported::params::createparam(
6817                        &name,
6818                        crate::ported::zsh_h::PM_ARRAY as i32,
6819                    );
6820                    crate::ported::params::paramtab()
6821                        .write()
6822                        .ok()
6823                        .and_then(|mut t| t.remove(&name))
6824                };
6825                if let Some(ref mut p) = pm {
6826                    p.u_arr = Some(exec.pparams());
6827                }
6828                let mut v = crate::ported::zsh_h::value {
6829                    pm,
6830                    arr: Vec::new(),
6831                    scanflags: 0,
6832                    valflags: 0,
6833                    start,
6834                    end,
6835                };
6836                crate::ported::params::setarrvalue(&mut v, values);
6837                let result = v.pm.and_then(|p| p.u_arr).unwrap_or_default();
6838                exec.set_pparams(result);
6839                return;
6840            }
6841            // Route through canonical setarrvalue (Src/params.c:2895).
6842            // It handles PM_READONLY rejection, PM_HASHED slice-error,
6843            // PM_ARRAY splice + bounds clamp + padding (c:2980+).
6844            let taken = match crate::ported::params::paramtab().write() {
6845                Ok(mut tab) => tab.remove(&name),
6846                Err(_) => None,
6847            };
6848            // c:Src/exec.c:2640 / getvalue(…, 1) — a subscript assignment to
6849            // a NONEXISTENT parameter auto-creates it. getindex/fetchvalue
6850            // with the create flag calls createparam(name, PM_ARRAY) so the
6851            // splice has an array to write into; `unset u; u[1,2]=(a z)`
6852            // then yields the array (a z). Without this, setarrvalue saw
6853            // v.pm == None and silently stored nothing. The single-index
6854            // scalar-value path (SET_ASSOC/SET_ARRAY_AT) already vivifies;
6855            // this brings the range/array-value path to parity.
6856            let taken = taken.or_else(|| {
6857                crate::ported::params::createparam(&name, crate::ported::zsh_h::PM_ARRAY as i32);
6858                crate::ported::params::paramtab()
6859                    .write()
6860                    .ok()
6861                    .and_then(|mut t| t.remove(&name))
6862            });
6863            // c:Src/params.c:2748+ — PM_SCALAR with subscript range
6864            // SPLICES the value into the scalar's char string. Bug
6865            // #589: zshrs's slice handler always called setarrvalue,
6866            // erroring "attempt to assign array value to non-array"
6867            // for `a=hello; a[2,3]=XYZ`. Detect PM_SCALAR and route
6868            // through assignstrvalue (which does scalar splice via
6869            // the PM_SCALAR arm at params.rs:3709-3789).
6870            let is_scalar = taken.as_ref().map_or(false, |pm| {
6871                crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
6872                    == crate::ported::zsh_h::PM_SCALAR
6873            });
6874            let mut v = crate::ported::zsh_h::value {
6875                pm: taken,
6876                arr: Vec::new(),
6877                scanflags: 0,
6878                valflags: 0,
6879                start,
6880                end,
6881            };
6882            if is_scalar {
6883                // Scalar splice — concat values, route through
6884                // assignstrvalue which dispatches by PM_TYPE.
6885                // start_translate returns 1-based positions; assignstrvalue's
6886                // PM_SCALAR arm at params.rs:3735+ expects 0-based start
6887                // (chars before start are kept) and 0-based end-exclusive
6888                // (chars from end are kept). Convert: start-=1.
6889                if v.start > 0 {
6890                    v.start -= 1;
6891                }
6892                let val: String = values.join("");
6893                crate::ported::params::assignstrvalue(Some(&mut v), Some(val), 0);
6894            } else {
6895                crate::ported::params::setarrvalue(&mut v, values);
6896            }
6897            // Write the mutated Param back to paramtab — setarrvalue
6898            // mutated v.pm in-place; the prior `tab.remove(&name)` at
6899            // the top of this handler took ownership, so we re-insert
6900            // here. setarrvalue + this re-insert IS the canonical
6901            // store (Src/params.c:2895). No further mirror needed.
6902            if let Some(pm) = v.pm {
6903                if let Ok(mut tab) = crate::ported::params::paramtab().write() {
6904                    tab.insert(name, pm);
6905                }
6906            }
6907        });
6908        if let Some((nm, old_len, i)) = sparse_track {
6909            crate::bash_arrays::note_subscript_set(&nm, old_len, i);
6910        }
6911        Value::Status(0)
6912    });
6913
6914    // BUILTIN_CONCAT_SPLICE — word-segment concat for an expansion whose
6915    // ARRAY shape survives into the word (`${arr[@]}`, `$@`, `${(@)a}`,
6916    // `${=v}`, slices). c:Src/subst.c:4245 `if (isarr)` gates the two
6917    // emit shapes and c:1663 `int plan9 = isset(RCEXPANDPARAM);` picks
6918    // between them at RUNTIME — so the option, not the compile-time
6919    // segment shape, decides splice-vs-cross-product here.
6920    vm.register_builtin(BUILTIN_CONCAT_SPLICE, |vm, _argc| {
6921        let rhs = vm.pop();
6922        let lhs = vm.pop();
6923        if plan9_active() {
6924            return concat_plan9(lhs, rhs);
6925        }
6926        concat_splice(lhs, rhs)
6927    });
6928
6929    // BUILTIN_CONCAT_DISTRIBUTE — word-segment concat. With
6930    // rcexpandparam (zsh option), distributes element-wise (cartesian
6931    // product). Default mode: joins arrays with IFS first char to a
6932    // single scalar before concat, matching zsh's default unquoted
6933    // and DQ semantics. Direct port of Src/subst.c sepjoin path
6934    // (line ~1813) which gates element-vs-join on the rc_expand_param
6935    // option, defaulting to join.
6936    // BUILTIN_CONCAT_DISTRIBUTE_FORCED — same shape as
6937    // CONCAT_DISTRIBUTE, but always cartesian-distributes when one
6938    // side is Array. Used for compile-time-detected explicit
6939    // distribution forms (`${^arr}` etc.) where the source flag
6940    // overrides the rcexpandparam option default.
6941    // `${^arr}` — RC_EXPAND_PARAM forced on by the flag. concat_plan9 carries
6942    // both halves of C's plan9 block: the c:4316-4350 cartesian emit AND the
6943    // c:4362 `uremnode` word deletion for an empty array. The DISTRIBUTE_FORCED
6944    // handler below cannot be reused: it is shared with `${(@)a}` / `${(f)v}` /
6945    // `${a[@]}`, which KEEP the word on empty (`x${(@)a}y` → `xy`).
6946    vm.register_builtin(BUILTIN_CONCAT_PLAN9, |vm, _argc| {
6947        let rhs = vm.pop();
6948        let lhs = vm.pop();
6949        concat_plan9(lhs, rhs)
6950    });
6951
6952    // `${^^arr}` — RC_EXPAND_PARAM forced OFF (c:2553-2555 `plan9 = 0`). Every
6953    // other concat builtin re-checks plan9_active(), which is the OPTION, so
6954    // under `setopt rcexpandparam` they cross-product regardless of the flag.
6955    // Go straight to concat_splice — C's non-plan9 path (c:4366-4437).
6956    vm.register_builtin(BUILTIN_CONCAT_SPLICE_NOPLAN9, |vm, _argc| {
6957        let rhs = vm.pop();
6958        let lhs = vm.pop();
6959        concat_splice(lhs, rhs)
6960    });
6961
6962    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE_FORCED, |vm, _argc| {
6963        let rhs = vm.pop();
6964        let lhs = vm.pop();
6965        match (lhs, rhs) {
6966            (Value::Array(la), Value::Array(ra)) => {
6967                if ra.is_empty() {
6968                    return Value::Array(la);
6969                }
6970                if la.is_empty() {
6971                    return Value::Array(ra);
6972                }
6973                let mut out = Vec::with_capacity(la.len() * ra.len());
6974                for a in &la {
6975                    let a_s = a.as_str_cow();
6976                    for b in &ra {
6977                        let b_s = b.as_str_cow();
6978                        let mut s = String::with_capacity(a_s.len() + b_s.len());
6979                        s.push_str(&a_s);
6980                        s.push_str(&b_s);
6981                        out.push(Value::str(s));
6982                    }
6983                }
6984                Value::Array(out)
6985            }
6986            (Value::Array(la), rhs_scalar) => {
6987                // An EMPTY array contributes nothing to a concatenated
6988                // word — the surrounding scalar text survives. zsh:
6989                // `x${^a}y` (a=()) / `x${(P)scalar-empty}y` → "xy", NOT
6990                // a dropped word. Without this, a `(P)` indirect to an
6991                // unset/empty scalar (which nodes_to_value collapses to
6992                // Value::Array([]) for standalone-removal semantics)
6993                // cartesian-dropped the whole word — p10k's
6994                // `typeset -g _$2=${(P)2}` then arrived as a bare
6995                // `typeset` and dumped every parameter (~217× → 19 MB
6996                // terminal flood → startup hang).
6997                if la.is_empty() {
6998                    return rhs_scalar;
6999                }
7000                let r = rhs_scalar.as_str_cow();
7001                let out: Vec<Value> = la
7002                    .into_iter()
7003                    .map(|a| {
7004                        let a_s = a.as_str_cow();
7005                        let mut s = String::with_capacity(a_s.len() + r.len());
7006                        s.push_str(&a_s);
7007                        s.push_str(&r);
7008                        Value::str(s)
7009                    })
7010                    .collect();
7011                Value::Array(out)
7012            }
7013            (lhs_scalar, Value::Array(ra)) => {
7014                // Symmetric empty-array-contributes-nothing rule; see
7015                // the (Array, scalar) arm above.
7016                if ra.is_empty() {
7017                    return lhs_scalar;
7018                }
7019                let l = lhs_scalar.as_str_cow();
7020                let out: Vec<Value> = ra
7021                    .into_iter()
7022                    .map(|b| {
7023                        let b_s = b.as_str_cow();
7024                        let mut s = String::with_capacity(l.len() + b_s.len());
7025                        s.push_str(&l);
7026                        s.push_str(&b_s);
7027                        Value::str(s)
7028                    })
7029                    .collect();
7030                Value::Array(out)
7031            }
7032            (lhs_s, rhs_s) => {
7033                let l = lhs_s.as_str_cow();
7034                let r = rhs_s.as_str_cow();
7035                let mut s = String::with_capacity(l.len() + r.len());
7036                s.push_str(&l);
7037                s.push_str(&r);
7038                Value::str(s)
7039            }
7040        }
7041    });
7042
7043    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE, |vm, argc| {
7044        let rhs = vm.pop();
7045        let lhs = vm.pop();
7046        // c:Src/subst.c:4245 `if (isarr)` — an unquoted array embedded
7047        // in a word ALWAYS emits one word per element, never a scalar
7048        // join. The shape (splice vs plan9 cross-product) is chosen at
7049        // RUNTIME by c:1663 `int plan9 = isset(RCEXPANDPARAM);`, exactly
7050        // as BUILTIN_CONCAT_SPLICE does. The only extra case DISTRIBUTE
7051        // handles is the DQ context: the compiler emits
7052        // CallBuiltin(BUILTIN_CONCAT_DISTRIBUTE, 1) when the parent word
7053        // is DQ-wrapped (compile_zsh.rs parent_is_dq), and inside DQ
7054        // `"pre${arr}post"` joins via $IFS[0] to a single scalar
7055        // regardless of the option (c:Src/subst.c:1650-1656 isarr
7056        // comment). The default UNQUOTED path emits argc=2 (lhs + rhs).
7057        // Bug #246 in docs/BUGS.md.
7058        if argc == 1 {
7059            // DQ context: join any Array side to scalar via sepjoin's
7060            // IFS default. c:Src/utils.c:3936-3945 — set-but-empty IFS
7061            // joins with "" (`IFS=""; echo "x$*y"` → `xabcy`); only
7062            // unset / space-leading IFS yields " ".
7063            let join_arr = |arr: Vec<Value>| -> String {
7064                let strs: Vec<String> = arr.iter().map(|v| v.as_str_cow().into_owned()).collect();
7065                crate::ported::utils::sepjoin(&strs, None)
7066            };
7067            let l = match lhs {
7068                Value::Array(a) => join_arr(a),
7069                other => other.as_str_cow().into_owned(),
7070            };
7071            let r = match rhs {
7072                Value::Array(a) => join_arr(a),
7073                other => other.as_str_cow().into_owned(),
7074            };
7075            let mut s = String::with_capacity(l.len() + r.len());
7076            s.push_str(&l);
7077            s.push_str(&r);
7078            return Value::str(s);
7079        }
7080        // Unquoted plain `${arr}`: same runtime dispatch as
7081        // BUILTIN_CONCAT_SPLICE — c:4245 `if (isarr)` always distributes
7082        // one word per element; c:1663 picks splice (default, first/last
7083        // sticking, c:4366-4437) vs plan9 cross-product (c:4316-4365).
7084        // concat_splice / concat_plan9 both honor EMPTY_EXPANSION_IS_SCALAR
7085        // so the p10k `${(P)2}` empty-array word-removal semantics survive.
7086        if plan9_active() {
7087            return concat_plan9(lhs, rhs);
7088        }
7089        concat_splice(lhs, rhs)
7090    });
7091
7092    // `[[ a -ef b ]]` — same-inode test. Resolves both paths via fs::metadata
7093    // (follows symlinks the way zsh's -ef does) and compares (dev, inode).
7094    // Returns false on any I/O error (path missing, permission denied, etc.).
7095    vm.register_builtin(BUILTIN_SAME_FILE, |vm, _argc| {
7096        let b = vm.pop().to_str();
7097        let a = vm.pop().to_str();
7098        let same = match (fs::metadata(&a), fs::metadata(&b)) {
7099            (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
7100            _ => false,
7101        };
7102        Value::Bool(same)
7103    });
7104
7105    // `[[ -c path ]]` — character device.
7106    vm.register_builtin(BUILTIN_IS_CHARDEV, |vm, _argc| {
7107        let path = vm.pop().to_str();
7108        let result = fs::metadata(&path)
7109            .map(|m| m.file_type().is_char_device())
7110            .unwrap_or(false);
7111        Value::Bool(result)
7112    });
7113    // `[[ -b path ]]` — block device.
7114    vm.register_builtin(BUILTIN_IS_BLOCKDEV, |vm, _argc| {
7115        let path = vm.pop().to_str();
7116        let result = fs::metadata(&path)
7117            .map(|m| m.file_type().is_block_device())
7118            .unwrap_or(false);
7119        Value::Bool(result)
7120    });
7121    // `[[ -p path ]]` — FIFO (named pipe).
7122    vm.register_builtin(BUILTIN_IS_FIFO, |vm, _argc| {
7123        let path = vm.pop().to_str();
7124        let result = fs::metadata(&path)
7125            .map(|m| m.file_type().is_fifo())
7126            .unwrap_or(false);
7127        Value::Bool(result)
7128    });
7129    // `[[ -S path ]]` — socket.
7130    vm.register_builtin(BUILTIN_IS_SOCKET, |vm, _argc| {
7131        let path = vm.pop().to_str();
7132        let result = fs::symlink_metadata(&path)
7133            .map(|m| m.file_type().is_socket())
7134            .unwrap_or(false);
7135        Value::Bool(result)
7136    });
7137
7138    // `[[ -k path ]]` / `-u` / `-g` — sticky / setuid / setgid bit.
7139    vm.register_builtin(BUILTIN_HAS_STICKY, |vm, _argc| {
7140        let path = vm.pop().to_str();
7141        let result = fs::metadata(&path)
7142            .map(|m| m.permissions().mode() & libc::S_ISVTX as u32 != 0)
7143            .unwrap_or(false);
7144        Value::Bool(result)
7145    });
7146    vm.register_builtin(BUILTIN_HAS_SETUID, |vm, _argc| {
7147        let path = vm.pop().to_str();
7148        let result = fs::metadata(&path)
7149            .map(|m| m.permissions().mode() & libc::S_ISUID as u32 != 0)
7150            .unwrap_or(false);
7151        Value::Bool(result)
7152    });
7153    vm.register_builtin(BUILTIN_HAS_SETGID, |vm, _argc| {
7154        let path = vm.pop().to_str();
7155        let result = fs::metadata(&path)
7156            .map(|m| m.permissions().mode() & libc::S_ISGID as u32 != 0)
7157            .unwrap_or(false);
7158        Value::Bool(result)
7159    });
7160    vm.register_builtin(BUILTIN_OWNED_BY_USER, |vm, _argc| {
7161        let path = vm.pop().to_str();
7162        let euid = unsafe { libc::geteuid() };
7163        let result = fs::metadata(&path)
7164            .map(|m| m.uid() == euid)
7165            .unwrap_or(false);
7166        Value::Bool(result)
7167    });
7168    vm.register_builtin(BUILTIN_OWNED_BY_GROUP, |vm, _argc| {
7169        let path = vm.pop().to_str();
7170        let egid = unsafe { libc::getegid() };
7171        let result = fs::metadata(&path)
7172            .map(|m| m.gid() == egid)
7173            .unwrap_or(false);
7174        Value::Bool(result)
7175    });
7176
7177    // `[[ -N path ]]` — file's access time is NOT newer than its
7178    // modification time (zsh man: "true if file exists and its
7179    // access time is not newer than its modification time"). Used
7180    // by zsh's mailbox-watching code. The semantic is `atime <=
7181    // mtime` (equivalent to `mtime >= atime`) — equal counts as
7182    // true, which a strict `mtime > atime` check missed for newly
7183    // created files where both stamps are identical.
7184    vm.register_builtin(BUILTIN_FILE_MODIFIED_SINCE_ACCESS, |vm, _argc| {
7185        let path = vm.pop().to_str();
7186        let result = fs::metadata(&path)
7187            .map(|m| m.atime() <= m.mtime())
7188            .unwrap_or(false);
7189        Value::Bool(result)
7190    });
7191
7192    // `[[ a -nt b ]]` — true if `a`'s mtime is strictly later than `b`'s.
7193    // BOTH files must exist; if either is missing the result is false.
7194    // (Earlier behavior was bash's "missing == infinitely-old"; zsh
7195    // strictly requires both files to exist.)
7196    vm.register_builtin(BUILTIN_FILE_NEWER, |vm, _argc| {
7197        let b = vm.pop().to_str();
7198        let a = vm.pop().to_str();
7199        // Use SystemTime modified() for nanosecond precision —
7200        // MetadataExt::mtime() returns seconds only, so two files
7201        // touched within the same second compared equal even when
7202        // 500ms apart. zsh tracks ns and uses `>=` for ties (touching
7203        // a then b in quick succession should still report b newer).
7204        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
7205        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
7206        let result = match (ta, tb) {
7207            (Some(ta), Some(tb)) => ta > tb,
7208            _ => false,
7209        };
7210        Value::Bool(result)
7211    });
7212
7213    // `[[ a -ot b ]]` — mirror of -nt. Same both-must-exist contract.
7214    vm.register_builtin(BUILTIN_FILE_OLDER, |vm, _argc| {
7215        let b = vm.pop().to_str();
7216        let a = vm.pop().to_str();
7217        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
7218        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
7219        let result = match (ta, tb) {
7220            (Some(ta), Some(tb)) => ta < tb,
7221            _ => false,
7222        };
7223        Value::Bool(result)
7224    });
7225
7226    // `set -e` / `setopt errexit` post-command check. Compiler emits
7227    // this after each top-level command's SetStatus (skipped inside
7228    // conditionals/pipelines/&&||/`!`). If errexit is on AND the last
7229    // command exited non-zero AND it's not a `return` from a function,
7230    // exit the shell with that status.
7231    // `set -x` / `setopt xtrace` — print each command before it runs.
7232    // The compiler emits this BEFORE the actual builtin/external call
7233    // with the command's literal text as a single string arg. We
7234    // print to stderr if xtrace is on. Honors `$PS4` (default `+ `).
7235    //
7236    // ── XTRACE flow control ────────────────────────────────────────
7237    // Mirror of C zsh's `doneps4` flag in execcmd_exec (Src/exec.c).
7238    // When an assignment trace fires (XTRACE_ASSIGN), it emits PS4
7239    // and sets this flag so the subsequent XTRACE_ARGS skips its own
7240    // PS4 emission — the assignment + command end up on the SAME
7241    // line: `<PS4>a=1 echo hello\n`. XTRACE_ARGS / XTRACE_NEWLINE
7242    // reset the flag after emitting the trailing `\n`.
7243    vm.register_builtin(BUILTIN_XTRACE_IS_ON, |_vm, _argc| {
7244        // Push live xtrace state. Caller pairs this with JumpIfFalse
7245        // to skip the trace-string-building block when xtrace is off,
7246        // avoiding side-effectful operand re-evaluation. Bug #159 in
7247        // docs/BUGS.md.
7248        let on = with_executor(|_| opt_state_get("xtrace").unwrap_or(false));
7249        Value::Int(if on { 1 } else { 0 })
7250    });
7251
7252    vm.register_builtin(BUILTIN_XTRACE_LINE, |vm, _argc| {
7253        let cmd_text = vm.pop().to_str();
7254        // Sync exec.last_status with the live vm.last_status BEFORE
7255        // the next command runs. Direct port of the zsh exec.c
7256        // contract — `$?` reads the exit status of the *most recent*
7257        // command. XTRACE_LINE is emitted by the compiler BEFORE
7258        // every simple command, so it's the natural sync point.
7259        let live = vm.last_status;
7260        with_executor(|exec| {
7261            exec.set_last_status(live);
7262        });
7263        // C zsh emits xtrace for `(( … ))` / `[[ … ]]` / `case` /
7264        // `if/while/until/for/repeat` head expressions via
7265        // `printprompt4(); fprintf(xtrerr, "%s\n", expr)` at
7266        // Src/exec.c:5240 (math), c:5286 (cond), c:4117 (for), etc.
7267        // The compiler emits BUILTIN_XTRACE_LINE only at those
7268        // construct boundaries (compile_arith / compile_cond /
7269        // compile_if / compile_while / compile_for / compile_case);
7270        // simple commands route to BUILTIN_XTRACE_ARGS instead. So
7271        // this handler always emits when xtrace is on — no prefix-
7272        // string heuristic.
7273        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
7274        if on {
7275            let already = XTRACE_DONE_PS4.with(|f| f.get());
7276            if !already {
7277                printprompt4();
7278            }
7279            // c:exec.c:5240/5286 — `fprintf(xtrerr, "%s\n", expr)`. Buffer
7280            // the line + newline, flush once (single write).
7281            xtrerr_fputs(&cmd_text);
7282            xtrerr_fputs("\n");
7283            xtrerr_flush();
7284            XTRACE_DONE_PS4.with(|f| f.set(false));
7285        }
7286        Value::Status(0)
7287    });
7288
7289    // BUILTIN_XTRACE_ARRAY_LINE — xtrace line for an `arr=(...)` / `arr+=(...)`
7290    // assignment. Stack on entry: [array, prefix] (argc = 2); pops prefix
7291    // ("name=( " / "name+=( "), then the whole assembled Value::Array. Direct
7292    // port of c:Src/exec.c::addvars:2624-2632, guarded on the live xtrace
7293    // state like C's `if (xtr)`: prints `prefix qz(e0) qz(e1) … ) ` with each
7294    // element shell-quoted (quotedzputs). Replaces the former one-VM-slot-per-
7295    // element trace, which overflowed next_slot (u16) on large literals.
7296    vm.register_builtin(BUILTIN_XTRACE_ARRAY_LINE, |vm, _argc| {
7297        let prefix = vm.pop().to_str();
7298        let arr = vm.pop();
7299        let live = vm.last_status;
7300        with_executor(|exec| {
7301            exec.set_last_status(live);
7302        });
7303        let on = with_executor(|_exec| opt_state_get("xtrace").unwrap_or(false));
7304        if on {
7305            let already = XTRACE_DONE_PS4.with(|f| f.get());
7306            if !already {
7307                printprompt4();
7308            }
7309            let mut line = String::with_capacity(prefix.len() + 16);
7310            line.push_str(&prefix);
7311            if let Value::Array(items) = arr {
7312                for it in items {
7313                    line.push_str(&crate::ported::utils::quotedzputs(&it.to_str()));
7314                    line.push(' ');
7315                }
7316            }
7317            line.push_str(") ");
7318            line.push('\n');
7319            xtrerr_fputs(&line);
7320            xtrerr_flush();
7321            XTRACE_DONE_PS4.with(|f| f.set(false));
7322        }
7323        Value::Status(0)
7324    });
7325
7326    // BUILTIN_MAKE_ARRAY_COUNTED — pop a count Int (top), then pop that many
7327    // values below it, and push them as one Value::Array (bottom-of-group
7328    // first). Same result as Op::MakeArray(N) but N comes from the stack as an
7329    // i64, dodging MakeArray's u16 operand cap. The compiler emits this only
7330    // when a literal `arr=(...)` has more than u16::MAX elements.
7331    vm.register_builtin(BUILTIN_MAKE_ARRAY_COUNTED, |vm, _argc| {
7332        let count = vm.pop().to_int().max(0) as usize;
7333        let mut items: Vec<Value> = Vec::with_capacity(count);
7334        for _ in 0..count {
7335            items.push(vm.pop());
7336        }
7337        items.reverse();
7338        Value::Array(items)
7339    });
7340
7341    // Like XTRACE_LINE but reads the top `argc - 1` values from the
7342    // VM stack WITHOUT consuming them (peek), then pops a prefix
7343    // string at the top. Joins prefix + peeked args with spaces using
7344    // zsh's quotedzputs-equivalent quoting. Direct port of
7345    // Src/exec.c:2055-2066 — emit AFTER expansion, with each arg
7346    // shell-quoted, so `for i in a b; echo for $i` traces as
7347    // `echo for a` / `echo for b`, not `echo for $i`.
7348    //
7349    // Stack contract on entry: [arg1, arg2, ..., argN, prefix].
7350    // Pops prefix; peeks argN..arg1 below. argc = N + 1.
7351    vm.register_builtin(BUILTIN_XTRACE_ARGS, |vm, argc| {
7352        let prefix = vm.pop().to_str();
7353        let live = vm.last_status;
7354        with_executor(|exec| {
7355            exec.set_last_status(live);
7356        });
7357        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
7358        if on {
7359            let n_args = argc.saturating_sub(1) as usize;
7360            let len = vm.stack.len();
7361            // c:Src/exec.c:2055 — argv is the POST-expansion word
7362            // list, so an arg that expanded to multiple words splats
7363            // into multiple trace tokens AND an arg that expanded to
7364            // zero words (empty unquoted `${UNSET}`) emits nothing.
7365            // pop_args (line 6243) already does this splat for the
7366            // real handler; mirror the same Array → splat / empty →
7367            // drop logic here so xtrace renders `echo ${UNSET}` as
7368            // `echo` (zsh) instead of `echo ''` (the previous
7369            // single-arg stringify path returned "" and then
7370            // quotedzputs wrapped it in `''`).
7371            let arg_strs: Vec<String> = if n_args > 0 && len >= n_args {
7372                let mut out = Vec::new();
7373                for v in &vm.stack[len - n_args..] {
7374                    match v {
7375                        Value::Array(items) => {
7376                            for item in items {
7377                                out.push(quotedzputs(&item.to_str()));
7378                            }
7379                        }
7380                        other => out.push(quotedzputs(&other.to_str())),
7381                    }
7382                }
7383                out
7384            } else {
7385                Vec::new()
7386            };
7387            // Builtins dispatch through `execbuiltin` (Src/builtin.c:442)
7388            // which emits its own PS4 + name + args xtrace. To avoid
7389            // double-emission, skip our emission here when the first
7390            // arg is a known builtin with a registered HandlerFunc —
7391            // those go through execbuiltin and will trace themselves.
7392            // Externals + builtins-not-yet-routed-through-execbuiltin
7393            // keep our emission as a stand-in.
7394            let goes_through_execbuiltin = crate::ported::builtin::BUILTINS
7395                .iter()
7396                .any(|b| b.node.nam == prefix && b.handlerfunc.is_some());
7397            if !goes_through_execbuiltin {
7398                let line = if arg_strs.is_empty() {
7399                    prefix
7400                } else {
7401                    format!("{} {}", prefix, arg_strs.join(" "))
7402                };
7403                // Mirrors Src/exec.c:2055 xtrace emission. C does:
7404                //   if (!doneps4) printprompt4();
7405                //   ... emit args + spaces ...
7406                //   fputc('\n', xtrerr); fflush(xtrerr);
7407                // printprompt4 + the args + `\n` all land in the xtrerr
7408                // buffer; the single fflush below writes the whole line in
7409                // one syscall so concurrent pipeline stages never
7410                // interleave (c:makecline:2122-2123).
7411                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
7412                if !already_ps4 {
7413                    printprompt4();
7414                }
7415                xtrerr_fputs(&line);
7416                xtrerr_fputs("\n"); // c:2122 fputc('\n', xtrerr)
7417                xtrerr_flush(); // c:2123 fflush(xtrerr)
7418            }
7419            XTRACE_DONE_PS4.with(|f| f.set(false));
7420        }
7421        Value::Status(0)
7422    });
7423
7424    // BUILTIN_XTRACE_ASSIGN — direct port of the per-assignment
7425    // trace block at Src/exec.c:2517-2582. C body excerpt:
7426    //   xtr = isset(XTRACE);
7427    //   if (xtr) { printprompt4(); doneps4 = 1; }
7428    //   while (assign) {
7429    //       if (xtr) fprintf(xtrerr, "%s+=" or "%s=", name);
7430    //       ... eval value into `val` ...
7431    //       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
7432    //       ...
7433    //   }
7434    //
7435    // Stack on entry: [..., name, value]. PEEKS both (they're left
7436    // on stack for SET_VAR to pop). Emits `name=<quoted-val> ` with
7437    // no newline; trailing `\n` comes from XTRACE_ARGS (cmd path)
7438    // or XTRACE_NEWLINE (assignment-only path).
7439    vm.register_builtin(BUILTIN_XTRACE_ASSIGN, |vm, _argc| {
7440        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
7441        if on {
7442            // PEEK [..., name, value] — argc==2 by contract.
7443            let len = vm.stack.len();
7444            if len >= 2 {
7445                let name = vm.stack[len - 2].to_str();
7446                let value = vm.stack[len - 1].to_str();
7447                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
7448                if !already_ps4 {
7449                    printprompt4();
7450                    XTRACE_DONE_PS4.with(|f| f.set(true));
7451                }
7452                // C: `fprintf(xtrerr, "%s=", name)` then `quotedzputs
7453                // (val); fputc(' ', xtrerr);`. Append to the xtrerr buffer
7454                // (no newline / no flush — the line continues with the
7455                // command via XTRACE_ARGS, or ends at XTRACE_NEWLINE).
7456                xtrerr_fputs(&format!("{}={} ", name, quotedzputs(&value)));
7457            }
7458        }
7459        Value::Status(0)
7460    });
7461
7462    // BUILTIN_XTRACE_NEWLINE — emit trailing `\n` + flush iff a
7463    // prior XTRACE_ASSIGN this line already emitted PS4. Mirrors
7464    // C's `fputc('\n', xtrerr); fflush(xtrerr);` at exec.c:3398
7465    // (the assignment-only path through execcmd_exec).
7466    vm.register_builtin(BUILTIN_XTRACE_NEWLINE, |_vm, _argc| {
7467        let on = with_executor(|exec| opt_state_get("xtrace").unwrap_or(false));
7468        if on {
7469            let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
7470            if already_ps4 {
7471                xtrerr_fputs("\n"); // c:3398 fputc('\n', xtrerr)
7472                xtrerr_flush(); // c:3398 fflush(xtrerr)
7473                XTRACE_DONE_PS4.with(|f| f.set(false));
7474            }
7475        }
7476        Value::Status(0)
7477    });
7478
7479    // c:Src/exec.c WC_TRYBLOCK — post-always re-jump probes. Each
7480    // returns 1 + consumes the atomic when the corresponding
7481    // escape flag is set; the try-block compile pairs each with
7482    // a JumpIfFalse + Jump → outer scope's return / break /
7483    // continue patches.
7484    vm.register_builtin(BUILTIN_RETFLAG_CHECK, |_vm, _argc| {
7485        use std::sync::atomic::Ordering;
7486        let r = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
7487        if r != 0 {
7488            // Don't clear here — doshfunc owns the clear at c:6047
7489            // when the function unwinds. Leaving it set propagates
7490            // through nested `eval`/`source` callers correctly.
7491            Value::Int(1)
7492        } else {
7493            Value::Int(0)
7494        }
7495    });
7496    vm.register_builtin(BUILTIN_BREAKS_CHECK, |_vm, _argc| {
7497        use std::sync::atomic::Ordering;
7498        let b = crate::ported::builtin::BREAKS.load(Ordering::Relaxed);
7499        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
7500        // `break` sets BREAKS but NOT CONTFLAG; `continue` sets both.
7501        // Filter out the continue path here so the two checks are
7502        // mutually exclusive.
7503        if b != 0 && c == 0 {
7504            // Consume BREAKS so the outer loop's break_patches
7505            // landing doesn't double-decrement.
7506            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
7507            Value::Int(1)
7508        } else {
7509            Value::Int(0)
7510        }
7511    });
7512    vm.register_builtin(BUILTIN_CONTFLAG_CHECK, |_vm, _argc| {
7513        use std::sync::atomic::Ordering;
7514        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
7515        if c != 0 {
7516            crate::ported::builtin::CONTFLAG.store(0, Ordering::Relaxed);
7517            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
7518            Value::Int(1)
7519        } else {
7520            Value::Int(0)
7521        }
7522    });
7523    vm.register_builtin(BUILTIN_NOEXEC_CHECK, |_vm, _argc| {
7524        // c:Src/exec.c:1390 — `set -n` / `noexec` option: parse but
7525        // don't execute. Returns Int(1) when noexec is set so the
7526        // emit-side JumpIfTrue skips the statement body.
7527        if opt_state_get("noexec").unwrap_or(false) {
7528            return Value::Int(1);
7529        }
7530        // c:Src/exec.c:1390 — execlist's list-loop gate:
7531        //   `while (wc_code(code) == WC_LIST && !breaks && !retflag
7532        //          && !errflag)`
7533        // — once errflag is set, the NEXT sublist never starts, so
7534        // lastval survives untouched to the shell exit. Without this
7535        // prologue gate the follow-up statement RAN, its dispatch
7536        // saw errflag, returned 1, and SetStatus clobbered lastval —
7537        // `[[ x == [a- ]]; print rc=$?` exited 1 instead of zsh's 2
7538        // (the cond syntax error set lastval=2 per exec.c:5216-5221).
7539        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
7540            & crate::ported::zsh_h::ERRFLAG_ERROR)
7541            != 0
7542        {
7543            return Value::Int(1);
7544        }
7545        Value::Int(0)
7546    });
7547    vm.register_builtin(BUILTIN_DONETRAP_RESET, |_vm, _argc| {
7548        // c:Src/exec.c:1455 — `donetrap = 0;` at sublist start.
7549        // Reset before each top-level statement so the next
7550        // sublist's ERREXIT_CHECK fires the ZERR trap on its FIRST
7551        // non-zero command. Carries the "already fired" state
7552        // across function-call returns within the SAME outer
7553        // sublist (per C semantics — donetrap is process-global).
7554        // Bug #303 in docs/BUGS.md.
7555        crate::ported::exec::DONETRAP.store(0, std::sync::atomic::Ordering::Relaxed);
7556        Value::Status(0)
7557    });
7558
7559    // `[[ -z X ]]` / `[[ -n X ]]` — pop one Value, route through
7560    // canonical `src/ported/cond.rs::evalcond` so the actual
7561    // empty/non-empty test reuses the C-port at `cond.rs:270-271`
7562    // (`'n' => !arg.is_empty()`, `'z' => arg.is_empty()`).
7563    //
7564    // The Array→args conversion lives at the bridge because cond.rs
7565    // expects `&[&str]` (C `cond_str` signature equivalent). For
7566    // `"${arr[@]}"` in DQ context the splice yields `Value::Array`
7567    // — an empty array still expands to one implicit empty word
7568    // (per zsh's "${arr[@]}" splat preserving at least one slot
7569    // in cond context), so:
7570    //   - Array(0)   → ["-z", ""]            → evalcond → 0 (true)
7571    //   - Array(1)   → ["-z", word]          → evalcond → 0/1
7572    //   - Array(2+)  → ["-z", w1, w2, ...]   → evalcond → 2 (parse
7573    //                                          error: too many ops)
7574    //                                          → coerced to false
7575    //   - Str(s)     → ["-z", s]             → evalcond → 0/1
7576    //
7577    // Bug #185 in docs/BUGS.md.
7578    fn run_cond_str_empty(v: Value, op: &str) -> Value {
7579        let words: Vec<String> = match v {
7580            Value::Array(arr) => arr.into_iter().map(|x| x.to_str()).collect(),
7581            Value::Str(s) => vec![s.to_string()],
7582            other => vec![other.to_str()],
7583        };
7584        let mut args: Vec<&str> = vec![op];
7585        if words.is_empty() {
7586            args.push("");
7587        } else {
7588            args.extend(words.iter().map(|s| s.as_str()));
7589        }
7590        let opts: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
7591        let vars: std::collections::HashMap<String, String> = std::collections::HashMap::new();
7592        // c:Src/cond.c:62-66 — `evalcond` returns 0=true, 1=false,
7593        // 2=syntax-error. Coerce error to false (observable behavior
7594        // in zsh: `[[ -z a b ]]` errors and the test as a whole
7595        // returns non-zero).
7596        // `[[ ]]` dispatch — C's `evalcond(state, NULL)` calling convention.
7597        // `None` for from_test → mathevali integer-compare coercion path.
7598        let ret = crate::ported::cond::evalcond(&args, &opts, &vars, false, None);
7599        Value::Int(if ret == 0 { 1 } else { 0 })
7600    }
7601    vm.register_builtin(BUILTIN_COND_STR_EMPTY, |vm, _argc| {
7602        let v = vm.pop();
7603        run_cond_str_empty(v, "-z")
7604    });
7605    vm.register_builtin(BUILTIN_COND_STR_NONEMPTY, |vm, _argc| {
7606        let v = vm.pop();
7607        run_cond_str_empty(v, "-n")
7608    });
7609
7610    // `exec N<<<"str"` — herestring redirect to explicit fd, applied
7611    // permanently. Direct port of `Src/exec.c:4655 getherestr` +
7612    // `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)` at c:3766-
7613    // 3780 for the nullexec=1 bare-exec-redir path. Bug #205 in
7614    // docs/BUGS.md.
7615    vm.register_builtin(BUILTIN_EXEC_HERESTR_FD, |vm, _argc| {
7616        let fd = vm.pop().to_int() as i32;
7617        let content = vm.pop().to_str();
7618        // c:4671-4672 — append `\n` for "real" herestrings (not
7619        // heredoc-derived). zshrs's bare-exec path only fires for
7620        // the `<<<` syntax (REDIR_HERESTR), so always append.
7621        let body = format!("{}\n", content);
7622        // c:4673-4679 — gettempfile → write_loop → close → reopen
7623        // read-only → unlink. Rust equivalent via tempfile crate or
7624        // explicit O_TMPFILE; use mkstemp + unlink-immediately to
7625        // mirror C exactly.
7626        use std::ffi::CString;
7627        let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
7628        let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
7629        if write_fd < 0 {
7630            crate::ported::utils::zwarn(&format!(
7631                "can't create temp file for here document: {}",
7632                std::io::Error::last_os_error()
7633            ));
7634            return Value::Status(1);
7635        }
7636        // c:4675 — write_loop(fd, t, len)
7637        let bytes = body.as_bytes();
7638        let mut off = 0;
7639        while off < bytes.len() {
7640            let n = unsafe {
7641                libc::write(
7642                    write_fd,
7643                    bytes[off..].as_ptr() as *const libc::c_void,
7644                    bytes.len() - off,
7645                )
7646            };
7647            if n <= 0 {
7648                unsafe { libc::close(write_fd) };
7649                return Value::Status(1);
7650            }
7651            off += n as usize;
7652        }
7653        unsafe { libc::close(write_fd) }; // c:4676
7654                                          // Path null-terminated by mkstemp; reopen for reading.
7655        let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
7656        // c:4678 — unlink immediately so the file disappears on
7657        // close, leaving only the fd reference.
7658        unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
7659        if read_fd < 0 {
7660            return Value::Status(1);
7661        }
7662        // c:3779 addfd → dup2 to target fd, close intermediate.
7663        let r = unsafe { libc::dup2(read_fd, fd) };
7664        unsafe { libc::close(read_fd) };
7665        if r < 0 {
7666            return Value::Status(1);
7667        }
7668        Value::Status(0)
7669    });
7670    // c:Src/exec.c:2418 + addfd splice — MULTIOS fan-out. Stack
7671    // layout pushed by compile_zsh's coalescing pass:
7672    //   [target_1, op_byte_1, target_2, op_byte_2, …, target_N,
7673    //    op_byte_N, fd]
7674    // argc = 2N + 1. Pops, opens every target, sets up a pipe +
7675    // splitter thread that reads pipe → writes every chunk to
7676    // every opened target, dup2's pipe-write-end onto fd. The
7677    // splitter is closed + joined by host_redirect_scope_end.
7678    // Bug #36 in docs/BUGS.md.
7679    vm.register_builtin(BUILTIN_MULTIOS_REDIRECT, |vm, argc| {
7680        if argc < 3 || argc % 2 == 0 {
7681            // Bad shape — bail.
7682            return Value::Status(1);
7683        }
7684        // Pop fd first (top of stack).
7685        let fd = vm.pop().to_int() as i32;
7686        // Then pop (op, target) pairs in reverse compile order. Keep
7687        // targets as Values — a glob-bearing target arrives as a
7688        // Value::Array of matches.
7689        let n_targets = ((argc - 1) / 2) as usize;
7690        let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_targets);
7691        for _ in 0..n_targets {
7692            let op_byte = vm.pop().to_int() as u8;
7693            let target = vm.pop();
7694            pairs.push((op_byte, target));
7695        }
7696        // Restore compile order (target_1 first).
7697        pairs.reverse();
7698
7699        // c:Src/glob.c:2195-2203 xpandredir — "Loop over matches,
7700        // duplicating the redirection for each file found": a glob
7701        // target with N matches becomes N members of the same multio
7702        // (`echo hi > *.txt` with two matches writes both files).
7703        let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
7704        for (op_byte, target) in pairs {
7705            match target {
7706                Value::Array(items) => {
7707                    for item in items {
7708                        entries.push((op_byte, item.to_str()));
7709                    }
7710                }
7711                other => entries.push((op_byte, other.to_str())),
7712            }
7713        }
7714        if entries.is_empty() {
7715            return Value::Status(1);
7716        }
7717
7718        // c:Src/exec.c:2418 — `else if (!mfds[fd1] || unset(MULTIOS))`:
7719        // with MULTIOS unset every redirect takes the REPLACE path in
7720        // script order — each target is still opened (created /
7721        // truncated) and dup2'd over the fd, so the LAST one wins and
7722        // earlier files end up empty (`unsetopt multios; print x > a
7723        // > b` leaves `a` empty, `x` in `b`). host_apply_redirect is
7724        // exactly one replace step, noclobber gate included.
7725        let multios_on = opt_state_get("multios").unwrap_or(true);
7726        if !multios_on {
7727            with_executor(|exec| {
7728                for (op_byte, target) in &entries {
7729                    exec.host_apply_redirect(fd as u8, *op_byte, target);
7730                    if exec.redirect_failed {
7731                        // c:Src/exec.c execerr — abort the remaining
7732                        // redirect list on failure.
7733                        break;
7734                    }
7735                }
7736            });
7737            return Value::Status(0);
7738        }
7739
7740        if entries.len() == 1 {
7741            // Single member after splicing — a plain replace
7742            // (c:2418 new-multio arm). Route through
7743            // host_apply_redirect so the noclobber gate, the
7744            // pipeline-output split partial, and error handling all
7745            // apply exactly as for an un-bagged redirect.
7746            let (op_byte, target) = &entries[0];
7747            with_executor(|exec| {
7748                exec.host_apply_redirect(fd as u8, *op_byte, target);
7749            });
7750            return Value::Status(0);
7751        }
7752
7753        // c:Src/exec.c:3722-3724 — when this command's stdout IS the
7754        // pipeline output, C seeds mfds[1] with the pipe BEFORE
7755        // walking the redirect list, so the pipe is the multio's
7756        // first member (`print x >&1 > f | cat` sends `x` down the
7757        // pipe TWICE: once for the seed, once for the `>&1` dup).
7758        let pipe_seed = fd == 1
7759            && with_executor(|exec| {
7760                exec.pipe_output_scope
7761                    .is_some_and(|d| d + 1 == exec.redirect_scope_stack.len())
7762            });
7763
7764        // Save current fd state for scope-end restoration — BEFORE
7765        // the first member's replace dup2 below.
7766        // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
7767        // descriptor is shell state and must live above the script's fd range:
7768        // plain dup() returns the LOWEST free fd, which parked the saved stdout
7769        // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
7770        // saved descriptor and reported success where zsh says `bad file number`.
7771        // F_DUPFD with a floor of 10 is exactly what movefd does.
7772        let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
7773        if saved >= 0 {
7774            with_executor(|exec| {
7775                if let Some(top) = exec.redirect_scope_stack.last_mut() {
7776                    top.push((fd, saved));
7777                } else {
7778                    unsafe { libc::close(saved) };
7779                }
7780            });
7781        }
7782
7783        // Accumulate member fds in redirect order. c:Src/exec.c:
7784        // 2447-2480 addfd — the FIRST member REPLACES the fd
7785        // (c:2448-2450 `mfds[fd1]->ct=1; mfds[fd1]->fds[0]=fd1;`), so
7786        // a later numeric `>&N` self-dup resolves against the fd's
7787        // value at that point in the sequence: `print x > f >&1`
7788        // writes f TWICE; `print x >&1 > f` writes the ORIGINAL
7789        // stdout + f.
7790        let mut target_fds: Vec<i32> = Vec::with_capacity(entries.len() + 1);
7791        if pipe_seed {
7792            let p = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
7793            if p >= 0 {
7794                target_fds.push(p);
7795            }
7796        }
7797        let noclobber = opt_state_get("noclobber").unwrap_or(false)
7798            || !opt_state_get("clobber").unwrap_or(true);
7799        for (i, (op_byte, target)) in entries.iter().enumerate() {
7800            let open_result: std::io::Result<i32> = match *op_byte {
7801                r::DUP_WRITE | r::DUP_READ => {
7802                    // Numeric `>&N` — dup the LIVE fd N (after any
7803                    // earlier member's replace).
7804                    match target.trim_start_matches('&').parse::<i32>() {
7805                        Ok(src) => {
7806                            let d = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
7807                            if d >= 0 {
7808                                Ok(d)
7809                            } else {
7810                                Err(std::io::Error::last_os_error())
7811                            }
7812                        }
7813                        Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
7814                    }
7815                }
7816                r::WRITE => {
7817                    // c:Src/exec.c clobber_open — noclobber applies
7818                    // to multio file targets too; failure aborts the
7819                    // remaining redirect list (execerr), so `setopt
7820                    // noclobber; touch a; print x > a > b` errors on
7821                    // `a` and never creates `b`.
7822                    let target_meta = std::fs::metadata(target).ok();
7823                    let target_is_regular_file = target_meta
7824                        .as_ref()
7825                        .map(|m| m.file_type().is_file())
7826                        .unwrap_or(false);
7827                    // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY re-uses
7828                    // an empty regular file under noclobber (same allowance
7829                    // as the single-redirect path).
7830                    let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
7831                        && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
7832                    if noclobber && target_is_regular_file && !clobber_empty_ok {
7833                        eprintln!(
7834                            "{}:{}: file exists: {}",
7835                            shname(),
7836                            crate::ported::lex::lineno(),
7837                            target
7838                        );
7839                        for prev in &target_fds {
7840                            unsafe {
7841                                libc::close(*prev);
7842                            }
7843                        }
7844                        with_executor(|exec| {
7845                            exec.redirect_failed = true;
7846                        });
7847                        // Sink the upcoming command's output (mirrors
7848                        // the single-redirect noclobber arm in
7849                        // host_apply_redirect).
7850                        if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
7851                            let new_fd = file.into_raw_fd();
7852                            unsafe {
7853                                libc::dup2(new_fd, fd);
7854                                libc::close(new_fd);
7855                            }
7856                        }
7857                        return Value::Status(1);
7858                    }
7859                    fs::OpenOptions::new()
7860                        .write(true)
7861                        .create(true)
7862                        .truncate(true)
7863                        .open(target)
7864                        .map(|f| f.into_raw_fd())
7865                }
7866                r::APPEND => fs::OpenOptions::new()
7867                    .write(true)
7868                    .create(true)
7869                    .append(true)
7870                    .open(target)
7871                    .map(|f| f.into_raw_fd()),
7872                _ => fs::OpenOptions::new()
7873                    .write(true)
7874                    .create(true)
7875                    .truncate(true)
7876                    .open(target)
7877                    .map(|f| f.into_raw_fd()),
7878            };
7879            match open_result {
7880                Ok(tfd) => {
7881                    if i == 0 && !pipe_seed {
7882                        // c:2448-2450 — first member replaces the fd.
7883                        unsafe {
7884                            libc::dup2(tfd, fd);
7885                        }
7886                    }
7887                    target_fds.push(tfd);
7888                }
7889                Err(e) => {
7890                    // c:Src/exec.c:3741 — `zwarn("%e: %s", errno, fname)`:
7891                    // zwarning supplies the `name:LINE:` prefix with the
7892                    // REAL current lineno; redir_errno_msg builds the `%e`
7893                    // errno message (was a hardcoded ErrorKind match that
7894                    // showed generic "redirect failed" for EROFS/etc.).
7895                    let msg = redir_errno_msg(&e);
7896                    crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
7897                    // Close already-opened fds to avoid leaks.
7898                    for prev in &target_fds {
7899                        unsafe {
7900                            libc::close(*prev);
7901                        }
7902                    }
7903                    with_executor(|exec| {
7904                        exec.redirect_failed = true;
7905                    });
7906                    return Value::Status(1);
7907                }
7908            }
7909        }
7910
7911        // Create the splitter pipe.
7912        let (read_end, write_end) = match os_pipe::pipe() {
7913            Ok(p) => p,
7914            Err(_) => {
7915                for f in &target_fds {
7916                    unsafe {
7917                        libc::close(*f);
7918                    }
7919                }
7920                return Value::Status(1);
7921            }
7922        };
7923        let pipe_write_raw = AsRawFd::as_raw_fd(&write_end);
7924        // Spawn the splitter thread: read pipe → write every chunk
7925        // to every target fd. Each write inside the thread uses
7926        // libc::write directly on the raw fd (no Rust File ownership
7927        // so the splitter can close after EOF without racing main).
7928        let target_fds_for_thread = target_fds.clone();
7929        let handle = std::thread::spawn(move || {
7930            let mut r = read_end;
7931            let mut buf = [0u8; 8192];
7932            loop {
7933                match std::io::Read::read(&mut r, &mut buf) {
7934                    Ok(0) => break,
7935                    Ok(n) => {
7936                        for &tfd in &target_fds_for_thread {
7937                            let mut off = 0;
7938                            while off < n {
7939                                let w = unsafe {
7940                                    libc::write(
7941                                        tfd,
7942                                        buf[off..n].as_ptr() as *const libc::c_void,
7943                                        n - off,
7944                                    )
7945                                };
7946                                if w <= 0 {
7947                                    break;
7948                                }
7949                                off += w as usize;
7950                            }
7951                        }
7952                    }
7953                    Err(_) => break,
7954                }
7955            }
7956            // Close every target so file contents flush.
7957            for tfd in target_fds_for_thread {
7958                unsafe {
7959                    libc::close(tfd);
7960                }
7961            }
7962        });
7963
7964        // Dup the pipe write-end onto the target fd; close the
7965        // original write_end so EOF arrives when host_redirect_scope_end
7966        // closes our tracked pipe_write_fd.
7967        let write_dup = unsafe { libc::fcntl(pipe_write_raw, libc::F_DUPFD, 10) };
7968        drop(write_end);
7969        if write_dup < 0 {
7970            return Value::Status(1);
7971        }
7972        unsafe {
7973            libc::dup2(write_dup, fd);
7974            libc::close(write_dup);
7975        }
7976        // Track the running splitter so scope-end can drain + join.
7977        // The "write_fd" we store is the user-visible fd (e.g. 1).
7978        // Closing that fd at scope-end isn't quite right; we need a
7979        // way to send EOF. Solution: track the write_dup we just
7980        // closed; instead keep a second dup for the close-on-end.
7981        // Shell-internal bookkeeping fd — above the script's range (movefd).
7982        let close_on_end = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
7983        with_executor(|exec| {
7984            if let Some(top) = exec.multios_scope_stack.last_mut() {
7985                top.push((close_on_end, handle));
7986            } else {
7987                // No scope — leak the dup; thread will keep running
7988                // until process exit. Should not happen because
7989                // host_redirect_scope_begin pushed a frame.
7990                unsafe { libc::close(close_on_end) };
7991            }
7992        });
7993        Value::Status(0)
7994    });
7995    // c:Src/exec.c:2418 input-arm — MULTIOS read fan-in. Stack
7996    // layout pushed by compile_zsh (mirrors the write side):
7997    //   [source_1, op_1, source_2, op_2, …, source_N, op_N, fd]
7998    // argc = 2N + 1; op distinguishes file opens (READ) from numeric
7999    // `<&N` dups (DUP_READ); a glob source arrives as Value::Array
8000    // and splices into one member per match (c:Src/glob.c:2195-2203).
8001    // Opens every source, sets up a pipe + producer thread that
8002    // reads each source in order and writes to the pipe write-end,
8003    // then closes its write-end so the consumer gets EOF. dup2 the
8004    // pipe read-end onto fd. Bug #36 input side in docs/BUGS.md.
8005    vm.register_builtin(BUILTIN_MULTIOS_READ, |vm, argc| {
8006        if argc < 3 || argc % 2 == 0 {
8007            return Value::Status(1);
8008        }
8009        let fd = vm.pop().to_int() as i32;
8010        let n_sources = ((argc - 1) / 2) as usize;
8011        let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_sources);
8012        for _ in 0..n_sources {
8013            let op_byte = vm.pop().to_int() as u8;
8014            let source = vm.pop();
8015            pairs.push((op_byte, source));
8016        }
8017        pairs.reverse();
8018
8019        // Splice glob match arrays (c:Src/glob.c:2195-2203).
8020        let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
8021        for (op_byte, source) in pairs {
8022            match source {
8023                Value::Array(items) => {
8024                    for item in items {
8025                        entries.push((op_byte, item.to_str()));
8026                    }
8027                }
8028                other => entries.push((op_byte, other.to_str())),
8029            }
8030        }
8031        if entries.is_empty() {
8032            return Value::Status(1);
8033        }
8034
8035        // c:Src/exec.c:2418 — `unset(MULTIOS)`: sequential replace,
8036        // last source wins (`unsetopt multios; cat < a < b` reads
8037        // only b; a is still opened — and errors still surface).
8038        let multios_on = opt_state_get("multios").unwrap_or(true);
8039        if !multios_on {
8040            with_executor(|exec| {
8041                for (op_byte, source) in &entries {
8042                    exec.host_apply_redirect(fd as u8, *op_byte, source);
8043                    if exec.redirect_failed {
8044                        break;
8045                    }
8046                }
8047            });
8048            return Value::Status(0);
8049        }
8050
8051        if entries.len() == 1 {
8052            // Single member after splicing — plain replace.
8053            let (op_byte, source) = &entries[0];
8054            with_executor(|exec| {
8055                exec.host_apply_redirect(fd as u8, *op_byte, source);
8056            });
8057            return Value::Status(0);
8058        }
8059
8060        // Save current fd state for scope-end restoration — BEFORE
8061        // the first member's replace dup2 below.
8062        // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
8063        // descriptor is shell state and must live above the script's fd range:
8064        // plain dup() returns the LOWEST free fd, which parked the saved stdout
8065        // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
8066        // saved descriptor and reported success where zsh says `bad file number`.
8067        // F_DUPFD with a floor of 10 is exactly what movefd does.
8068        let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
8069        if saved >= 0 {
8070            with_executor(|exec| {
8071                if let Some(top) = exec.redirect_scope_stack.last_mut() {
8072                    top.push((fd, saved));
8073                } else {
8074                    unsafe { libc::close(saved) };
8075                }
8076            });
8077        }
8078
8079        // Open every source in redirect order; numeric `<&N` dups
8080        // resolve against the LIVE fd table. First member replaces
8081        // the fd (c:2448-2450) so later self-dups see it.
8082        let mut source_fds: Vec<i32> = Vec::with_capacity(entries.len());
8083        for (i, (op_byte, source)) in entries.iter().enumerate() {
8084            let open_result: std::io::Result<i32> = match *op_byte {
8085                r::DUP_READ | r::DUP_WRITE => match source.trim_start_matches('&').parse::<i32>() {
8086                    Ok(src) => {
8087                        let d = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
8088                        if d >= 0 {
8089                            Ok(d)
8090                        } else {
8091                            Err(std::io::Error::last_os_error())
8092                        }
8093                    }
8094                    Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
8095                },
8096                _ => fs::File::open(source).map(|f| f.into_raw_fd()),
8097            };
8098            match open_result {
8099                Ok(tfd) => {
8100                    if i == 0 {
8101                        unsafe {
8102                            libc::dup2(tfd, fd);
8103                        }
8104                    }
8105                    source_fds.push(tfd);
8106                }
8107                Err(e) => {
8108                    let msg = match e.kind() {
8109                        std::io::ErrorKind::PermissionDenied => "permission denied",
8110                        std::io::ErrorKind::NotFound => "no such file or directory",
8111                        _ => "open failed",
8112                    };
8113                    // c:Src/exec.c:3741 — zwarn with real lineno prefix.
8114                    crate::ported::utils::zwarn(&format!("{}: {}", msg, source));
8115                    for prev in &source_fds {
8116                        unsafe {
8117                            libc::close(*prev);
8118                        }
8119                    }
8120                    with_executor(|exec| {
8121                        exec.redirect_failed = true;
8122                    });
8123                    return Value::Status(1);
8124                }
8125            }
8126        }
8127
8128        // Create the concatenator pipe.
8129        let (read_end, write_end) = match os_pipe::pipe() {
8130            Ok(p) => p,
8131            Err(_) => {
8132                for f in &source_fds {
8133                    unsafe {
8134                        libc::close(*f);
8135                    }
8136                }
8137                return Value::Status(1);
8138            }
8139        };
8140        // dup the pipe read-end onto fd before spawning the
8141        // producer; close the original read_end so the consumer
8142        // (reading via fd) is the sole reference until scope-end.
8143        let read_dup = unsafe { libc::dup(AsRawFd::as_raw_fd(&read_end)) };
8144        drop(read_end);
8145        if read_dup < 0 {
8146            for f in &source_fds {
8147                unsafe {
8148                    libc::close(*f);
8149                }
8150            }
8151            return Value::Status(1);
8152        }
8153        unsafe {
8154            libc::dup2(read_dup, fd);
8155            libc::close(read_dup);
8156        }
8157        // Spawn the producer.
8158        let source_fds_for_thread = source_fds.clone();
8159        let handle = std::thread::spawn(move || {
8160            let mut w = write_end;
8161            let mut buf = [0u8; 8192];
8162            for sfd in source_fds_for_thread {
8163                loop {
8164                    let n = unsafe {
8165                        libc::read(sfd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
8166                    };
8167                    if n <= 0 {
8168                        break;
8169                    }
8170                    let n = n as usize;
8171                    if std::io::Write::write_all(&mut w, &buf[..n]).is_err() {
8172                        break;
8173                    }
8174                }
8175                unsafe {
8176                    libc::close(sfd);
8177                }
8178            }
8179            // Closing w (the write_end) at scope drop signals EOF
8180            // to the consumer.
8181        });
8182        with_executor(|exec| {
8183            // Track using a closed-write sentinel — the producer
8184            // owns write_end so we just need to join. Use -1 fd
8185            // marker meaning "no fd to close".
8186            if let Some(top) = exec.multios_scope_stack.last_mut() {
8187                top.push((-1, handle));
8188            } else {
8189                let _ = handle.join();
8190            }
8191        });
8192        Value::Status(0)
8193    });
8194    // c:Src/exec.c:3978-3986 — nullexec==1 marker. See the const's
8195    // doc block. Arg: 1 = entering a bare-exec redirect, 0 = leaving.
8196    vm.register_builtin(BUILTIN_EXEC_PERM_REDIRS, |vm, _argc| {
8197        let on = vm.pop().to_int() != 0;
8198        with_executor(|exec| exec.exec_redirs_permanent = on);
8199        Value::Status(0)
8200    });
8201    // Bare-exec redirect epilogue — see the const's doc block.
8202    // c:Src/exec.c:252-259 (execerr) + c:4367-4386 (done: POSIX gate).
8203    vm.register_builtin(BUILTIN_EXEC_REDIR_DONE, |vm, _argc| {
8204        use std::sync::atomic::Ordering;
8205        let failed = with_executor(|exec| {
8206            let f = exec.redirect_failed;
8207            exec.redirect_failed = false;
8208            f
8209        });
8210        if !failed {
8211            return Value::Status(0);
8212        }
8213        // c:255 — `redir_err = lastval = 1`.
8214        vm.last_status = 1;
8215        if isset(crate::ported::zsh_h::POSIXBUILTINS) && !isset(crate::ported::zsh_h::INTERACTIVE) {
8216            // c:4379-4383 — non-interactive POSIX fatal: exit(1).
8217            // In-process equivalent: arm EXIT_PENDING/EXIT_VAL so the
8218            // next BUILTIN_ERREXIT_CHECK (trigger 2) unwinds the
8219            // script with status 1 — same deferred-exit shape the
8220            // `exit` builtin uses inside subshell contexts.
8221            crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
8222            crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
8223        }
8224        Value::Status(1)
8225    });
8226    // c:Src/exec.c:3722-3724 — see the const's doc block. No args.
8227    vm.register_builtin(BUILTIN_PIPE_OUTPUT_MARK, |_vm, _argc| {
8228        with_executor(|exec| exec.pipe_output_pending = true);
8229        Value::Status(0)
8230    });
8231    // c:Src/exec.c:3710-3724 — install this pipeline stage's fds.
8232    //     /* Make a copy of stderr for xtrace output before redirecting */
8233    //     fflush(xtrerr);
8234    //     ...
8235    //     /* Add pipeline input/output to mnodes */
8236    //     if (input)  addfd(forked, save, mfds, 0, input, 0, NULL);
8237    //     if (output) addfd(forked, save, mfds, 1, output, 1, NULL);
8238    // Emitted into the stage chunk by compile_zsh.rs (after the arg
8239    // words' expansion ops, before the redirect scope), and fed by
8240    // BUILTIN_RUN_PIPELINE via `stage_fds_park`. Doing the dup2 HERE
8241    // rather than before the chunk runs is what makes a `$(...)` in a
8242    // stage's arguments see the shell's fd 0 instead of the pipe.
8243    vm.register_builtin(BUILTIN_PIPE_FDS_INSTALL, |vm, argc| {
8244        // Arg: `|&` merge-stderr flag (compile_zsh always passes it).
8245        let merge_stderr = pop_args(vm, argc)
8246            .first()
8247            .map(|s| s != "0" && !s.is_empty())
8248            .unwrap_or(false);
8249        let (in_fd, out_fd) = stage_fds_take();
8250        if in_fd < 0 && out_fd < 0 {
8251            return Value::Status(0);
8252        }
8253        // c:3711 `fflush(xtrerr)` — flush before the fds move, so
8254        // anything buffered from the expansion phase lands on the
8255        // ORIGINAL fd, not on the pipe.
8256        let _ = std::io::stdout().flush();
8257        let _ = std::io::stderr().flush();
8258        unsafe {
8259            if in_fd >= 0 {
8260                libc::dup2(in_fd, libc::STDIN_FILENO);
8261                if in_fd != libc::STDIN_FILENO {
8262                    libc::close(in_fd);
8263                }
8264            }
8265            if out_fd >= 0 {
8266                libc::dup2(out_fd, libc::STDOUT_FILENO);
8267                if out_fd != libc::STDOUT_FILENO {
8268                    libc::close(out_fd);
8269                }
8270                // `cmd |& next`: the `2>&1` C appends to cmd's redirect
8271                // list (walked at c:3730+, i.e. after this addfd), so
8272                // stderr follows the pipe, not the shell's stdout.
8273                if merge_stderr {
8274                    libc::dup2(libc::STDOUT_FILENO, libc::STDERR_FILENO);
8275                }
8276            }
8277        }
8278        Value::Status(0)
8279    });
8280    // c:Src/exec.c — block-level redirect-failure gate. When a
8281    // compound command (`{ … } < file`, `( … ) > file`, etc.) has a
8282    // failing redirect (e.g. `< /nonexistent`), zsh skips the entire
8283    // body AND sets lastval to 1. The simple-command path's
8284    // redirect_failed check (line 215-221 above) only catches the
8285    // failure when a builtin dispatches and is consumed by that
8286    // single builtin call — so a multi-statement block kept running
8287    // its remaining statements after the redir error. Emit-side at
8288    // compile_zsh.rs::compile_command's Redirected arm pairs this
8289    // with a JumpIfTrue → WithRedirectsEnd to abandon the body.
8290    vm.register_builtin(BUILTIN_REDIRECT_FAILED_CHECK, |vm, _argc| {
8291        let failed = with_executor(|exec| {
8292            let f = exec.redirect_failed;
8293            exec.redirect_failed = false;
8294            f
8295        });
8296        if failed {
8297            vm.last_status = 1;
8298            Value::Int(1)
8299        } else {
8300            Value::Int(0)
8301        }
8302    });
8303    // c:Src/exec.c — drop-in replacement for fusevm's Op::Exec used by
8304    // the dynamic-first-word path (`$cmd`, `$(cmd)`, glob-named cmds).
8305    // fusevm's Op::Exec returns Value::Status(0) when post-expansion
8306    // argv is empty (vm.rs:1722) — that clobbers \$? for the
8307    // `\$(exit 1); echo \$?` case where the cmd-subst left
8308    // last_status = 1 but the empty expansion gets exec'd to 0.
8309    // Mirror C zsh: when the word list is empty after expansion,
8310    // \$? becomes whatever the inner cmd-subst's last_status is
8311    // (preserved here by returning Value::Status(last_status)).
8312    // c:Src/cond.c:308-316 — `if (!(pprog = patcompile(right, ...)))
8313    //   { zwarnnam(fromtest, "bad pattern: %s", right); return 2; }`.
8314    // The cond path must NOT use str_match/glob_match_static: the
8315    // case-statement consumer of those follows Src/loop.c:667 zerr
8316    // semantics (errflag abort), while cond is a zwarn + status-2
8317    // soft failure. COND_BAD_PATTERN carries the 2 across the
8318    // Bool-shaped stack contract (so `!=`'s LogNot can't lose it).
8319    thread_local! {
8320        static COND_BAD_PATTERN: std::cell::Cell<bool> =
8321            const { std::cell::Cell::new(false) };
8322    }
8323    vm.register_builtin(BUILTIN_COND_STRMATCH, |vm, _argc| {
8324        let pat = vm.pop().to_str();
8325        let s = vm.pop().to_str();
8326        // bash `shopt -s nocasematch` → case-insensitive `[[ == ]]` / `[[ != ]]`.
8327        // Lowercase BOTH sides for the match decision (glob metacharacters are
8328        // not letters, so the pattern's `*`/`?`/`[…]` structure is preserved).
8329        // No-op unless the bash shopt is active. --zsh unaffected.
8330        let (s, pat) = if crate::dash_mode::nocasematch() {
8331            (s.to_lowercase(), pat.to_lowercase())
8332        } else {
8333            (s, pat)
8334        };
8335        let mut pat_tok = pat.clone();
8336        crate::ported::glob::tokenize(&mut pat_tok);
8337        if crate::ported::pattern::patcompile(
8338            &pat_tok,
8339            crate::ported::zsh_h::PAT_STATIC as i32,
8340            None,
8341        )
8342        .is_none()
8343        {
8344            // c:314 — zwarnnam(fromtest, "bad pattern: %s", right).
8345            crate::ported::utils::zwarn(&format!("bad pattern: {}", pat));
8346            COND_BAD_PATTERN.with(|c| c.set(true));
8347            return Value::Bool(false);
8348        }
8349        // Match via the shared engine so `(#b)`/`(#m)` backref and
8350        // MATCH-variable population stays in one place.
8351        Value::Bool(crate::vm_helper::glob_match_static(&s, &pat))
8352    });
8353    vm.register_builtin(BUILTIN_COND_UNKNOWN, |vm, _argc| {
8354        // c:Src/cond.c:150-188 — `zwarnnam(fromtest, "unknown condition: %s",
8355        // name)` for a `-X` op with no matching cond module. Like a cond
8356        // syntax error it yields status 2 and aborts: arm COND_BAD_PATTERN so
8357        // the downstream BUILTIN_COND_STATUS_FROM_BOOL carries the 2 across the
8358        // Bool-shaped stack and runs the shared errflag+set_last_status(2)+abort
8359        // path (c:Src/exec.c:5216-5221). Returns Bool(false) as the operand.
8360        let op = vm.pop().to_str();
8361        crate::ported::utils::zerr(&format!("unknown condition: {}", op));
8362        COND_BAD_PATTERN.with(|c| c.set(true));
8363        Value::Bool(false)
8364    });
8365    vm.register_builtin(BUILTIN_COND_STATUS_FROM_BOOL, |vm, _argc| {
8366        // `${~pat}` / `${(P)~pat}` inside a `[[ … ]]` operand flips
8367        // GLOB_SUBST on via the tilde carrier so the pattern match sees
8368        // active metacharacters. In C that flag is prefork-scoped and
8369        // gone once the operand is consumed; zshrs restores it at the
8370        // next command-dispatch boundary, but a bare `[[ … ]]` has no
8371        // trailing assignment to trigger that — so globsubst leaked ON
8372        // into the NEXT command's word expansion, filename-generating a
8373        // scalar value it should not (p10k `_p9k_set_prompt`: line 45
8374        // `[[ … != ${(P)~disabled} ]]` leaked into line 46's
8375        // `local val=$arr[idx]`, whose glob-char-laden value then hit
8376        // "no matches found" and aborted the whole prompt build →
8377        // garbled 25-line prompt / interactive hang). Consume the
8378        // carrier here: this builtin ends EVERY `[[ … ]]`, and runs
8379        // after the operands (and their pattern match) are done.
8380        consume_tilde_globsubst_carrier();
8381        let ok = vm.pop().to_int() != 0;
8382        let bad = COND_BAD_PATTERN.with(|c| {
8383            let b = c.get();
8384            c.set(false);
8385            b
8386        });
8387        if bad {
8388            // c:Src/exec.c:5216-5221 — `stat = evalcond(...);
8389            //   /* 2 indicates a syntax error. For compatibility,
8390            //      turn this into a shell error. */
8391            //   if (stat == 2) errflag |= ERRFLAG_ERROR;`
8392            // The errflag abort exits the script with lastval (2),
8393            // matching `zsh -fc '[[ x == [a- ]]; print rc=$?'`
8394            // printing nothing after the diagnostic and exiting 2.
8395            crate::ported::utils::errflag.fetch_or(
8396                crate::ported::zsh_h::ERRFLAG_ERROR,
8397                std::sync::atomic::Ordering::Relaxed,
8398            );
8399            with_executor(|exec| exec.set_last_status(2));
8400            return Value::Int(2); // c:Src/cond.c:316 `return 2;`
8401        }
8402        let status: i32 = if ok { 0 } else { 1 };
8403        // c:Src/exec.c:5216 — `lastval = evalcond(...)`: the conditional's
8404        // result IS the command's lastval, and c:Src/cond.c's evalcond
8405        // never inspects errflag while evaluating. So when a `[[ … ]]`
8406        // operand raised errflag (e.g. a nounset "parameter not set" zerr
8407        // on `${arr[99]}` under NO_UNSET), zsh STILL completes the test and
8408        // exits with the cond result; the errflag only aborts the FOLLOWING
8409        // commands. Sync the result to the executor's live lastval HERE —
8410        // the nounset site left it at a transient 1, and the next
8411        // BUILTIN_ERREXIT_CHECK reads the executor (not vm.last_status), so
8412        // without this sync `setopt NO_UNSET; [[ -z ${arr[99]} ]]` exited 1
8413        // instead of 0. The Op::SetStatus that follows sets vm.last_status;
8414        // this keeps the executor coherent with it before the abort check.
8415        with_executor(|exec| exec.set_last_status(status));
8416        Value::Int(status as i64)
8417    });
8418    vm.register_builtin(BUILTIN_USE_CMDOUTVAL_RESET, |_vm, _argc| {
8419        crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
8420        Value::Status(0)
8421    });
8422
8423    vm.register_builtin(BUILTIN_EXEC_DYNAMIC, |vm, argc| {
8424        let raw = pop_args(vm, argc);
8425        // Flatten Array entries into argv slots (matches fusevm
8426        // Op::Exec's flatten at vm.rs:1660-1665) so `${arr[@]}` /
8427        // splice expansions produce one argv slot per element.
8428        let args: Vec<String> = raw.into_iter().collect();
8429        // c:Src/subst.c paramsubst — when `${var:?msg}` or
8430        // `${var?msg}` set errflag, the expansion may produce empty
8431        // argv[0] which would fall into the EACCES/permission-denied
8432        // path below, masking the real paramsubst diagnostic with a
8433        // spurious "permission denied:" line and rc=126. Honour
8434        // errflag so the simple command ends with the paramsubst
8435        // error as the sole diagnostic, rc=1. Bug #86.
8436        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
8437            & crate::ported::zsh_h::ERRFLAG_ERROR)
8438            != 0
8439        {
8440            return Value::Status(1);
8441        }
8442        if args.is_empty() {
8443            // c:Src/exec.c:3442 — a command whose words expand to ZERO
8444            // words is a NULL command: `cmdoutval = use_cmdoutval ?
8445            // lastval : 0`. `use_cmdoutval` is set (below, in
8446            // BUILTIN_CMD_SUBST_TEXT) only when a command substitution
8447            // ran during this command's word expansion, so:
8448            //   `false; $(exit 5)`  → keep the subst status (5)
8449            //   `false; $nonexistent` → reset to 0 (null command).
8450            // The previous port unconditionally kept `$?`, so
8451            // `false; $unset` wrongly stayed 1 (A01grammar.ztst:5).
8452            let keep =
8453                crate::ported::exec::use_cmdoutval.load(std::sync::atomic::Ordering::Relaxed) != 0;
8454            let status = if keep { vm.last_status } else { 0 };
8455            crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
8456            return Value::Status(status);
8457        }
8458        if args[0].is_empty() {
8459            // Explicit empty command word — exec returns EACCES.
8460            let script_name =
8461                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
8462            let lineno: u64 = with_executor(|exec| {
8463                exec.scalar("LINENO")
8464                    .and_then(|s| s.parse::<u64>().ok())
8465                    .unwrap_or(1)
8466            });
8467            eprintln!("{}:{}: permission denied: ", script_name, lineno);
8468            return Value::Status(126);
8469        }
8470        // AOP intercepts (zshrs extension, no C counterpart) — same
8471        // gate as host_exec_external (the static-head path): dynamic
8472        // command names (`cmd=/bin/echo; $cmd payload`) must consult
8473        // registered intercepts before dispatch, else `intercept
8474        // before /bin/echo ...` fires for the literal spelling but
8475        // not the variable one. run_intercepts runs before-advice
8476        // in-place and returns None to continue; Some(status) means
8477        // an around/after advice fully handled the command.
8478        let intercepted = with_executor(|exec| {
8479            if exec.intercepts.is_empty() {
8480                return None;
8481            }
8482            let full_cmd = if args.len() == 1 {
8483                args[0].clone()
8484            } else {
8485                args.join(" ")
8486            };
8487            let rest: Vec<String> = args[1..].to_vec();
8488            exec.run_intercepts(&args[0], &full_cmd, &rest)
8489        });
8490        if let Some(result) = intercepted {
8491            return Value::Status(result.unwrap_or(127));
8492        }
8493        // zshrs-original opcode builtins (async, doctor, peach, …) reached
8494        // via a run-time-resolved head (`$var`): they are absent from the
8495        // static BUILTINS port table / builtintab, so execcmd_exec below would
8496        // treat the head as external and report "command not found" — even
8497        // though `whence` calls it a builtin and a literal head runs it via
8498        // CallBuiltin. Dispatch by name here, but ONLY when the head is neither
8499        // a user function nor a ported builtin, so the shell's
8500        // function -> builtin -> external order is preserved.
8501        if let Some(head) = args.first() {
8502            let is_fn = with_executor(|e| e.function_exists(head));
8503            let is_ported =
8504                crate::ported::builtin::createbuiltintable().contains_key(head.as_str());
8505            if !is_fn && !is_ported {
8506                if let Some(status) = try_run_registered_builtin(head, &args[1..]) {
8507                    crate::ported::builtin::LASTVAL
8508                        .store(status, std::sync::atomic::Ordering::Relaxed);
8509                    return Value::Status(status);
8510                }
8511            }
8512        }
8513        // c:Src/exec.c:2900 execcmd_exec — canonical simple-command
8514        // dispatcher. Runs precmd-modifier walk (c:3013-3091), then
8515        // dispatches to execbuiltin (c:4233) / runshfunc (c:3431+) /
8516        // execute (c:4314) per the resolved head. zshrs's bytecode VM
8517        // expanded the args before reaching here; we feed them in via
8518        // eparams.args and let execcmd_exec do the rest exactly as C
8519        // does for static heads. Without this, `c=builtin; $c source X`
8520        // skipped the precmd walk and emitted "command not found:
8521        // builtin".
8522        let mut state = crate::ported::zsh_h::estate {
8523            prog: Box::<crate::ported::zsh_h::eprog>::default(),
8524            pc: 0,
8525            strs: None,
8526            strs_offset: 0,
8527        };
8528        let mut eparams = crate::ported::zsh_h::execcmd_params {
8529            args: Some(args),
8530            redir: None,
8531            beg: 0,
8532            varspc: None,
8533            assignspc: None,
8534            typ: crate::ported::zsh_h::WC_SIMPLE as i32,
8535            postassigns: 0,
8536            htok: 0,
8537        };
8538        // input/output=0 → no pipe redirection (use shell stdio
8539        // directly); `output != 0` at c:2988 forks immediately. last1=2
8540        // (c:Src/exec.c:2014 `last1 ? 1 : 2`): terminal pipe stage but
8541        // the shell IS needed afterward — the VM keeps executing
8542        // bytecode after this op. last1=1 would arm the fake-exec
8543        // optimization (c:3646-3651, gate at c:3662 `last1 != 1`),
8544        // making `execute()` execve THIS process for external heads:
8545        // `p=/bin/echo; $p hi; echo after` replaced the shell and
8546        // `after` never ran (D04parameter chunk 11 shell-killer).
8547        // c:Src/exec.c:1690-1700 — execpline's job frame: save thisjob
8548        // (`pj = thisjob`) and allocate the jobtab slot that
8549        // execcmd_fork's addproc (c:2853) hangs the child pid off.
8550        // Without a live thisjob, the fork at c:3662 (last1 != 1 →
8551        // external must fork) registers no proc, nothing waits, and
8552        // the child races the rest of the script.
8553        let pj = {
8554            use crate::ported::jobs;
8555            *jobs::THISJOB
8556                .get_or_init(|| std::sync::Mutex::new(-1))
8557                .lock()
8558                .unwrap_or_else(|e| e.into_inner())
8559        };
8560        let newjob = {
8561            use crate::ported::jobs;
8562            let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
8563            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
8564            jobs::initjob(&mut tab) // c:1700 `thisjob = newjob = initjob()`
8565        };
8566        {
8567            use crate::ported::jobs;
8568            *jobs::THISJOB
8569                .get_or_init(|| std::sync::Mutex::new(-1))
8570                .lock()
8571                .unwrap_or_else(|e| e.into_inner()) = newjob as i32;
8572        }
8573        crate::ported::exec::execcmd_exec(
8574            &mut state,
8575            &mut eparams,
8576            0,                                   // input  (c:2989)
8577            0,                                   // output (c:2988)
8578            crate::ported::zsh_h::Z_SYNC as i32, // how
8579            2,                                   // last1=2 — shell continues (c:2014)
8580            -1,                                  // close_if_forked
8581        );
8582        // c:Src/exec.c:1828-1835 — execpline's Z_SYNC tail: waitjobs()
8583        // reaps the forked external. c:Src/jobs.c:487-495 + 551-552 —
8584        // the job's LAST proc sets lastval (0200|sig when signalled,
8585        // else WEXITSTATUS). Builtin/shfunc heads never forked (job
8586        // has no procs) — LASTVAL was already set by execbuiltin /
8587        // doshfunc inside execcmd_exec; skip the wait.
8588        {
8589            use crate::ported::jobs;
8590            let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
8591            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
8592            if jobs::hasprocs(&tab, newjob) {
8593                jobs::waitjobs(&mut tab, newjob); // c:1835
8594                if let Some(p) = tab[newjob].procs.last() {
8595                    let val = if p.is_signaled() {
8596                        0o200 | p.term_sig() // c:Src/jobs.c:489-490
8597                    } else {
8598                        p.exit_status() // c:Src/jobs.c:494
8599                    };
8600                    crate::ported::builtin::LASTVAL
8601                        .store(val, std::sync::atomic::Ordering::Relaxed);
8602                }
8603            }
8604            // c:1977-1979 — `deletejob(jn, 0)` once done; c:1981
8605            // `thisjob = pj` restores the caller's job.
8606            if newjob < tab.len() {
8607                jobs::deletejob(&mut tab[newjob], false);
8608            }
8609            *jobs::THISJOB
8610                .get_or_init(|| std::sync::Mutex::new(-1))
8611                .lock()
8612                .unwrap_or_else(|e| e.into_inner()) = pj;
8613        }
8614        let status = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
8615        let mut synth = crate::ported::zsh_h::job::default();
8616        crate::ported::jobs::waitonejob(&mut synth);
8617        Value::Status(status)
8618    });
8619    // c:Src/exec.c:3340-3364 — `< file` / `> file` with no command
8620    // word. Resolves NULLCMD/READNULLCMD at runtime then routes
8621    // through host_exec_external. Redirects are already applied by
8622    // the surrounding WithRedirectsBegin scope.
8623    vm.register_builtin(BUILTIN_NULLCMD_EXEC, |vm, argc| {
8624        let args = pop_args(vm, argc);
8625        let is_single_read = args
8626            .first()
8627            .map(|s| s != "0" && !s.is_empty())
8628            .unwrap_or(false);
8629        // c:Src/exec.c — when the surrounding redir-open failed
8630        // (e.g. `< /nonexistent`), zerr already printed the diag
8631        // and set redirect_failed. Don't invoke NULLCMD — return
8632        // status 1 like the wordcode path does.
8633        let redir_failed = with_executor(|exec| {
8634            let f = exec.redirect_failed;
8635            exec.redirect_failed = false;
8636            f
8637        });
8638        if redir_failed {
8639            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
8640            return Value::Status(1);
8641        }
8642        let nullcmd = crate::ported::params::getsparam("NULLCMD");
8643        let nc_str = nullcmd.as_deref().unwrap_or("");
8644        let nc_empty = nc_str.is_empty();
8645        // c:3340-3344 — CSHNULLCMD or no NULLCMD set → diagnostic.
8646        if nc_empty || crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLCMD) {
8647            let script_name =
8648                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
8649            let lineno: u64 = with_executor(|exec| {
8650                exec.scalar("LINENO")
8651                    .and_then(|s| s.parse::<u64>().ok())
8652                    .unwrap_or(1)
8653            });
8654            eprintln!("{}:{}: redirection with no command", script_name, lineno);
8655            return Value::Status(1);
8656        }
8657        // c:3350 — SHNULLCMD → run `:`.
8658        let cmd: String = if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHNULLCMD) {
8659            ":".to_string()
8660        } else if is_single_read {
8661            // c:3354-3359 — single REDIR_READ + READNULLCMD set → readnullcmd.
8662            let rnc = crate::ported::params::getsparam("READNULLCMD");
8663            let rnc_str = rnc.as_deref().unwrap_or("");
8664            if !rnc_str.is_empty() {
8665                rnc_str.to_string()
8666            } else {
8667                nc_str.to_string() // c:3360-3363 fallback
8668            }
8669        } else {
8670            nc_str.to_string() // c:3360-3363
8671        };
8672        let status = with_executor(|exec| exec.host_exec_external(&[cmd]));
8673        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
8674        Value::Status(status)
8675    });
8676    // c:Src/exec.c:3342 — `zerr("redirection with no command")`.
8677    // Bare prefix-keyword (`builtin`, `command`, `exec`, `noglob`,
8678    // `nocorrect`) with a redirect but no command word. Emits the
8679    // canonical diagnostic via zerr (which sets errflag) and
8680    // returns Status(1). Bug #534.
8681    vm.register_builtin(BUILTIN_REDIR_NO_CMD, |_vm, _argc| {
8682        crate::ported::utils::zerr("redirection with no command");
8683        Value::Status(1)
8684    });
8685    vm.register_builtin(BUILTIN_DEBUG_TRAP, |vm, _argc| {
8686        // c:Src/signals.c:1245 dotrap(SIGDEBUG) — fires the DEBUG
8687        // trap body once per statement. The body sees the parent
8688        // shell's $? (LASTVAL). Guard against re-entry: commands
8689        // inside the DEBUG trap body would otherwise trigger
8690        // DEBUG_TRAP recursively → stack overflow. zsh guards via
8691        // its in_trap counter; we mirror with a thread-local Cell.
8692        //
8693        // c:Src/exec.c::trapcmd — before dotrap, the C source sets
8694        // `ZSH_DEBUG_CMD` to the about-to-run command text via
8695        // `dupstring(text)`. The trap body reads the parameter;
8696        // C unsets it after the trap returns. compile_list emits
8697        // the rendered statement text as the single arg here so the
8698        // shell-visible parameter reflects the command. Bug #263 in
8699        // docs/BUGS.md.
8700        let cmd_text = vm.pop().to_str();
8701        DEBUG_TRAP_REENTRY.with(|c| {
8702            if c.get() {
8703                return Value::Status(0);
8704            }
8705            // c:Src/exec.c:1423 — `if (sigtrapped[SIGDEBUG] &&
8706            // isset(DEBUGBEFORECMD) && !intrap)`. Bug #573: without
8707            // this gate, every sublist boundary called
8708            // setsparam("ZSH_DEBUG_CMD", ...) even when no DEBUG trap
8709            // was set, polluting the param table and (under
8710            // WARN_CREATE_GLOBAL) emitting a spurious
8711            // `scalar parameter ZSH_DEBUG_CMD created globally`
8712            // warning at every function call.
8713            //
8714            // Two trap registries exist (per signals.rs:1481-1511 dotrap):
8715            //   - settrap path → sigtrapped[SIGDEBUG] bits set
8716            //   - bin_trap path → traps_table["DEBUG"] populated, sigtrapped untouched
8717            // Mirror the dotrap dispatch decision: skip only when BOTH
8718            // are absent.
8719            let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
8720            let debug_trapped = crate::ported::signals::sigtrapped
8721                .lock()
8722                .map(|v| v.get(sig_debug).copied().unwrap_or(0))
8723                .unwrap_or(0);
8724            let debug_in_table = crate::ported::builtin::traps_table()
8725                .lock()
8726                .map(|t| t.contains_key("DEBUG"))
8727                .unwrap_or(false);
8728            if debug_trapped == 0 && !debug_in_table {
8729                return Value::Status(0);
8730            }
8731            c.set(true);
8732            // c:Src/exec.c — set ZSH_DEBUG_CMD scalar (PM_READONLY
8733            // is NOT set on ZSH_DEBUG_CMD, so the canonical
8734            // setsparam path is fine here — no direct paramtab
8735            // mutation needed).
8736            crate::ported::params::setsparam("ZSH_DEBUG_CMD", &cmd_text);
8737            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGDEBUG);
8738            // c:Src/exec.c::trapcmd — `unsetparam("ZSH_DEBUG_CMD")`
8739            // after the trap returns. Mirror that.
8740            crate::ported::params::unsetparam("ZSH_DEBUG_CMD");
8741            c.set(false);
8742            Value::Status(0)
8743        })
8744    });
8745
8746    // Fatal-only abort check emitted between the pipes of an `&&` / `||`
8747    // chain, where the full errexit check is suppressed. Mirrors ONLY the
8748    // errflag arm of BUILTIN_ERREXIT_CHECK below: an errflag abandons the
8749    // list in zsh, and no connector can consume it.
8750    vm.register_builtin(BUILTIN_FATAL_ABORT_CHECK, |vm, _argc| {
8751        use std::sync::atomic::Ordering;
8752        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
8753            & crate::ported::zsh_h::ERRFLAG_ERROR)
8754            != 0;
8755        if !errflag_set || isset(crate::ported::zsh_h::INTERACTIVE) {
8756            return Value::Int(0);
8757        }
8758        // CONTINUE_ON_ERROR: clear and keep going, as the full check does.
8759        if isset(crate::ported::zsh_h::CONTINUEONERROR) {
8760            crate::ported::utils::errflag
8761                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
8762            return Value::Int(0);
8763        }
8764        // Abort the chain with the failing command's own status intact —
8765        // a cond syntax error left lastval=2 (c:Src/exec.c:5216-5221), and
8766        // that 2 is what zsh exits with. Reading the executor's live
8767        // lastval (not forcing 1) is the same rule the full check uses.
8768        vm.last_status = with_executor(|exec| exec.last_status());
8769        Value::Int(1)
8770    });
8771    vm.register_builtin(BUILTIN_ERREXIT_CHECK, |vm, _argc| {
8772        // Returns Value::Int(1) when the caller should jump to the
8773        // current scope's return-patch landing (subshell-end / func-
8774        // end / chunk-end). Returns Value::Int(0) otherwise. Emit
8775        // side at `emit_errexit_check` pairs this with a JumpIfTrue
8776        // → return_patches pattern so the caller can short-circuit.
8777        //
8778        // Four triggers:
8779        //   1. RETFLAG set by a nested `return` / `exit` (eval,
8780        //      sourced file, called function). Unwind THIS scope so
8781        //      the flag propagates outward until something clears it.
8782        //   2. EXIT_PENDING set (mostly subshell-context exits). Same
8783        //      propagation logic.
8784        //   3. `set -e` + nonzero status — the classic errexit path.
8785        //   4. errflag set in non-interactive mode — readonly
8786        //      reassign, bad redirect, parse error mid-expansion etc.
8787        //      Aborts the script (c:Src/init.c loop()).
8788        use std::sync::atomic::Ordering;
8789        let retflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
8790        let exit_pending = crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed);
8791        // c:Src/exec.c:1571-1603 — `sublist_done:` runs the ZERR trap for
8792        // the sublist that just failed. It is NOT gated on retflag: C only
8793        // consults retflag at the TOP of the list loop (c:1370 `while
8794        // (wc_code(code) == WC_LIST && !breaks && !retflag && !errflag)`),
8795        // which stops the NEXT sublist — the current one still completes
8796        // its sublist_done. So `return 5` fires the ERR trap on its way out.
8797        //
8798        // zshrs's escape short-circuit below returns before ever reaching
8799        // the ZERR fire, so a `return N` inside a try-list skipped the trap:
8800        //   f() { { return 5 } always { print fin } }; f
8801        // printed `fin / err=5` where zsh prints `err=5 / fin / err=5`.
8802        // (Plain `f() { return 5 }` matched by luck — the inner fire was
8803        // missing but the OUTER sublist fired instead, since doshfunc had
8804        // cleared retflag by then and DONETRAP was still 0.)
8805        //
8806        // `exit` is deliberately excluded: C's `exit` goes zexit() →
8807        // realexit(), leaving the process without ever reaching
8808        // sublist_done. Verified: `zsh -fc 'trap "print err" ERR; f(){ exit
8809        // 5 }; f'` prints nothing.
8810        if retflag != 0 && exit_pending == 0 {
8811            let last = vm.last_status;
8812            // c:1598-1603 — same DONETRAP gate as the non-escape path below.
8813            if last != 0 && crate::ported::exec::DONETRAP.load(Ordering::Relaxed) == 0 {
8814                // c:Src/signals.c:1085-1087 — `int obreaks = breaks; int
8815                // oretflag = retflag; int olastval = lastval;` and c:1220-1222
8816                // — `breaks += obreaks; retflag = oretflag;`. dotrapargs
8817                // brackets EVERY trap dispatch with this save/restore because
8818                // the trap body runs as a normal list and would otherwise
8819                // consume the caller's control-flow flags. That matters
8820                // exactly here: we are firing ZERR while retflag is SET, and a
8821                // FUNCTION-form trap (`TRAPZERR() { … }`) goes through
8822                // doshfunc, whose epilogue eats retflag outright
8823                // (c:Src/exec.c:6047-6052 `if (retflag) { retflag = 0; breaks
8824                // = funcsave->breaks; }`). Without the bracket the pending
8825                // `return 5` was swallowed by its own ERR trap and the
8826                // function ran on:
8827                //   TRAPZERR() { print z }; f() { { return 2 } always { : }
8828                //                             print after }; f
8829                // printed `after`, where zsh returns from f.
8830                //
8831                // zshrs's `dotrap` inlines the dispatch and does not carry
8832                // dotrapargs' save/restore, so the bracket lives at this call
8833                // site. lastval is restored too (c:1087 / c:1213 `lastval =
8834                // olastval`) — the trap body's own commands must not become
8835                // the caller's `$?`.
8836                let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
8837                let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
8838                let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
8839                let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
8840                crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
8841                crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
8842                crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
8843                crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed); // c:1213
8844            }
8845        }
8846        if retflag != 0 || exit_pending != 0 {
8847            if exit_pending != 0 {
8848                // c:Src/builtin.c zexit — the deferred exit carries its
8849                // status in EXIT_VAL; sync it into the VM counter so
8850                // the top-level unwind reports it as the script's exit
8851                // (run_chunk returns vm.last_status). Without this, a
8852                // POSIX-fatal `.` failure exited 127 (bin_dot's return)
8853                // instead of C's exit(1) at Src/exec.c:4383.
8854                vm.last_status = crate::ported::builtin::EXIT_VAL.load(Ordering::Relaxed) & 0xFF;
8855            }
8856            return Value::Int(1);
8857        }
8858        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
8859            & crate::ported::zsh_h::ERRFLAG_ERROR)
8860            != 0;
8861        // c:Src/init.c:1931 — `if (errflag && !interact &&
8862        // !isset(CONTINUEONERROR)) { errexit = 1; break; }` — with
8863        // CONTINUE_ON_ERROR set, the top-level do-while re-enters
8864        // loop() and the NEXT list runs instead of the shell exiting.
8865        // Clear the flag so the next statement starts clean (the
8866        // failed statement's lastval is already in place).
8867        if errflag_set
8868            && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
8869            && crate::ported::zsh_h::isset(crate::ported::zsh_h::CONTINUEONERROR)
8870        {
8871            crate::ported::utils::errflag
8872                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
8873            return Value::Int(0);
8874        }
8875        if errflag_set && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE) {
8876            // c:Src/exec.c execlist — every enclosing list loop runs
8877            // `while (... && !errflag)`, so a set errflag breaks the
8878            // CURRENT scope and the check in the enclosing scope
8879            // breaks THAT one, all the way out. Leave errflag SET —
8880            // do NOT convert it to EXIT_PENDING: a process-exit
8881            // signal tunnels through the containment boundaries C
8882            // has, namely eval (Src/builtin.c:6221 `errflag &=
8883            // ~ERRFLAG_ERROR`), source (Src/init.c:1663 same), fork
8884            // boundaries (subshell/cmdsubst — child's errflag dies
8885            // with the child), and the interactive toplevel
8886            // (Src/init.c:139). Those boundaries clear errflag
8887            // themselves and execution continues past them; with
8888            // EXIT_PENDING armed here, `eval 'assoc=(odd)'; echo
8889            // after` aborted the whole script where zsh 5.9 prints
8890            // `after` (eval status 1). Bug #74's function case
8891            // (`f() { local -r x=5; x=10; }; f; echo after`) still
8892            // aborts: the function scope unwinds on THIS check, and
8893            // the caller's next ERREXIT_CHECK sees the still-set
8894            // errflag and unwinds too — exactly C's propagation.
8895            //
8896            // c:Src/init.c:234 — loop() BREAKS on errflag and
8897            // zsh_main exits with the UNTOUCHED lastval, NOT a
8898            // forced 1: `typeset -i x=3#8` (math error during the
8899            // assignment, before typeset sets a status) exits 0 in
8900            // zsh; a cond syntax error set lastval=2 (exec.c:5216-
8901            // 5221) and zsh exits 2; the readonly-reassign case
8902            // exits 1 because ITS lastval is 1. Sync the VM counter
8903            // from the executor's live lastval instead of
8904            // overwriting.
8905            vm.last_status = with_executor(|exec| exec.last_status());
8906            // c:Src/exec.c:1598-1603 — `sublist_done:` runs the ZERR trap
8907            // for the failed sublist BEFORE the enclosing list loop breaks
8908            // on errflag (`while (... && !errflag)` at c:1370). So an
8909            // errflag-setting command (readonly reassign, bad redirect)
8910            // must fire ZERR on its way out, exactly like the retflag
8911            // escape above and the non-escape fall-through below. Without
8912            // this the errflag early-return pre-empted the ZERR block
8913            // further down, so `TRAPZERR() { … }; typeset -r ro=1; ro=2`
8914            // aborted the script (correct) but never fired the trap. Same
8915            // DONETRAP gate + dotrapargs save/restore bracket
8916            // (c:signals.c:1085-1087 / 1213-1222) as the retflag branch:
8917            // a function-form TRAPZERR runs through doshfunc and would
8918            // otherwise consume the caller's breaks/retflag/lastval.
8919            let last = with_executor(|exec| exec.last_status());
8920            if last != 0 && crate::ported::exec::DONETRAP.load(Ordering::Relaxed) == 0 {
8921                let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
8922                let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
8923                let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
8924                // c:Src/signals.c:1101 — dotrapargs returns early if errflag
8925                // is set, and c:1174/1205-1218 brackets the dispatch with
8926                // `traperr = errflag` … restore. The failing assignment left
8927                // errflag SET, so the trap body (`print zerr`) would itself
8928                // bail on the first op. Clear errflag across the dispatch so
8929                // the body runs, then restore it so the script still aborts.
8930                let oerrflag = crate::ported::utils::errflag.load(Ordering::Relaxed); // c:1174
8931                crate::ported::utils::errflag.store(0, Ordering::Relaxed);
8932                let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
8933                crate::ported::utils::errflag.store(oerrflag, Ordering::Relaxed); // c:1216
8934                crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
8935                crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
8936                crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
8937                crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed); // c:1213
8938            }
8939            return Value::Int(1);
8940        }
8941        let last = vm.last_status;
8942        if last == 0 {
8943            return Value::Int(0);
8944        }
8945        // c:Src/exec.c:1598 `if (!this_noerrexit && !donetrap &&
8946        // !this_donetrap)` — gate the ZERR trap fire on DONETRAP so
8947        // an inner sublist (e.g. `false` inside a function) that
8948        // already fired ZERR doesn't fire it AGAIN at the outer
8949        // sublist's post-command check (after the function
8950        // returned non-zero). Bug #303 in docs/BUGS.md. DONETRAP
8951        // is reset at top-level statement boundaries via
8952        // BUILTIN_DONETRAP_RESET (compile_list emit at
8953        // compile_zsh.rs).
8954        let already_done = crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
8955        if !already_done {
8956            // c:Src/signals.c:1245 dotrap(SIGZERR) — canonical ZERR
8957            // trap dispatch. Fires whenever a command exits
8958            // non-zero.
8959            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR);
8960            // c:1602 — `donetrap = 1;` after firing.
8961            crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed);
8962        }
8963        // c:Src/exec.c:1605-1610 — compute errreturn / errexit.
8964        //   errreturn = ERRRETURN && (INTERACTIVE || locallevel || sourcelevel)
8965        //               && !(noerrexit & NOERREXIT_RETURN)
8966        //   errexit   = (ERREXIT || (ERRRETURN && !errreturn))
8967        //               && !(noerrexit & NOERREXIT_EXIT)
8968        let no_err = crate::ported::exec::noerrexit.load(Ordering::Relaxed);
8969        let locallvl = crate::ported::params::locallevel.load(Ordering::Relaxed);
8970        let sourcelvl = crate::ported::init::sourcelevel.load(Ordering::Relaxed);
8971        let errreturn_opt = isset(crate::ported::zsh_h::ERRRETURN);
8972        let in_unwindable_scope =
8973            isset(crate::ported::zsh_h::INTERACTIVE) || locallvl != 0 || sourcelvl != 0;
8974        let errreturn = errreturn_opt
8975            && in_unwindable_scope
8976            && (no_err & crate::ported::zsh_h::NOERREXIT_RETURN) == 0;
8977        if errreturn {
8978            // c:1620-1623 — `retflag = 1; breaks = loops;` — unwind to
8979            // function boundary without exiting the shell.
8980            crate::ported::builtin::RETFLAG.store(1, Ordering::Relaxed);
8981            let loops = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
8982            crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed);
8983            return Value::Int(1);
8984        }
8985        let (errexit_on, in_subshell) = with_executor(|exec| {
8986            let on_canonical = isset(ERREXIT) || (errreturn_opt && !errreturn); // c:1608-1609
8987            let on_legacy = opt_state_get("errexit").unwrap_or(false);
8988            (
8989                (on_canonical || on_legacy) && (no_err & crate::ported::zsh_h::NOERREXIT_EXIT) == 0,
8990                !exec.subshell_snapshots.is_empty(),
8991            )
8992        });
8993        if !errexit_on {
8994            return Value::Int(0);
8995        }
8996        // c:Src/exec.c:1611-1618 — under ERR_EXIT a failing command exits the
8997        // whole shell via realexit() FROM THE POINT OF FAILURE, before any
8998        // enclosing `always` arm can run. zsh 5.9.2 (the reference) has no
8999        // `this_noerrexit` deferral, so at top-level / function scope the
9000        // faithful behavior is to process-exit here (zexit fires the SIGEXIT
9001        // trap and exits). This bypasses the always arm, fixing
9002        // `setopt errexit; { false } always { print A }` which wrongly ran the
9003        // always body: the deferred EXIT_PENDING routed the unwind through
9004        // always_entry (compile_zsh.rs re-points it there) and
9005        // SET_TRY_BLOCK_ERROR then cleared the pending exit so the body ran.
9006        if crate::ported::builtin::SUBSHELL_DEPTH.load(Ordering::Relaxed) == 0 {
9007            crate::ported::builtin::zexit(last, crate::ported::zsh_h::ZEXIT_NORMAL); // c:1618 realexit
9008        }
9009        // Subshell: zshrs runs subshells in-process, so it cannot process-exit
9010        // the whole shell here — defer to the subshell-end unwind.
9011        crate::ported::builtin::EXIT_VAL.store(last, Ordering::Relaxed);
9012        crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
9013        let _ = in_subshell;
9014        Value::Int(1)
9015    });
9016
9017    // BUILTIN_ASSIGN_ONLY_STATUS — status of an assignment-only
9018    // simple command. c:Src/exec.c:3393-3396 (execcmd_exec, no
9019    // command word + varspc): `if (errflag) lastval = 1; else
9020    // lastval = cmdoutval;`; same shape at c:1322 (execsimple
9021    // WC_ASSIGN: `lv = (errflag ? errflag : cmdoutval)`) and
9022    // c:3977 (nullexec=2 redir variant). cmdoutval is the exit of
9023    // a `$()` that ran in an RHS (already in vm.last_status via
9024    // compile_assign's per-assign SetStatus), 0 otherwise. The
9025    // store goes to the canonical LASTVAL too — that IS C's single
9026    // `lastval` global; without it the errflag-abort path
9027    // (BUILTIN_ERREXIT_CHECK trigger 4) syncs vm.last_status from
9028    // a stale LASTVAL and `readonly r=1; r=2` exited 0, not 1.
9029    vm.register_builtin(BUILTIN_ASSIGN_ONLY_STATUS, |vm, _argc| {
9030        use std::sync::atomic::Ordering;
9031        let had_cmd_subst = vm.pop().to_int() != 0;
9032        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
9033            & crate::ported::zsh_h::ERRFLAG_ERROR)
9034            != 0;
9035        // c:Src/exec.c addvars — `if (!pm) { lastval = 1; if
9036        // (!cmdoutval) cmdoutval = 1; }` (assignment-failed cheat).
9037        let assign_failed = ASSIGN_FAILED_FLAG.swap(false, std::sync::atomic::Ordering::Relaxed);
9038        let status = if errflag_set || assign_failed {
9039            1 // c:Src/exec.c:3394 `lastval = 1` / addvars cmdoutval=1
9040        } else if had_cmd_subst {
9041            vm.last_status // c:3396 `lastval = cmdoutval` (subst exit)
9042        } else {
9043            0 // c:3396 `lastval = cmdoutval` (cmdoutval = 0)
9044        };
9045        with_executor(|exec| exec.set_last_status(status));
9046        // c:Src/jobs.c deletefilelist — a `=(cmd)` temp file is bound to the
9047        // JOB of the command that created it and unlinked when that command
9048        // completes (Src/exec.c:5588 for the shfunc case; the simple-command
9049        // job's filelist likewise). An assignment-only command like
9050        // `f==(cmd)` has no consuming builtin/exec, so the PsubFdGuard that
9051        // cleans consuming commands never fires — the temp leaked and a later
9052        // `$(<$f)` / `[[ -f $f ]]` still saw it, where zsh deletes it at the
9053        // end of the assignment (verified: even `f==(x) && cat $f` fails).
9054        // Clean here so the assignment command is the temp's job boundary.
9055        close_pending_psub_fds();
9056        Value::Status(status)
9057    });
9058
9059    // `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
9060    // Pops [name, op_byte, rhs] (rhs popped first). Returns the modified
9061    // value as Value::Str. Handles unset/empty distinction (`:-` etc.
9062    // treat empty same as unset, matching POSIX).
9063    // BUILTIN_PARAM_DEFAULT_FAMILY — `${var-x}` / `${var:-x}` / `${var=x}` /
9064    // `${var:=x}` / `${var?x}` / `${var:?x}` / `${var+x}` / `${var:+x}`.
9065    // PURE PASSTHRU: pop name + op + rhs, reconstruct the canonical
9066    // brace expression, hand to `subst::paramsubst` (C port of
9067    // `Src/subst.c::paramsubst`). All "missing vs empty" gating,
9068    // nounset suppression, default-evaluation, and elide-empty-words
9069    // semantics live inside paramsubst.
9070    vm.register_builtin(BUILTIN_PARAM_DEFAULT_FAMILY, |vm, _argc| {
9071        let rhs = vm.pop().to_str();
9072        let op = vm.pop().to_int() as u8;
9073        let name = vm.pop().to_str();
9074        // op=8 is the `${+name}` set-test prefix form (distinct from the
9075        // `${name+rhs}` substitute-if-set suffix form which is op=7).
9076        // Per compile_zsh.rs::parse_param_modifier: the `+` is emitted as
9077        // a leading sigil and `rhs` is empty.
9078        let body = if op == 8 {
9079            format!("${{+{}}}", name)
9080        } else {
9081            let op_str = match op {
9082                0 => ":-",
9083                1 => ":=",
9084                2 => ":?",
9085                3 => ":+",
9086                4 => "-",
9087                5 => "=",
9088                6 => "?",
9089                7 => "+",
9090                _ => "-",
9091            };
9092            format!("${{{}{}{}}}", name, op_str, rhs)
9093        };
9094        paramsubst_to_value(&body)
9095    });
9096
9097    // `${var:offset[:length]}` — substring. Pops [name, offset, length].
9098    // length == -1 means "rest of string". Negative offset counts from end.
9099    // BUILTIN_PARAM_SUBSTRING — `${var:offset:length}` literal-int form.
9100    // PURE PASSTHRU: reconstruct `${name:offset:length}` and route
9101    // through `subst::paramsubst`. Length sentinel `i64::MIN` =
9102    // "no length given" (omit the `:length` portion).
9103    //
9104    // c:Src/subst.c:1571,3781 — `${name:-N}` is the colon-default
9105    // operator, NOT a substring with negative offset. zsh's lexical
9106    // rule disambiguates via a literal space: `${name: -N}` (space
9107    // before `-`) is the substring form. The reconstructed body MUST
9108    // preserve that space when offset < 0; otherwise paramsubst's
9109    // `:-` dispatch fires on the synthesized `${name:-N}` body and
9110    // returns N as the unset-default instead of slicing the last N
9111    // chars. Length-form `${name:-N:M}` has the same trap.
9112    vm.register_builtin(BUILTIN_PARAM_SUBSTRING, |vm, _argc| {
9113        let length = vm.pop().to_int();
9114        let offset = vm.pop().to_int();
9115        let name = vm.pop().to_str();
9116        // !!! DASH-STRICT GATE !!! dash/ash have no `${var:offset:length}`
9117        // substring expansion (it is a "Bad substitution"); bash/ksh/sh do.
9118        if crate::dash_mode::dash_strict() {
9119            crate::ported::utils::zerr("bad substitution");
9120            crate::ported::utils::errflag.fetch_or(
9121                crate::ported::zsh_h::ERRFLAG_ERROR,
9122                std::sync::atomic::Ordering::Relaxed,
9123            );
9124            with_executor(|exec| exec.set_last_status(1));
9125            return Value::str("");
9126        }
9127        let off_sep = if offset < 0 { " " } else { "" };
9128        let body = if length == i64::MIN {
9129            format!("${{{}:{}{}}}", name, off_sep, offset)
9130        } else {
9131            format!("${{{}:{}{}:{}}}", name, off_sep, offset, length)
9132        };
9133        paramsubst_to_value(&body)
9134    });
9135
9136    // BUILTIN_PARAM_SUBSTRING_EXPR — `${var:offset_expr[:length_expr]}` form.
9137    // PURE PASSTHRU: rebuild `${name:offset:length}` using the
9138    // expression text verbatim (paramsubst's offset/length
9139    // parser evaluates arith / param refs itself).
9140    //
9141    // c:Src/subst.c:1571,3781 — same `:-` disambiguation trap as
9142    // BUILTIN_PARAM_SUBSTRING. The expression text may itself start
9143    // with `-` (e.g. `${VAR:$((-1))}` arith resolves at the body-
9144    // assembly layer in some upstream paths, leaving `-1` in
9145    // off_expr). Insert a leading space when off_expr starts with
9146    // `-` so paramsubst's check_colon_subscript (subst.c:1571)
9147    // accepts the operand as a math expression instead of the
9148    // `:-` operator catching it.
9149    vm.register_builtin(BUILTIN_PARAM_SUBSTRING_EXPR, |vm, _argc| {
9150        let has_len = vm.pop().to_int() != 0;
9151        let len_expr = vm.pop().to_str();
9152        let off_expr = vm.pop().to_str();
9153        let name = vm.pop().to_str();
9154        let off_sep = if off_expr.starts_with('-') { " " } else { "" };
9155        let body = if has_len {
9156            format!("${{{}:{}{}:{}}}", name, off_sep, off_expr, len_expr)
9157        } else {
9158            format!("${{{}:{}{}}}", name, off_sep, off_expr)
9159        };
9160        paramsubst_to_value(&body)
9161    });
9162
9163    // `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}`
9164    // Pops [name, pattern, op_byte]. op: 0=`#` short-prefix, 1=`##` long,
9165    // 2=`%` short-suffix, 3=`%%` long. Glob-pattern matching via the
9166    // existing glob_match_static helper.
9167    // BUILTIN_PARAM_STRIP — `${var#pat}` / `${var##pat}` / `${var%pat}` /
9168    // `${var%%pat}`. PURE PASSTHRU: reconstruct the brace expression
9169    // and route through `subst::paramsubst`. (M)/(S) flags arrive
9170    // through SUB_FLAGS (already inside paramsubst's scope), so we
9171    // just clear the bridge-side cached read.
9172    vm.register_builtin(BUILTIN_PARAM_STRIP, |vm, _argc| {
9173        let _dq_flag = vm.pop().to_int() != 0;
9174        let op = vm.pop().to_int() as u8;
9175        let pattern = vm.pop().to_str();
9176        let name = vm.pop().to_str();
9177        let op_str = match op {
9178            0 => "#",
9179            1 => "##",
9180            2 => "%",
9181            3 => "%%",
9182            _ => "#",
9183        };
9184        let body = format!("${{{}{}{}}}", name, op_str, pattern);
9185        paramsubst_to_value(&body)
9186    });
9187
9188    // `$((expr))` — pops [expr_string], evaluates via MathEval which
9189    // honors integer-vs-float distinction (zsh-compatible). Returns
9190    // the result as Value::Str so it can be Concat'd into surrounding
9191    // word context.
9192    vm.register_builtin(BUILTIN_ARITH_EVAL, |vm, _argc| {
9193        // Pure path: evaluate expr, return string. errflag may be
9194        // set by arithsubst on math error; the caller decides
9195        // whether to clear it. For `(( ... ))` (math command) the
9196        // compile_arith path clears via BUILTIN_ARITH_CMD_FINISH;
9197        // for `$((... ))` (substitution inside another command)
9198        // errflag stays set so the surrounding command aborts —
9199        // matches c:Src/math.c "math errors propagate as errflag
9200        // through the containing word expansion".
9201        let expr = vm.pop().to_str();
9202        let result = crate::ported::subst::arithsubst(&expr, "", "");
9203        let _ = vm; // silence unused warning when no math error path mutates
9204        Value::str(result)
9205    });
9206
9207    // After-call hook used by compile_arith's `(( ... ))` path: when
9208    // arithsubst set errflag (math error), clear it and signal
9209    // status=2 in vm.last_status — matches zsh's c:exec.c arith-
9210    // failure: the math command exits 2 and the script continues.
9211    vm.register_builtin(BUILTIN_ARITH_CMD_FINISH, |vm, _argc| {
9212        use std::sync::atomic::Ordering;
9213        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
9214        let err = live & crate::ported::zsh_h::ERRFLAG_ERROR;
9215        let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
9216        if err != 0 {
9217            // c:Src/subst.c:3344 — when `${var:?msg}` fires, errflag
9218            // is OR'd with ERRFLAG_HARD to signal a script-abort
9219            // error (vs a recoverable math error like `$((1/0))`).
9220            // Clear only the ERRFLAG_ERROR bit; preserve
9221            // ERRFLAG_HARD so the next ERREXIT_CHECK aborts the
9222            // script. Bug #193 in docs/BUGS.md.
9223            if hard != 0 {
9224                // Keep ERRFLAG_HARD AND ERRFLAG_ERROR set so the
9225                // script-abort gate downstream still fires.
9226                vm.last_status = 2;
9227                Value::Status(2)
9228            } else {
9229                crate::ported::utils::errflag
9230                    .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
9231                vm.last_status = 2;
9232                Value::Status(2)
9233            }
9234        } else {
9235            Value::Status(vm.last_status)
9236        }
9237    });
9238
9239    // `$(cmd)` — pops [cmd_string], routes through
9240    // run_command_substitution which performs an in-process pipe-capture.
9241    // Avoids the Op::CmdSubst sub-chunk word-emit bug
9242    // (`printf "a\nb"` produced "anb" via that path). Returns trimmed
9243    // output (trailing newlines stripped per POSIX cmd-sub semantics).
9244    vm.register_builtin(BUILTIN_CMD_SUBST_TEXT, |vm, _argc| {
9245        let cmd = vm.pop().to_str();
9246        // Inherit live $? into the inner shell so cmd-subst sees the
9247        // parent's most recent exit. Same rationale as the mode-3
9248        // backtick path above.
9249        let live_status = vm.last_status;
9250        let result = with_executor(|exec| {
9251            exec.set_last_status(live_status);
9252            exec.run_command_substitution(&cmd)
9253        });
9254        // Mirror run_command_substitution's exec.last_status side
9255        // effect into the VM's live counter so a containing
9256        // assignment's BUILTIN_SET_VAR — which reads vm.last_status
9257        // — sees the cmd-subst's exit. Without this, `a=$(false);
9258        // echo $?` reads stale 0 (vm.last_status was zeroed by
9259        // compile_assign's prelude SetStatus, and run_cmd_subst only
9260        // updated exec.last_status). Pull the value back through
9261        // exec since it owns the canonical post-subst record.
9262        let cs_status = with_executor(|exec| exec.last_status());
9263        vm.last_status = cs_status;
9264        // c:Src/exec.c — a command substitution running during a
9265        // command's word expansion makes its exit the status of an
9266        // otherwise-empty command (`$(exit 5)` → 5). Flag it so
9267        // BUILTIN_EXEC_DYNAMIC's null-command branch keeps `$?` instead
9268        // of resetting to 0.
9269        crate::ported::exec::use_cmdoutval.store(1, std::sync::atomic::Ordering::Relaxed);
9270        Value::str(result)
9271    });
9272
9273    // Text-based word expansion. Pops [preserved_text, mode_byte].
9274    // mode_byte:
9275    //   0 = Default — expand_string + xpandbraces + expand_glob
9276    //   1 = DoubleQuoted — strip outer `"…"`, expand_string only
9277    //         (no brace, no glob — DQ semantics)
9278    //   2 = SingleQuoted — strip outer `'…'`, no expansion
9279    //         (kept for symmetry; Snull early-return covers most SQ)
9280    //   3 = AltBackquote — strip backticks, run as cmd-sub
9281    //   7 = RedirTarget — same as Default but glob gated on MULTIOS
9282    //         (c:Src/glob.c:2161-2167 xpandredir)
9283    // Single result → Value::str; multi → Value::Array.
9284    vm.register_builtin(BUILTIN_EXPAND_TEXT, |vm, _argc| {
9285        let mode = vm.pop().to_int() as u8;
9286        let text = vm.pop().to_str();
9287        // Sync vm.last_status → exec.last_status so cmd-subst (mode 3)
9288        // and any nested $? reads inside singsub see the live `$?`
9289        // from the most recent VM op. Without this, cmd-subst inside
9290        // arg-eval saw a stale exec.last_status that was zeroed at
9291        // the start of the current statement. Direct port of zsh's
9292        // pre-cmdsubst lastval propagation per Src/exec.c:4770.
9293        let live_status = vm.last_status;
9294        with_executor(|exec| exec.set_last_status(live_status));
9295        let result_value = with_executor(|exec| match mode {
9296            // Mode 1 = DoubleQuoted (argument context).
9297            // Mode 5 = DoubleQuoted in scalar-assignment context.
9298            // Both share the same DQ unescape pre-processing; mode 5
9299            // additionally bumps `in_scalar_assign` so subst_port's
9300            // paramsubst sees ssub=true and suppresses split flags
9301            // `(f)` / `(s:STR:)` / `(0)` per Src/subst.c:1759 +
9302            // Src/exec.c::addvars line 2546 (the PREFORK_SINGLE bit
9303            // C zsh sets when prefork-ing the assignment RHS).
9304            1 | 5 => {
9305                // DoubleQuoted: strip outer `"…"` if present. In DQ
9306                // context, `\` escapes the DQ-special chars `$`, `` ` ``,
9307                // `"`, `\`. zsh's expand_string expects the lexer's
9308                // `\0X` literal-marker for an already-escaped char, so
9309                // we pre-process: `\$` → `\0$`, `\\` → `\0\`, etc. Then
9310                // expand_string handles the rest.
9311                let inner = if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
9312                    &text[1..text.len() - 1]
9313                } else {
9314                    text.as_str()
9315                };
9316                // The lexer's dquote_parse (Src/lex.c) already tokenized
9317                // DQ contents: `$` → Qstring (\u{8c}), `\$`/`\\`/`\"`/
9318                // `` \` `` → Bnull (\u{9f}) + literal. Stringsubst /
9319                // multsub recognize these markers natively. We pass
9320                // `inner` through verbatim — no re-tokenization needed.
9321                let prepped: String = inner.to_string();
9322                // Tell parameter-flag application that we're inside
9323                // double quotes — array-only flags ((o), (O), (n),
9324                // (i), (M), (u)) must be no-ops here per zsh.
9325                exec.in_dq_context += 1;
9326                if mode == 5 {
9327                    exec.in_scalar_assign += 1;
9328                }
9329                // Mode 1 = argv DQ word; mode 5 = scalar-assign RHS.
9330                // In C zsh, the corresponding prefork-on-list paths
9331                // are: argv → `prefork(argv_list, 0)` returns multi-
9332                // word LinkList (Src/exec.c::execcmd), assignment →
9333                // `prefork(rhs_list, PREFORK_SINGLE|PREFORK_ASSIGN)`
9334                // returns single-word (Src/exec.c::addvars line
9335                // 2546). zshrs's `multsub` (Src/subst.c:544) is the
9336                // multi-result variant; `singsub` (Src/subst.c:514)
9337                // asserts ≤1 node. Mode 5 keeps singsub; mode 1
9338                // switches to multsub so `"${(@)arr}"`/`"$@"`/
9339                // `"${arr[@]}"` in argv context emit multiple words
9340                // as the C path would.
9341                // c:Src/lex.c untokenize — the final argv pass C runs
9342                // on every expanded word (glob.c:1862 / exec.c) drops
9343                // the Nularg empty-word sentinel remnulargs left in
9344                // place and folds any remaining token chars. Without
9345                // it, quoted splits with empty pieces
9346                // ("${(s:|:)x}" on "|a|b|") leak U+00A1 into argv.
9347                let result_value = if mode == 5 {
9348                    let out = crate::ported::subst::singsub(&prepped);
9349                    Value::str(crate::ported::lex::untokenize(&out))
9350                } else {
9351                    let (_first, nodes, _ms_ws, _ret) = crate::ported::subst::multsub(&prepped, 0);
9352                    // c:Src/subst.c:655 — multsub returns Vec::new()
9353                    // for zero-word results (quoted array splat that
9354                    // resolved to empty array). Surface as
9355                    // Value::Array(vec![]) so the downstream array
9356                    // assignment / argv flattening sees ZERO args.
9357                    // Previous Rust port returned Value::str("") which
9358                    // surfaced as ONE empty arg. Bug #120 in
9359                    // docs/BUGS.md.
9360                    if nodes.is_empty() {
9361                        Value::Array(Vec::new())
9362                    } else if nodes.len() == 1 {
9363                        Value::str(crate::ported::lex::untokenize(
9364                            &nodes.into_iter().next().unwrap(),
9365                        ))
9366                    } else {
9367                        Value::Array(
9368                            nodes
9369                                .into_iter()
9370                                .map(|n| Value::str(crate::ported::lex::untokenize(&n)))
9371                                .collect(),
9372                        )
9373                    }
9374                };
9375                if mode == 5 {
9376                    exec.in_scalar_assign -= 1;
9377                }
9378                exec.in_dq_context -= 1;
9379                result_value
9380            }
9381            2 => {
9382                // SingleQuoted: pure literal, strip outer `'…'`.
9383                let inner = if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
9384                    &text[1..text.len() - 1]
9385                } else {
9386                    text.as_str()
9387                };
9388                Value::str(inner.to_string())
9389            }
9390            3 => {
9391                // Backquote command sub: strip outer backticks.
9392                // Word-split the result on IFS when the surrounding
9393                // word is unquoted — zsh: `print -l \`echo a b c\``
9394                // emits one arg per word. The $(…) path applies the
9395                // same split via BUILTIN_WORD_SPLIT after capture; do
9396                // the equivalent here for the `…` form.
9397                let inner = if text.len() >= 2 && text.starts_with('`') && text.ends_with('`') {
9398                    &text[1..text.len() - 1]
9399                } else {
9400                    text.as_str()
9401                };
9402                // Apply the live VM status before running the inner
9403                // shell so the inherited $? matches zsh's lastval
9404                // propagation.
9405                exec.set_last_status(live_status);
9406                let captured = exec.run_command_substitution(inner);
9407                let trimmed = captured.trim_end_matches('\n');
9408                if exec.in_dq_context > 0 {
9409                    Value::str(trimmed.to_string())
9410                } else {
9411                    let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
9412                    let parts: Vec<Value> = trimmed
9413                        .split(|c: char| ifs.contains(c))
9414                        .filter(|s| !s.is_empty())
9415                        .map(|s| Value::str(s.to_string()))
9416                        .collect();
9417                    if parts.is_empty() {
9418                        Value::str(String::new())
9419                    } else if parts.len() == 1 {
9420                        parts.into_iter().next().unwrap()
9421                    } else {
9422                        Value::Array(parts)
9423                    }
9424                }
9425            }
9426            4 => {
9427                // HeredocBody: expand variables / command-subst / arith
9428                // but NOT glob or brace. Heredoc lines like `[42]` must
9429                // pass through verbatim — running them through the
9430                // default pipeline triggers NOMATCH on the literal.
9431                Value::str(crate::ported::subst::singsub(&text))
9432            }
9433            _ => {
9434                // Default (unquoted): the lexer's gettokstr already
9435                // tokenized backslash-escapes (`\$` → Bnull+$, etc).
9436                // Pass `text` through verbatim — multsub/stringsubst
9437                // recognize the markers natively. No bridge-side
9438                // re-tokenization needed.
9439                //
9440                // Mode 6 = unquoted RHS in scalar-assign context.
9441                // Pass PREFORK_ASSIGN so prefork's filesub colon-walk
9442                // fires per c:Src/exec.c:2546.
9443                let prepped: String = text.clone();
9444                if std::env::var("ZSHRS_TRACE_DEFP").is_ok() {
9445                    eprintln!(
9446                        "[TRACE_DEFP] text={:?} prepped={:?} mode={}",
9447                        text, prepped, mode
9448                    );
9449                }
9450                let pf_flags = if mode == 6 {
9451                    crate::ported::zsh_h::PREFORK_ASSIGN
9452                } else {
9453                    0
9454                };
9455                // c:Src/subst.c:544+ — `multsub(&prepped, 0)` is the
9456                // unquoted-argv equivalent of zsh's `prefork(list,
9457                // 0, NULL)` for a single-element list. Returns the
9458                // post-expansion node list (Vec<String>) so array-
9459                // shape results (e.g. `${a:e}`, `${a[@]}`,
9460                // `${(s::)str}`) splat into multiple argv words.
9461                // singsub() collapses to one string and discards the
9462                // splat — parity bug #28 (whole-array modifier).
9463                let (_first, nodes, _ms_ws, _ret) =
9464                    crate::ported::subst::multsub(&prepped, pf_flags);
9465                if std::env::var("ZSHRS_TRACE_MULTSUB").is_ok() {
9466                    eprintln!("[TRACE_MULTSUB] prepped={:?} nodes={:?}", prepped, nodes);
9467                }
9468                // c:Src/subst.c:166 — xpandbraces runs AFTER prefork's
9469                // substitution pass and BEFORE untokenize/glob. Per
9470                // word, scan for Inbrace TOKEN and expand. Words that
9471                // don't contain Inbrace TOKEN pass through unchanged.
9472                // Brace expansion is done here (inside the bridge
9473                // default arm) instead of via a post-EXPAND_TEXT
9474                // BRACE_EXPAND emit because untokenize (line below)
9475                // strips TOKEN bytes, after which the strict-TOKEN
9476                // xpandbraces gate would no longer match.
9477                let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
9478                // c:Src/options.c — `no_brace_expand` (negated
9479                // `braceexpand`) gates brace expansion entirely.
9480                // When off, `{a,b}` stays literal.
9481                let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
9482                let pre_brace: Vec<String> = if nodes.is_empty() {
9483                    vec![String::new()]
9484                } else {
9485                    nodes
9486                };
9487                let brace_expanded: Vec<String> = pre_brace
9488                    .into_iter()
9489                    .flat_map(|w| {
9490                        if brace_expand && w.contains('\u{8f}') {
9491                            crate::ported::glob::xpandbraces(&w, brace_ccl)
9492                        } else {
9493                            vec![w]
9494                        }
9495                    })
9496                    .collect();
9497                // zsh stores the option as `glob` (default ON);
9498                // `setopt noglob` writes `glob=false`. Honor either
9499                // form so the dispatcher behaves the same as zsh.
9500                // Mode 7 = redirect-target word: glob only under
9501                // MULTIOS (c:Src/glob.c:2161-2167 xpandredir,
9502                // "Globbing is only done for multios.").
9503                let noglob = opt_state_get("noglob").unwrap_or(false)
9504                    || opt_state_get("GLOB").map(|v| !v).unwrap_or(false)
9505                    || !opt_state_get("glob").unwrap_or(true)
9506                    || (mode == 7 && !opt_state_get("multios").unwrap_or(true));
9507                let parts: Vec<String> = brace_expanded
9508                    .into_iter()
9509                    .flat_map(|s| {
9510                        // The lexer leaves glob metacharacters in their
9511                        // META-encoded form: `*` → `\u{87}`, `?` →
9512                        // `\u{86}`, `[` → `\u{91}`, etc. expand_string
9513                        // doesn't untokenize them, so the literal-char
9514                        // checks below (`s.contains('*')`) would miss
9515                        // every real glob and skip expand_glob — that
9516                        // bug let `echo *.toml` print the literal
9517                        // `*.toml` because the META `\u{87}` never
9518                        // matched the literal `*`. Untokenize once so
9519                        // the metacharacter checks see the canonical
9520                        // form. zsh's pattern.c expects `*` etc. as
9521                        // bare chars at the glob layer.
9522                        // c:Src/pattern.c:4306 haswilds on the still-
9523                        // TOKENIZED word (pre-untokenize), matching C's
9524                        // zglob entry gate (Src/glob.c:1230) which runs
9525                        // on the lexer-tokenized string. haswilds
9526                        // matches ONLY token codes: source-level
9527                        // `*.toml` carries Star and fires; bare literal
9528                        // `[`/`*`/`?` from `$'...'` decode, `:-`
9529                        // default values, or nested-substitution
9530                        // results were never shtokenize'd (C
9531                        // subst.c:3231 sets globsubst=0 in the `:-`
9532                        // arm) and stay literal — bug #625. Plain
9533                        // multibyte text (`↔`) never matches a token
9534                        // codepoint — bug #627.
9535                        let is_glob_pre = !noglob && crate::ported::pattern::haswilds(&s);
9536                        let s = crate::lex::untokenize(&s);
9537                        // Skip glob expansion for assignment-shaped
9538                        // words (`NAME=value`). zsh doesn't expand the
9539                        // RHS of an assignment as a path glob unless
9540                        // `setopt globassign` is set, and feeding such
9541                        // words through expand_glob makes NOMATCH
9542                        // (default ON) fire spuriously on
9543                        // `integer i=2*3+1`, `path=*.rs`, etc.
9544                        let is_assignment_shape = {
9545                            let bytes = s.as_bytes();
9546                            let mut i = 0;
9547                            if !bytes.is_empty()
9548                                && (bytes[0] == b'_' || bytes[0].is_ascii_alphabetic())
9549                            {
9550                                i += 1;
9551                                while i < bytes.len()
9552                                    && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric())
9553                                {
9554                                    i += 1;
9555                                }
9556                                i < bytes.len() && bytes[i] == b'='
9557                            } else {
9558                                false
9559                            }
9560                        };
9561                        // Glob-trigger decision: pre-untokenize
9562                        // haswilds_tokens_only result (computed above
9563                        // before the untokenize that collapses META
9564                        // tokens to their ASCII forms). The TOKEN-only
9565                        // gate matches C `Src/pattern.c:4306-4376`
9566                        // exactly — only Inbrack/Star/Quest/Inpar/Bar/
9567                        // Inang/Pound/Hat token codes count as wild,
9568                        // not their literal ASCII counterparts. Source-
9569                        // level `*.toml` carries Star token so globs;
9570                        // `$'…'`-decoded `[abc]` carries bare `[` so
9571                        // stays literal. Bug #625.
9572                        if is_glob_pre && !is_assignment_shape {
9573                            exec.expand_glob(&s)
9574                        } else if is_assignment_shape
9575                            && crate::ported::zsh_h::isset(crate::ported::zsh_h::MAGICEQUALSUBST)
9576                        {
9577                            // c:Src/exec.c:3353 — when MAGIC_EQUAL_SUBST is set
9578                            // on a non-typeset command, esprefork = PREFORK_TYPESET,
9579                            // so every NAME=value arg runs through
9580                            // filesub(PREFORK_TYPESET): the `~`/`=` after the
9581                            // first `=` (and after each `:`) undergo filename
9582                            // expansion. `print foo=~/bar` → `foo=$HOME/bar`.
9583                            // filesubstr (subst.c:741) keys on the Tilde TOKEN,
9584                            // not literal `~`; this `s` was already untokenized
9585                            // above, so re-tokenize (as BUILTIN_MAGIC_EQUALS_PREFORK
9586                            // does) before filesub, then untokenize the result.
9587                            let mut tokd = s.clone();
9588                            crate::ported::glob::shtokenize(&mut tokd);
9589                            let exp = crate::ported::subst::filesub(
9590                                &tokd,
9591                                crate::ported::zsh_h::PREFORK_TYPESET,
9592                            );
9593                            vec![crate::lex::untokenize(&exp).to_string()]
9594                        } else {
9595                            vec![s]
9596                        }
9597                    })
9598                    .collect();
9599                if parts.len() == 1 {
9600                    let only = parts.into_iter().next().unwrap_or_default();
9601                    // Empty unquoted expansion → drop the arg entirely
9602                    // (zsh "remove empty unquoted words" rule). Returning
9603                    // an empty Value::Array makes pop_args contribute zero
9604                    // items. Direct port of subst.c's empty-elide pass at
9605                    // the end of multsub which removes empty linknodes
9606                    // from unquoted contexts. Quoted DQ/SQ paths (modes
9607                    // 1/2/5) take separate arms above and always emit
9608                    // Value::Str so the empty arg survives.
9609                    //
9610                    // c:Src/subst.c:4437 + 1650-1656 — a word CONTAINING a
9611                    // quoted span never drops: `x"${v[-1]}"y` (v empty)
9612                    // is the scalar "xy", and a standalone `"${v[-1]}"`
9613                    // is ONE empty arg. The lexer marks DQ/SQ spans with
9614                    // Dnull(\u{9e})/Snull(\u{9d})/Qstring(\u{8c})/
9615                    // Bnull(\u{9f}); their presence in the SOURCE word
9616                    // means qt semantics apply. Without this gate, zpwr's
9617                    // global `setopt rc_expand_param` turned autopair's
9618                    // `local lchar="${LBUFFER[-1]}"` (empty prompt +
9619                    // backspace) into an ARGLESS `local` — the full
9620                    // parameter-table dump the user saw per keystroke.
9621                    if only.is_empty() {
9622                        // A quote span that WRAPS the expansion keeps the
9623                        // empty arg (`"${v[-1]}"` → one empty arg). But a
9624                        // quote INSIDE the `${…}` braces — e.g. the alternate
9625                        // of `${x:+'q'}` or `${x:-'d'}` — does NOT: when that
9626                        // branch isn't taken the result is a plain unquoted
9627                        // empty and must ELIDE, matching zsh (`a=(A ${x:+'q'}
9628                        // C)` → 2 elements, not 3). So only count quote
9629                        // markers at brace-depth 0 (outside `${…}`). Inbrace
9630                        // = \u{8f}, Outbrace = \u{90}.
9631                        let mut depth = 0i32;
9632                        let mut word_has_quoted_span = false;
9633                        for c in text.chars() {
9634                            match c {
9635                                '\u{8f}' => depth += 1,
9636                                '\u{90}' => depth -= 1,
9637                                '\u{9e}' | '\u{9d}' | '\u{8c}' | '\u{9f}' | '"' | '\''
9638                                    if depth <= 0 =>
9639                                {
9640                                    word_has_quoted_span = true;
9641                                    break;
9642                                }
9643                                _ => {}
9644                            }
9645                        }
9646                        if word_has_quoted_span {
9647                            Value::str(String::new())
9648                        } else {
9649                            Value::Array(Vec::new())
9650                        }
9651                    } else {
9652                        Value::str(only)
9653                    }
9654                } else {
9655                    Value::Array(parts.into_iter().map(Value::str).collect())
9656                }
9657            }
9658        });
9659        // Pull any inner cmd-subst (`` `cmd` `` via mode 3 or via
9660        // mode 0/6 multsub → getoutput, `$(cmd)` via the default
9661        // arm's multsub path, nested `$()`s reached through
9662        // stringsubst) back into vm.last_status so a containing
9663        // assignment's BUILTIN_SET_VAR — which reads vm.last_status —
9664        // sees the cmd-subst's exit. Without this, backtick
9665        // assignments (`a=\`false\`; echo $?`) reported 0 because the
9666        // ported LASTVAL update never reached the VM-side counter.
9667        let cs_status = with_executor(|exec| exec.last_status());
9668        vm.last_status = cs_status;
9669        result_value
9670    });
9671
9672    // `${#name}` — pops [name]. Returns the value's element count for
9673    // arrays (indexed and assoc) or character length for scalars.
9674    // BUILTIN_PARAM_LENGTH — `${#name}`. PURE PASSTHRU.
9675    vm.register_builtin(BUILTIN_PARAM_LENGTH, |vm, _argc| {
9676        let name = vm.pop().to_str();
9677        // PARAM_LENGTH's empty-result semantics differ from
9678        // paramsubst_to_value: 0 nodes → "0" (numeric length), not
9679        // empty array. paramsubst on `${#X}` always returns at least
9680        // one node in practice (the length string); the empty case
9681        // is defensive.
9682        let mut ret_flags: i32 = 0;
9683        let (_full, _pos, nodes) = crate::ported::subst::paramsubst(
9684            &format!("${{#{}}}", name),
9685            0,
9686            false,
9687            0i32,
9688            &mut ret_flags,
9689        );
9690        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
9691            with_executor(|exec| exec.set_last_status(1));
9692        }
9693        if nodes.is_empty() {
9694            Value::str("0")
9695        } else {
9696            nodes_to_value(nodes)
9697        }
9698    });
9699
9700    // `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
9701    // `${var/%pat/repl}` — Pops [name, pattern, replacement, op_byte].
9702    // op: 0=first, 1=all, 2=anchor-prefix (`/#`), 3=anchor-suffix (`/%`).
9703    // BUILTIN_PARAM_REPLACE — `${var/pat/repl}` / `${var//pat/repl}` /
9704    // `${var/#pat/repl}` / `${var/%pat/repl}`. PURE PASSTHRU.
9705    vm.register_builtin(BUILTIN_PARAM_REPLACE, |vm, _argc| {
9706        let dq_flag = vm.pop().to_int() != 0;
9707        let op = vm.pop().to_int() as u8;
9708        let repl = vm.pop().to_str();
9709        let pattern = vm.pop().to_str();
9710        let name = vm.pop().to_str();
9711        // !!! DASH-STRICT GATE !!! dash / ash have no `${var/pat/repl}`
9712        // pattern-replacement expansion — it is a "Bad substitution" error
9713        // (bash/ksh/POSIX-sh support it, so those modes fall through). Raise
9714        // the canonical zsh diagnostic + exit 1 to match /bin/dash's failure.
9715        if crate::dash_mode::dash_strict() {
9716            crate::ported::utils::zerr("bad substitution");
9717            crate::ported::utils::errflag.fetch_or(
9718                crate::ported::zsh_h::ERRFLAG_ERROR,
9719                std::sync::atomic::Ordering::Relaxed,
9720            );
9721            with_executor(|exec| exec.set_last_status(1));
9722            return Value::str("");
9723        }
9724        // DQ context: C's lexer marks every `$` inside double quotes
9725        // as the Qstring token (Src/lex.c dquote_parse) and keeps `'`
9726        // a plain char — so a DQ replacement's `$'…'` is LITERAL in
9727        // C (Src/subst.c:301 decodes only the tokenized Snull form;
9728        // `"${a/X/$'\0'}"` keeps the five chars `$'\0'`). The body
9729        // rebuilt below re-enters stringsubst as raw text, which
9730        // would mis-decode `$'…'` as ANSI-C; stamp the Qstring
9731        // marker on the repl's `$` so stringsubst sees the same DQ
9732        // signal C's tokens carry. The PATTERN side keeps decoding
9733        // (matches observed zsh: the pattern's `$'\0'` matches a
9734        // real NUL while the repl's stays literal).
9735        let repl = if dq_flag {
9736            repl.replace('$', "\u{8c}")
9737        } else {
9738            repl
9739        };
9740        // op encoding: 0 = first `/`, 1 = all `//`, 2 = anchor-prefix
9741        // `/#`, 3 = anchor-suffix `/%`. The brace form distinguishes
9742        // first-vs-all by single vs doubled slash, and anchored by
9743        // a `#` or `%` immediately after the slash(es).
9744        let body = match op {
9745            0 => format!("${{{}/{}/{}}}", name, pattern, repl),
9746            1 => format!("${{{}//{}/{}}}", name, pattern, repl),
9747            2 => format!("${{{}/#{}/{}}}", name, pattern, repl),
9748            3 => format!("${{{}/%{}/{}}}", name, pattern, repl),
9749            _ => format!("${{{}/{}/{}}}", name, pattern, repl),
9750        };
9751        // c:Src/subst.c:1625 — paramsubst's qt flag. The compiler
9752        // threads the word's DQ context onto the stack; dropping it
9753        // (the old `let _dq_flag`) ran the rebuilt body with qt=false
9754        // whenever the opcode fired outside an EXPAND_TEXT scope, so
9755        // DQ-only semantics inside the replacement (e.g. `$'` staying
9756        // literal per Src/subst.c:301 — `"${a/x/$'\t'q}"`) were lost.
9757        // Bump in_dq_context exactly like EXPAND_TEXT mode 1 so
9758        // paramsubst_to_value's qt probe sees the right context.
9759        if dq_flag {
9760            with_executor(|exec| exec.in_dq_context += 1);
9761        }
9762        let ret = paramsubst_to_value(&body);
9763        if dq_flag {
9764            with_executor(|exec| exec.in_dq_context -= 1);
9765        }
9766        ret
9767    });
9768
9769    vm.register_builtin(BUILTIN_REGISTER_COMPILED_FN, |vm, argc| {
9770        let args = pop_args(vm, argc);
9771        let mut iter = args.into_iter();
9772        let name = iter.next().unwrap_or_default();
9773        let body_b64 = iter.next().unwrap_or_default();
9774        let body_source = iter.next().unwrap_or_default();
9775        let line_base_str = iter.next().unwrap_or_default();
9776        let line_base: i64 = line_base_str.parse().unwrap_or(0);
9777        let bytes = base64_decode(&body_b64);
9778        let status = match bincode::deserialize::<fusevm::Chunk>(&bytes) {
9779            Ok(chunk) => with_executor(|exec| {
9780                // c:Src/exec.c:5383 — `shf->filename =
9781                // ztrdup(scriptfilename);` — the function's
9782                // definition-file is read from the canonical
9783                // file-scope `scriptfilename` global at compile
9784                // time, NOT from a per-executor struct field.
9785                // exec.scriptfilename is seeded once at
9786                // bins/zshrs.rs:1717 to the bin basename ("zsh")
9787                // and never updates on source/dot, so reading from
9788                // it left every user function's def_file as "zsh".
9789                // Route through scriptfilename_get() so source /
9790                // dot's set_scriptfilename calls propagate.
9791                let def_file = crate::ported::utils::scriptfilename_get()
9792                    .or_else(|| exec.scriptfilename.clone());
9793                if !body_source.is_empty() {
9794                    exec.function_source
9795                        .insert(name.clone(), body_source.clone());
9796                }
9797                exec.function_line_base.insert(name.clone(), line_base);
9798                exec.function_def_file.insert(name.clone(), def_file);
9799                // PFA-SMR aspect: every `name() {}` / `function name { }`
9800                // funnels through here at compile time. Emit one record
9801                // with the function name + raw body source.
9802                #[cfg(feature = "recorder")]
9803                if crate::recorder::is_enabled() {
9804                    let ctx = exec.recorder_ctx();
9805                    let body = if body_source.is_empty() {
9806                        None
9807                    } else {
9808                        Some(body_source.as_str())
9809                    };
9810                    crate::recorder::emit_function(&name, body, ctx);
9811                }
9812                // Mirror into canonical shfunctab so scanfunctions /
9813                // ${(k)functions} / functions builtin see user defs.
9814                // C: exec.c:funcdef → shfunctab->addnode(ztrdup(name),shf).
9815                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
9816                    let mut shf = crate::ported::hashtable::shfunc_with_body(&name, &body_source);
9817                    // c:Src/exec.c:5409 — `shf->lineno = lineno;`. Use
9818                    // the same max(1, line_base) clamp as the synth_shf
9819                    // in vm_helper::dispatch_function_call. Bug #396.
9820                    shf.lineno = std::cmp::max(1, line_base);
9821                    tab.add(shf);
9822                }
9823                // c:Src/exec.c:5460-5475 — `TRAP<SIG>() { ... }` is the
9824                // function-named trap install. zsh detects the `TRAP`
9825                // prefix at func-def time and calls
9826                // `settrap(signum, NULL, ZSIG_FUNC)` so the next
9827                // dispatch of that signal routes to the named shfunc.
9828                // Bug #157 in docs/BUGS.md — fusevm_bridge's funcdef
9829                // opcode skipped this dispatch entirely, so TRAPEXIT /
9830                // TRAPUSR1 / TRAPZERR / TRAPDEBUG never fired.
9831                if name.len() > 4 && name.starts_with("TRAP") {
9832                    if let Some(sn) = crate::ported::jobs::getsigidx(&name[4..]) {
9833                        let _ = crate::ported::signals::settrap(
9834                            sn,
9835                            None,
9836                            crate::ported::zsh_h::ZSIG_FUNC as i32,
9837                        );
9838                    }
9839                }
9840                exec.functions_compiled.insert(name, chunk);
9841                0
9842            }),
9843            Err(_) => 1,
9844        };
9845        Value::Status(status)
9846    });
9847
9848    // Wire the ShellHost so direct shell ops (Op::Glob, Op::TildeExpand,
9849    // Op::ExpandParam, Op::CmdSubst, Op::CallFunction, etc.) route through
9850    // ZshrsHost back into the executor.
9851    vm.set_shell_host(Box::new(ZshrsHost));
9852}
9853
9854impl ZshrsHost {
9855    /// True iff `c` can be a `(j:…:)` / `(s:…:)` delimiter — non-alphanumeric,
9856    /// non-underscore. Restricting to punctuation avoids `(jL)` consuming `L`
9857    /// as a delim instead of as the next flag.
9858    fn is_zsh_flag_delim(c: char) -> bool {
9859        !c.is_ascii_alphanumeric() && c != '_'
9860    }
9861}
9862
9863/// Shared `${name[idx]}` subscript dispatch for BUILTIN_ARRAY_INDEX
9864/// and the KSHARRAYS-unset arm of BUILTIN_ARRAY_INDEX_UNBRACED.
9865///
9866/// c:Src/subst.c subscript parsing — when paramsubst re-parses the
9867/// synthesized `${name[idx]}` body, characters like `'` `"` `\` `$`
9868/// etc. are LEXER-active inside the `[…]` and get reinterpreted
9869/// (quote-strip, paramsubst recursion, …). For PRE-EVALUATED key
9870/// strings (the dynamic-key fast path at compile_zsh.rs:3234 already
9871/// expanded `$k` via EXPAND_TEXT), the idx is a literal string that
9872/// must match the stored key byte-for-byte — no further
9873/// reinterpretation. Direct assoc lookup bypasses the lexer for this
9874/// case, avoiding the quote-strip bug where `h[a'b]` failed to
9875/// resolve because paramsubst's subscript lexer treated the `'` as a
9876/// quote. Bug #338. Only fires for simple assoc-name + non-flag idx
9877/// (no outer-flag sentinels, no `(…)` flag prefix on idx, no splat
9878/// operator). Other paths (slice, splat, flag-based search,
9879/// magic-assoc) still flow through paramsubst.
9880fn array_index_lookup(name: &str, idx: &str) -> Value {
9881    let idx_is_simple = !idx.starts_with('(') && idx != "@" && idx != "*" && !idx.contains(',');
9882    if idx_is_simple {
9883        // assoc_key_hit: single-lock O(1) probe — exec.assoc() clones
9884        // the WHOLE map per lookup (O(n), quadratic in shell loops).
9885        // When `name` IS an assoc, exact-key semantics apply to EVERY
9886        // plain key: hit → value, miss → empty (C `${assoc[missing]}`).
9887        // Never fall through to the textual `${name[key]}` rebuild —
9888        // keys carrying `{`/`}`/`[`/`]` (zsh-autopair probes
9889        // `${AUTOPAIR_LBOUNDS[$pair]}` with pair='{') re-parse as
9890        // broken syntax there ("failed to compile regex: repetition
9891        // quantifier…" + a `}` appended per keystroke).
9892        if let Some((_, v)) = crate::vm_helper::assoc_key_hit(name, idx) {
9893            return Value::str(v.unwrap_or_default());
9894        }
9895    }
9896    // c:Src/params.c:1449-1450 getindex — a leading `(e)`/`(E)` flag
9897    // group makes the subscript LITERAL (group consumed, exact key).
9898    // The textual rebuild below re-parses a FLAT `${name[(e)KEY]}`
9899    // string, so a `]` / `}` that arrived via `$key` expansion
9900    // terminates the subscript / brace early — "bad substitution" or
9901    // spilled-junk values (zpwr expandstats iterates alias keys
9902    // containing brackets). C never re-parses: getarg scans the
9903    // TOKENIZED source where expanded data brackets are inert. Do the
9904    // exact-match lookup directly against the assoc (plain or magic
9905    // alias tables); search groups ((r)/(i)/(k)/…) and other targets
9906    // keep the textual path.
9907    if let Some(rest) = idx.strip_prefix('(') {
9908        if let Some(close) = rest.find(')') {
9909            let grp = &rest[..close];
9910            if !grp.is_empty() && grp.chars().all(|ch| ch == 'e' || ch == 'E') {
9911                let key = &rest[close + 1..];
9912                if let Some(hit) = direct_assoc_key_get(name, key) {
9913                    return Value::str(hit.unwrap_or_default());
9914                }
9915            }
9916        }
9917    }
9918    // Plain assoc key that the flat rebuild would mangle (`]` closes
9919    // the subscript, `}` closes the brace): direct lookup. On a miss
9920    // return empty — the textual fallback cannot represent the key.
9921    if (idx.contains(']') || idx.contains('}')) && !idx.starts_with('(') {
9922        if let Some(hit) = direct_assoc_key_get(name, idx) {
9923            return Value::str(hit.unwrap_or_default());
9924        }
9925    }
9926    let body = format!("${{{}[{}]}}", name, idx);
9927    paramsubst_to_value(&body)
9928}
9929
9930/// Exact-key read against an assoc-like target WITHOUT the textual
9931/// `${name[key]}` reparse (see array_index_lookup — expanded `]`/`}`
9932/// in keys break the flat form). `Some(hit)` when `name` is a target
9933/// this helper understands (plain assoc, or the alias magic assocs of
9934/// zsh/parameter — Src/Modules/parameter.c getpmalias family);
9935/// `None` = not direct-capable, caller keeps the textual path.
9936fn direct_assoc_key_get(name: &str, key: &str) -> Option<Option<String>> {
9937    use crate::ported::zsh_h::{ALIAS_GLOBAL, DISABLED};
9938    // c:Src/Modules/parameter.c:1247+ getpmalias / getpmgalias /
9939    // getpmsalias — each view filters its table by flags.
9940    let alias_view = |global: bool, suffix: bool, disabled: bool| -> Option<String> {
9941        let tab = if suffix {
9942            crate::ported::hashtable::sufaliastab_lock()
9943        } else {
9944            crate::ported::hashtable::aliastab_lock()
9945        };
9946        tab.read().ok().and_then(|t| {
9947            t.iter().find_map(|(k, a)| {
9948                let f = a.node.flags as u32;
9949                if k == key
9950                    && ((f & ALIAS_GLOBAL as u32 != 0) == global || suffix)
9951                    && ((f & DISABLED as u32 != 0) == disabled)
9952                {
9953                    Some(a.text.clone())
9954                } else {
9955                    None
9956                }
9957            })
9958        })
9959    };
9960    match name {
9961        "aliases" => Some(alias_view(false, false, false)),
9962        "galiases" => Some(alias_view(true, false, false)),
9963        "saliases" => Some(alias_view(false, true, false)),
9964        "dis_aliases" => Some(alias_view(false, false, true)),
9965        "dis_galiases" => Some(alias_view(true, false, true)),
9966        "dis_saliases" => Some(alias_view(false, true, true)),
9967        _ => {
9968            // Single-lock O(1) probe (see assoc_key_hit) — the previous
9969            // double exec.assoc() cloned the whole map twice per lookup.
9970            crate::vm_helper::assoc_key_hit(name, key).map(|(_, v)| v)
9971        }
9972    }
9973}
9974
9975/// KSHARRAYS bare-`$name` expansion words for the unbraced
9976/// no-subscript form (BUILTIN_ARRAY_INDEX_UNBRACED's KSHARRAYS arm).
9977///
9978/// - `@` / `*` stay the full positional list (the c:Src/params.c:
9979///   2293-2296 first-element collapse is gated on
9980///   `itype_end(t, IIDENT, 1) != t` — an identifier-shaped name —
9981///   which `@`/`*` are not). The literal `[idx]` then joins the LAST
9982///   word, matching zsh 5.9: `setopt ksharrays; set -- p q;
9983///   print -- $@[0]` → `zsh:1: no matches found: q[0]`.
9984/// - Identifier-named arrays collapse to the FIRST element
9985///   (c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`).
9986/// - Assocs collapse to the first value in scan order; the `options`
9987///   magic assoc's scan order is `OPTIONTAB` bucket order (first key
9988///   `posixargzero`), matching zsh 5.9: `emulate sh -L;
9989///   print $options[posixargzero]` → `off[posixargzero]`.
9990/// - Scalars / unset names expand to their value / empty (zsh 5.9:
9991///   `setopt ksharrays; print -- $unsetvar[0]` →
9992///   `zsh:1: no matches found: [0]`).
9993fn ksharrays_bare_words(name: &str) -> Vec<String> {
9994    if name == "@" || name == "*" {
9995        return with_executor(|exec| exec.pparams());
9996    }
9997    // Magic special-parameter lookups first — mirrors the
9998    // BUILTIN_GET_VAR precedence (partab before executor tables).
9999    if let Some(vals) = crate::vm_helper::partab_array_get(name) {
10000        return vec![vals.into_iter().next().unwrap_or_default()];
10001    }
10002    if let Some(keys) = crate::vm_helper::partab_scan_keys(name) {
10003        let v = keys
10004            .first()
10005            .and_then(|k| crate::vm_helper::partab_get(name, k))
10006            .unwrap_or_default();
10007        return vec![v];
10008    }
10009    let arr_or_assoc = with_executor(|exec| {
10010        if let Some(arr) = exec.array(name) {
10011            // c:Src/params.c:2293-2296 — first element only.
10012            return Some(arr.first().cloned().unwrap_or_default());
10013        }
10014        if let Some(map) = exec.assoc(name) {
10015            // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
10016            // `$assoc` is `${assoc[0]}` (KEY-"0" lookup), EMPTY unless the
10017            // hash has a key "0": `emulate -L ksh; typeset -A h=(a 1 b 2);
10018            // print $h` is empty. Every other mode (`setopt ksharrays`,
10019            // `emulate sh`) falls through to the first bucket value below.
10020            if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
10021                return Some(map.get("0").cloned().unwrap_or_default());
10022            }
10023            // Mirrors BUILTIN_GET_VAR's bare-assoc ordering
10024            // (sorted keys, first value).
10025            let mut keys: Vec<&String> = map.keys().collect();
10026            keys.sort();
10027            return Some(
10028                keys.first()
10029                    .and_then(|k| map.get(*k).cloned())
10030                    .unwrap_or_default(),
10031            );
10032        }
10033        None
10034    });
10035    if let Some(v) = arr_or_assoc {
10036        return vec![v];
10037    }
10038    vec![with_executor(|exec| exec.get_variable(name))]
10039}
10040
10041/// Run `body` through `crate::ported::subst::paramsubst` and convert
10042/// the resulting node list into a fusevm `Value`. Centralises the
10043/// pattern duplicated across ~10 BUILTIN_* handlers:
10044///   - build a `${...}` body string from opcode operands
10045///   - paramsubst the body
10046///   - propagate errflag to `exec.last_status`
10047///   - delegate the LinkList → Value conversion to `nodes_to_value`
10048///
10049/// **Extension** — Rust-only helper. No direct C analog because C
10050/// zsh uses LinkList everywhere; the conversion happens at the
10051/// boundary back into the VM's stack.
10052fn paramsubst_to_value(body: &str) -> Value {
10053    // c:Src/subst.c:1625 paramsubst's `qt` flag is the C signal that
10054    // the current expansion is inside `"…"`. The fast-path bridges
10055    // (BUILTIN_PARAM_*, BUILTIN_BRIDGE_BRACE_ARRAY) used to hardcode
10056    // qt=false, which silently broke DQ-only semantics inside
10057    // `${arr:^other}` / `${arr:^^other}` (Src/subst.c:3456-3520).
10058    // The executor's `in_dq_context` counter is bumped by EXPAND_TEXT
10059    // mode 1 / mode 5 before the bridge fires, so reading it here
10060    // propagates the DQ flag without changing every bridge call site.
10061    let qt = with_executor(|exec| exec.in_dq_context > 0);
10062    let mut ret_flags: i32 = 0;
10063    let (_full, _pos, nodes) = crate::ported::subst::paramsubst(body, 0, qt, 0i32, &mut ret_flags);
10064    if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
10065        with_executor(|exec| exec.set_last_status(1));
10066    }
10067    // c:Src/lex.c untokenize — the final argv pass C runs on every
10068    // expanded word (glob.c:1862 / exec.c) DROPS the Nularg
10069    // empty-word sentinel (c:2089 `if (c != Nularg)`) that
10070    // remnulargs faithfully leaves in place (glob.c:3673 re-adds it
10071    // for all-empty results). These fast-path bridges are terminal —
10072    // their output lands directly in argv slots — so apply it here.
10073    // Without it, quoted splits with empty pieces ("${(s:|:)x}" on
10074    // "|a|b|") leak U+00A1 into argv.
10075    let nodes: Vec<String> = nodes
10076        .into_iter()
10077        .map(|n| crate::ported::lex::untokenize(&n))
10078        .collect();
10079    nodes_to_value(nodes)
10080}
10081
10082/// Wrap a `Vec<String>` (e.g. paramsubst nodes, multsub parts,
10083/// xpandbraces output) into a fusevm `Value`: 0 → empty Array, 1 →
10084/// Str, >1 → Array. Same unwrap idiom every handler that calls a
10085/// canonical Vec-returning fn does.
10086/// c:Src/subst.c:1663 — `int plan9 = isset(RCEXPANDPARAM);`
10087///
10088/// zsh calls the RC_EXPAND_PARAM word shape "plan9" after the rc(1)
10089/// shell it comes from. The option is read fresh on every expansion
10090/// (`setopt` mid-script changes the very next word), so the concat
10091/// builtins must consult it at RUNTIME, not bake it in at compile time.
10092fn plan9_active() -> bool {
10093    with_executor(|_exec| opt_state_get("rcexpandparam").unwrap_or(false))
10094}
10095
10096thread_local! {
10097    /// The `isarr` bit that `Value` cannot carry.
10098    ///
10099    /// c:Src/subst.c:4245 `if (isarr)` gates the whole array emit block,
10100    /// so plan9's word-removal rule (c:4362 `uremnode`) only ever applies
10101    /// to an ARRAY-valued expansion. An empty SCALAR has `isarr == 0`,
10102    /// takes the c:4437 scalar branch, and leaves the surrounding text
10103    /// intact — `setopt rcexpandparam; v=; print -rl -- x$v y` prints `x`
10104    /// and `y`, while the same line with an empty ARRAY prints only `y`.
10105    ///
10106    /// zshrs collapses BOTH shapes to `Value::Array(vec![])`: a real empty
10107    /// array, and an unquoted empty scalar (collapsed so a standalone `$v`
10108    /// contributes zero argv words, mirroring prefork's `uremnode` at
10109    /// c:Src/subst.c:184-187). The two are indistinguishable by the time
10110    /// they reach a concat builtin, so the expansion builtins record here
10111    /// whether the empty Array they just produced came from a scalar.
10112    ///
10113    /// Sticky until the next expansion overwrites it — a word folds its
10114    /// segments left-associatively (`concat(concat(x, $e), y)`), so the
10115    /// propagating second concat must still see the first one's bit.
10116    static EMPTY_EXPANSION_IS_SCALAR: std::cell::Cell<bool> =
10117        const { std::cell::Cell::new(false) };
10118}
10119
10120/// Record whether the empty expansion just produced was a scalar (`true`)
10121/// or a genuine array (`false`). See `EMPTY_EXPANSION_IS_SCALAR`.
10122fn note_empty_is_scalar(is_scalar: bool) {
10123    EMPTY_EXPANSION_IS_SCALAR.with(|c| c.set(is_scalar));
10124}
10125
10126/// True when the empty `Value::Array` about to be concatenated stands for
10127/// an empty SCALAR (c:4437), not an empty array (c:4362).
10128fn empty_is_scalar() -> bool {
10129    EMPTY_EXPANSION_IS_SCALAR.with(|c| c.get())
10130}
10131
10132/// c:Src/subst.c:4366-4437 — the NON-plan9 arm of paramsubst's array
10133/// emit block: "simply join the first and last values."
10134///
10135/// The word prefix (`ostr..aptr`) is concatenated onto element 0
10136/// (c:4386 `strcatsub(&y, ostr, aptr, x, xlen, NULL, …)`), the interior
10137/// elements are emitted bare (c:4393-4412), and the word suffix (`fstr`)
10138/// is concatenated onto the final element (c:4414-4429). Applied left-
10139/// associatively across a word's segments this reproduces zsh's
10140/// `pre${arr}post` → `prep` / `q` / `rpost`.
10141///
10142/// An EMPTY array never reaches this arm in C: c:4261
10143/// `if ((!aval[0] || !aval[1]) && !plan9)` collapses it to the scalar ""
10144/// first, so the surrounding text survives as one word (`x$e y` → `x`).
10145/// That is what the empty-Array arms below reproduce.
10146fn concat_splice(lhs: Value, rhs: Value) -> Value {
10147    match (lhs, rhs) {
10148        (Value::Array(mut la), Value::Array(ra)) => {
10149            if la.is_empty() {
10150                return Value::Array(ra);
10151            }
10152            if ra.is_empty() {
10153                return Value::Array(la);
10154            }
10155            // Last of la merges with first of ra; rest unchanged.
10156            let last_l = la.pop().unwrap();
10157            let mut ra_iter = ra.into_iter();
10158            let first_r = ra_iter.next().unwrap();
10159            let l_s = last_l.as_str_cow();
10160            let r_s = first_r.as_str_cow();
10161            let mut merged = String::with_capacity(l_s.len() + r_s.len());
10162            merged.push_str(&l_s);
10163            merged.push_str(&r_s);
10164            la.push(Value::str(merged));
10165            la.extend(ra_iter);
10166            Value::Array(la)
10167        }
10168        (Value::Array(mut la), rhs_scalar) => {
10169            // c:4261 — empty array + empty surrounding text is zero
10170            // words, not one empty word. Bug #120 in docs/BUGS.md:
10171            // `b=("${a[@]:0:-1}")` gave len=1 instead of zsh's len=0.
10172            let rhs_s = rhs_scalar.as_str_cow();
10173            if la.is_empty() {
10174                if rhs_s.is_empty() {
10175                    return Value::Array(Vec::new());
10176                }
10177                return Value::str(rhs_s.to_string());
10178            }
10179            let last = la.pop().unwrap();
10180            let l_s = last.as_str_cow();
10181            let mut s = String::with_capacity(l_s.len() + rhs_s.len());
10182            s.push_str(&l_s);
10183            s.push_str(&rhs_s);
10184            la.push(Value::str(s));
10185            Value::Array(la)
10186        }
10187        (lhs_scalar, Value::Array(mut ra)) => {
10188            let lhs_s = lhs_scalar.as_str_cow();
10189            if ra.is_empty() {
10190                // Symmetric c:4261 empty-array rule; see the arm above.
10191                if lhs_s.is_empty() {
10192                    return Value::Array(Vec::new());
10193                }
10194                return Value::str(lhs_s.to_string());
10195            }
10196            let first = ra.remove(0);
10197            let r_s = first.as_str_cow();
10198            let mut s = String::with_capacity(lhs_s.len() + r_s.len());
10199            s.push_str(&lhs_s);
10200            s.push_str(&r_s);
10201            let mut out = Vec::with_capacity(ra.len() + 1);
10202            out.push(Value::str(s));
10203            out.extend(ra);
10204            Value::Array(out)
10205        }
10206        (lhs_s, rhs_s) => {
10207            let l = lhs_s.as_str_cow();
10208            let r = rhs_s.as_str_cow();
10209            let mut s = String::with_capacity(l.len() + r.len());
10210            s.push_str(&l);
10211            s.push_str(&r);
10212            Value::str(s)
10213        }
10214    }
10215}
10216
10217/// c:Src/subst.c:4316-4365 — the plan9 (RC_EXPAND_PARAM) arm of
10218/// paramsubst's array emit block.
10219///
10220/// Every element gets the FULL word prefix and suffix
10221/// (c:4341 `strcatsub(&y, ostr, aptr, x, xlen, y + 1, …)` inside the
10222/// per-element loop), giving the cross product with the surrounding
10223/// text: `pre${arr}post` → `preppost` / `preqpost` / `prerpost`.
10224///
10225/// An EMPTY array removes the WHOLE word: the c:4327
10226/// `while ((x = *aval++))` loop body never runs, so `plan9` is still
10227/// non-zero at c:4362 and the node is deleted —
10228/// `if (plan9) { uremnode(l, n); return n; }` (c:4362-4365). `e=();
10229/// setopt RC_EXPAND_PARAM; print -rl -- x$e y` prints only `y`. This is
10230/// the opposite of the non-plan9 rule at c:4261, which keeps `x`.
10231fn concat_plan9(lhs: Value, rhs: Value) -> Value {
10232    // c:4245 `if (isarr)` — an empty SCALAR never enters the array emit
10233    // block, so it contributes "" and the word survives (c:4437). Only a
10234    // real empty ARRAY reaches c:4362's `uremnode`. `Value` cannot tell
10235    // the two apart; EMPTY_EXPANSION_IS_SCALAR carries the missing bit.
10236    let scalar_empty = empty_is_scalar();
10237    match (lhs, rhs) {
10238        // c:4362-4365 — an empty array on either side deletes the word.
10239        // Propagated as an empty Array so a later concat in the same word
10240        // (`x${e[@]}y` folds twice) keeps the word deleted; pop_args
10241        // splats an empty Array into zero argv words.
10242        (Value::Array(la), rhs_v) if la.is_empty() => {
10243            if scalar_empty {
10244                // Empty scalar prefix: "" + rhs (c:4437 strcatsub).
10245                return match rhs_v {
10246                    Value::Array(ra) if ra.is_empty() => Value::Array(Vec::new()),
10247                    other => other,
10248                };
10249            }
10250            Value::Array(Vec::new())
10251        }
10252        (lhs_v, Value::Array(ra)) if ra.is_empty() => {
10253            if scalar_empty {
10254                // Empty scalar suffix: lhs + "" (c:4437 strcatsub).
10255                return lhs_v;
10256            }
10257            Value::Array(Vec::new())
10258        }
10259        (Value::Array(la), Value::Array(ra)) => {
10260            let mut out = Vec::with_capacity(la.len() * ra.len());
10261            for a in &la {
10262                let a_s = a.as_str_cow();
10263                for b in &ra {
10264                    let b_s = b.as_str_cow();
10265                    let mut s = String::with_capacity(a_s.len() + b_s.len());
10266                    s.push_str(&a_s);
10267                    s.push_str(&b_s);
10268                    out.push(Value::str(s));
10269                }
10270            }
10271            Value::Array(out)
10272        }
10273        (Value::Array(la), rhs_scalar) => {
10274            let r = rhs_scalar.as_str_cow();
10275            let out: Vec<Value> = la
10276                .into_iter()
10277                .map(|a| {
10278                    let a_s = a.as_str_cow();
10279                    let mut s = String::with_capacity(a_s.len() + r.len());
10280                    s.push_str(&a_s);
10281                    s.push_str(&r);
10282                    Value::str(s)
10283                })
10284                .collect();
10285            Value::Array(out)
10286        }
10287        (lhs_scalar, Value::Array(ra)) => {
10288            let l = lhs_scalar.as_str_cow();
10289            let out: Vec<Value> = ra
10290                .into_iter()
10291                .map(|b| {
10292                    let b_s = b.as_str_cow();
10293                    let mut s = String::with_capacity(l.len() + b_s.len());
10294                    s.push_str(&l);
10295                    s.push_str(&b_s);
10296                    Value::str(s)
10297                })
10298                .collect();
10299            Value::Array(out)
10300        }
10301        (lhs_s, rhs_s) => {
10302            // Both scalar: nothing to distribute (c:4444 scalar branch).
10303            let l = lhs_s.as_str_cow();
10304            let r = rhs_s.as_str_cow();
10305            let mut s = String::with_capacity(l.len() + r.len());
10306            s.push_str(&l);
10307            s.push_str(&r);
10308            Value::str(s)
10309        }
10310    }
10311}
10312
10313fn nodes_to_value(nodes: Vec<String>) -> Value {
10314    // c:Src/glob.c:3649 remnulargs — strip the Nularg (`\u{a1}`)
10315    //   sentinel and other INULL bytes that paramsubst's splat block
10316    //   emits for empty array elements (so prefork's empty-node-delete
10317    //   pass doesn't drop them). Downstream consumers (cond `-z`/`-n`,
10318    //   command args, etc.) must see the post-remnulargs strings. Bug
10319    //   #185 in docs/BUGS.md: `[[ -z "${b[@]}" ]]` for b=("") returned
10320    //   false because the leftover `\u{a1}` had StringLen=1.
10321    let stripped: Vec<String> = nodes
10322        .into_iter()
10323        .map(|mut s| {
10324            crate::ported::glob::remnulargs(&mut s);
10325            s
10326        })
10327        .collect();
10328    if stripped.is_empty() {
10329        // Zero nodes = an ARRAY-shaped expansion that produced no words
10330        // (empty array splat, empty slice). c:4245 `if (isarr)` holds, so
10331        // plan9 deletes the surrounding word (c:4362).
10332        note_empty_is_scalar(false);
10333        Value::Array(Vec::new())
10334    } else if stripped.len() == 1 {
10335        let only = stripped.into_iter().next().unwrap();
10336        // c:Src/subst.c:183-186 — `else if (!(flags & PREFORK_SINGLE)
10337        // && !(*ret_flags & PREFORK_KEY_VALUE) && !keep)
10338        //   uremnode(list, node);`
10339        // C zsh's prefork removes empty linknodes from the result
10340        // list when in non-SINGLE (argv-context) mode. The ported
10341        // prefork at subst.rs:388-396 honors the same delete-empty
10342        // pass, but some paramsubst paths land here with a single-
10343        // empty-string Vec instead of an empty Vec (paramsubst's
10344        // slice / substring / parameter-flag branches allocate a
10345        // result before checking emptiness). Mirror the prefork
10346        // drop at this layer: single-empty under !in_dq_context
10347        // collapses to Value::Array(empty), and pop_args (line 6243)
10348        // splats the empty Array → zero argv words. DQ context
10349        // (in_dq_context > 0) keeps the empty string so
10350        // `echo "${UNSET}"` still produces an empty arg per zsh's
10351        // quoting rules (c:Src/subst.c:1650-1656 isarr comment).
10352        if only.is_empty() {
10353            let in_dq = with_executor(|exec| exec.in_dq_context > 0);
10354            if !in_dq {
10355                // One empty node = a SCALAR-shaped empty result (c:4437),
10356                // not an empty array — see EMPTY_EXPANSION_IS_SCALAR.
10357                note_empty_is_scalar(true);
10358                return Value::Array(Vec::new());
10359            }
10360        }
10361        Value::str(only)
10362    } else {
10363        Value::Array(stripped.into_iter().map(Value::str).collect())
10364    }
10365}
10366
10367fn pop_args(vm: &mut fusevm::VM, argc: u8) -> Vec<String> {
10368    let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
10369    for _ in 0..argc {
10370        popped.push(vm.pop());
10371    }
10372    popped.reverse();
10373    let mut args: Vec<String> = Vec::with_capacity(popped.len());
10374    for v in popped {
10375        match v {
10376            Value::Array(items) => {
10377                for item in items {
10378                    args.push(item.to_str());
10379                }
10380            }
10381            other => args.push(other.to_str()),
10382        }
10383    }
10384    // `expand_glob` set the glob-failed cell when a no-match glob
10385    // triggered nomatch (c:Src/glob.c:1877). Signal the failure via
10386    // last_status + the per-command glob_failed cell; the dispatcher
10387    // (`host_exec_external`) consumes + clears it and returns status 1
10388    // without running the command body.
10389    if with_executor(|exec| exec.current_command_glob_failed.get()) {
10390        with_executor(|exec| exec.set_last_status(1));
10391    }
10392    // `$_` tracks the last argument of the PREVIOUSLY executed
10393    // command (zsh / bash convention). Promote the deferred value
10394    // into `$_` BEFORE this command runs (so `echo $_` reads the
10395    // prior command's last arg) then stash THIS command's last arg
10396    // for the next dispatch.
10397    let new_last = args.last().cloned();
10398    with_executor(|exec| {
10399        if let Some(prev) = exec.pending_underscore.take() {
10400            exec.set_scalar("_".to_string(), prev);
10401        }
10402        if let Some(last) = new_last {
10403            exec.pending_underscore = Some(last);
10404        }
10405    });
10406    args
10407}
10408
10409/// zsh dispatch order is alias → function → builtin → external. The
10410/// compiler emits direct CallBuiltin ops for known builtin names for
10411/// perf, which silently skips a user function that shadows the same
10412/// name (e.g. `echo() { ... }; echo hi` would run the C builtin
10413/// without this check). Returns Some(status) when the call is routed
10414/// to the user function; the builtin handler should fall through to
10415/// its native impl when None.
10416/// Fork+exec a system binary by name. Used by `reg_overridable!` as
10417/// the fall-through path when `[builtins].coreutils_shadows = off`
10418/// (the default) — runs the canonical `/bin/X` instead of zshrs's
10419/// in-process shadow so old scripts hit zero behavioral divergence.
10420///
10421/// Inherits stdin/stdout/stderr from the parent so pipelines work
10422/// transparently. Resolves the binary via PATH; mirrors what zsh's
10423/// own external-command dispatch would do. Returns the child's exit
10424/// status (or 127 if PATH lookup fails — the standard "command not
10425/// found" code).
10426/// RAII guard that queues signals for the lifetime of a synchronous
10427/// foreground `waitpid` (via `std::process::Command::status`/`wait`).
10428///
10429/// zshrs installs a process-wide SIGCHLD handler (`zhandler` →
10430/// `wait_for_processes` → `waitpid(-1, WNOHANG)`) that reaps EVERY
10431/// exited child to drive the job table. `std::process::Command` does
10432/// its own targeted `waitpid(pid)`; when the reaper fires on any
10433/// thread between the fork and that wait, it reaps the child first and
10434/// `Command::status()` fails with ECHILD ("No child processes (os
10435/// error 10)"). This surfaced as `zshrs: hostname: No child processes
10436/// (os error 10)` when a coreutils shadow (`coreutils_shadows = off`
10437/// default) fork-execs `/usr/bin/hostname` while a background prewarm
10438/// child exits at the same instant.
10439///
10440/// Holding this guard bumps `queueing_enabled` (a global SeqCst atomic
10441/// that `zhandler` honors on every thread), so a SIGCHLD arriving
10442/// during the wait is pushed onto the deferred queue instead of being
10443/// reaped — `Command::status()` reaps its own child and reads the real
10444/// status. On drop, `unqueue_signals()` drains the queue, so any
10445/// genuine background children that exited meanwhile still get reaped
10446/// and routed to the job table. This is the same queue_signals /
10447/// unqueue_signals fencing zsh uses around its own foreground waits
10448/// (Src/exec.c). Panic-safe via `Drop`.
10449pub(crate) struct ForegroundWaitGuard;
10450
10451impl ForegroundWaitGuard {
10452    #[inline]
10453    pub(crate) fn enter() -> Self {
10454        crate::ported::signals_h::queue_signals();
10455        ForegroundWaitGuard
10456    }
10457}
10458
10459impl Drop for ForegroundWaitGuard {
10460    #[inline]
10461    fn drop(&mut self) {
10462        crate::ported::signals_h::unqueue_signals();
10463    }
10464}
10465
10466fn exec_system_command(name: &str, args: &[String]) -> i32 {
10467    // c:Src/jobs.c — count the fork so `time` reports for an
10468    // overridable coreutils shadow run as an external (`time sleep 0`,
10469    // `time cat …`). This is a distinct spawn path from
10470    // execute_external_bg; without the bump BUILTIN_TIME_SUBLIST saw no
10471    // job and stayed silent. (Builtins that don't reach a spawn never
10472    // hit this fn.)
10473    crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
10474    // Queue signals across the wait so the SIGCHLD reaper can't steal
10475    // this child out from under Command::status — see ForegroundWaitGuard.
10476    let status = {
10477        let _wait_guard = ForegroundWaitGuard::enter();
10478        std::process::Command::new(name)
10479            .args(args)
10480            .stdin(std::process::Stdio::inherit())
10481            .stdout(std::process::Stdio::inherit())
10482            .stderr(std::process::Stdio::inherit())
10483            .status()
10484    };
10485    match status {
10486        Ok(s) => s.code().unwrap_or(if s.success() { 0 } else { 1 }),
10487        Err(e) => {
10488            eprintln!("zshrs: {}: {}", name, e);
10489            127
10490        }
10491    }
10492}
10493
10494fn try_user_fn_override(name: &str, args: &[String]) -> Option<i32> {
10495    let has_fn = with_executor(|exec| {
10496        exec.functions_compiled.contains_key(name) || exec.function_exists(name)
10497    });
10498    if !has_fn {
10499        return None;
10500    }
10501    Some(with_executor(|exec| {
10502        exec.dispatch_function_call(name, args).unwrap_or(127)
10503    }))
10504}
10505
10506/// Builtin ID for `${name}` reads — routes through canonical
10507/// `getsparam` (Src/params.c:3076) via paramtab + env walk so nested
10508/// VMs (function calls) see the same storage.
10509pub const BUILTIN_GET_VAR: u16 = 283;
10510
10511/// Like `BUILTIN_GET_VAR` but forces double-quoted (DQ) semantics on
10512/// the read regardless of the runtime `in_dq_context`. The compiler
10513/// emits this for a QUOTED simple-var read (`"$name"`) — those compile
10514/// to a direct GET_VAR with no EXPAND_TEXT wrapper, so `in_dq_context`
10515/// is 0 and the plain GET_VAR would wrongly word-elide an array's empty
10516/// elements (`a=(1 "" 3); "$a"` must keep the empty → `1  3`, not
10517/// `1 3`). With force_dq the array joins via sepjoin keeping empties and
10518/// a scalar is returned verbatim (no empty-drop, no SH_WORD_SPLIT).
10519pub const BUILTIN_GET_VAR_DQ: u16 = 639;
10520
10521/// Builtin ID for `name=value` assignments — pops [name, value] and
10522/// routes through canonical `setsparam` (Src/params.c:3350).
10523pub const BUILTIN_SET_VAR: u16 = 284;
10524
10525/// Builtin ID that sets the thread-local [`SET_VAR_GLOB_ELIGIBLE`] flag true.
10526/// Emitted by the compiler immediately before a `BUILTIN_SET_VAR` whose scalar
10527/// RHS carried an UNQUOTED glob token, so the runtime knows the RHS is a literal
10528/// glob pattern eligible for GLOB_ASSIGN. Takes no stack args, pushes nothing.
10529pub const BUILTIN_MARK_GLOB_ELIGIBLE: u16 = 640;
10530
10531/// Builtin ID for pipeline execution. Pops N sub-chunk indices from the stack;
10532/// each index points into `vm.chunk.sub_chunks` (compiled stage bodies). Forks
10533/// N children, wires stdin/stdout between them via pipes, runs each stage's
10534/// bytecode on a fresh VM in its child, parent waits for all and pushes the
10535/// last stage's exit status. This is bytecode-native pipeline execution —
10536/// no tree-walker delegation.
10537pub const BUILTIN_RUN_PIPELINE: u16 = 285;
10538
10539/// Builtin ID for `Array → String` joining. Pops one value: if it's an Array,
10540/// joins its string-coerced elements with a single space; otherwise passes
10541/// through. Used after `Op::Glob` to convert the pattern's matched paths into
10542/// the single argv-token form the bytecode word model expects (no per-word
10543/// splitting yet — that's a future phase).
10544pub const BUILTIN_ARRAY_JOIN: u16 = 286;
10545
10546/// Builtin ID for `cmd &` background execution. IDs 287/288/289 are reserved
10547/// for the planned array work in Phase G1 (SET_ARRAY/SET_ASSOC/ARRAY_INDEX),
10548/// so this lands at 290. Pops the sub-chunk index then the job text; forks;
10549/// child detaches (`setsid`), runs the sub-chunk on a fresh VM, exits with
10550/// last_status; parent registers the job in the canonical JOBTAB
10551/// (initjob/addproc/spawnjob per c:Src/exec.c:1700-1758) so `jobs` / `wait
10552/// %N` / `kill %N` / `disown` and the zsh/parameter assocs all see it, then
10553/// returns Status(0) immediately.
10554pub const BUILTIN_RUN_BG: u16 = 290;
10555
10556/// Indexed-array assignment: `arr=(a b c)`. Compile_simple emits N element
10557/// pushes followed by name push, then `CallBuiltin(BUILTIN_SET_ARRAY, N+1)`.
10558/// The handler pops args (last popped = name in our pushing order) and stores
10559/// `Vec<String>` into `executor.arrays`. Tree-walker callers see the same
10560/// storage. Any prior scalar binding in `executor.variables` for `name` is
10561/// removed so `${name}` (scalar context) consistently reflects the array's
10562/// first element via `get_variable`.
10563pub const BUILTIN_SET_ARRAY: u16 = 287;
10564
10565/// Single-key set on an associative array: `foo[key]=val`. Stack (top-down):
10566/// [name, key, value]. Stores `value` into `executor.assoc_arrays[name][key]`,
10567/// creating the outer entry if missing. compile_simple detects `var[...]=...`
10568/// in assignments and emits this builtin.
10569pub const BUILTIN_SET_ASSOC: u16 = 288;
10570
10571/// `${arr[idx]}` — single-element array index. Pops two args:
10572///   stack: [name, idx_str]
10573/// Returns the indexed element as Value::str. Indexing semantics: zsh is
10574/// 1-based by default; bash is 0-based. We follow zsh.
10575/// Special idx values: `@` and `*` return the whole array as Value::Array
10576/// (which fuses correctly via the Op::Exec splice for argv splice).
10577pub const BUILTIN_ARRAY_INDEX: u16 = 289;
10578
10579/// `${#arr[@]}` and `${#arr}` (when arr is an array name) — array length.
10580/// Pops one arg: name. Returns Value::str of len.
10581
10582/// `${arr[@]}` — splice all elements as a Value::Array. Pops one arg: name.
10583/// The Array gets flattened by Op::Exec/ExecBg/CallFunction into argv.
10584pub const BUILTIN_ARRAY_ALL: u16 = 292;
10585
10586/// Flatten one level of Value::Array nesting. Pops N values; for each, if it's
10587/// a Value::Array, its elements are appended directly; otherwise the value is
10588/// appended as-is. Pushes a single Value::Array of the flattened result. Used
10589/// by the for-loop word-list compile path: when a word like `${arr[@]}`
10590/// produces a nested Array, this lets `for i in ${arr[@]}` iterate over the
10591/// inner elements rather than the outer single-element array.
10592pub const BUILTIN_ARRAY_FLATTEN: u16 = 293;
10593
10594/// `coproc [name] { body }` — bidirectional pipe to async child. Pops a name
10595/// (optional, "" for default) and a sub-chunk index. Creates two pipes, forks,
10596/// child redirects its fd 0/1 to the inner ends and runs the body, parent
10597/// stores [write_fd, read_fd] into the named array (default `COPROC`). Caller
10598/// closes the fds and `wait`s when done. Job-table integration deferred to
10599/// Phase G6 alongside the bg `&` work.
10600pub const BUILTIN_RUN_COPROC: u16 = 294;
10601
10602/// `arr+=(d e f)` — append N elements to an existing indexed array. Compile
10603/// emits N element pushes + name push, then `CallBuiltin(295, N+1)`. Handler
10604/// drains args (last popped = name), extends `executor.arrays[name]` (creates
10605/// the entry if missing). Mirrors zsh's `+=` semantics for indexed arrays.
10606pub const BUILTIN_APPEND_ARRAY: u16 = 295;
10607
10608/// `name[@]=(...)` / `name[*]=(...)` whole-array SET. Identical to
10609/// BUILTIN_SET_ARRAY for an indexed array / scalar (whole replace), but
10610/// rejects an associative target with "attempt to set slice of
10611/// associative array" (c:Src/params.c:3324-3327).
10612pub const BUILTIN_SET_ARRAY_AT: u16 = 633;
10613
10614/// `name[@]+=(...)` / `name[*]+=(...)` whole-array APPEND. Indexed
10615/// append (push), assoc target → same slice-of-assoc error as 633.
10616pub const BUILTIN_APPEND_ARRAY_AT: u16 = 634;
10617
10618/// `select var in words; do body; done` — interactive numbered-menu loop.
10619/// Compile emits N word pushes + var-name push + sub-chunk index push, then
10620/// `CallBuiltin(296, N+2)`. Handler prints `1) word1\n2) word2\n...` to
10621/// stderr, prints `$PROMPT3` (default `?# `) to stderr, reads a line from
10622/// stdin. On EOF returns 0. On a valid 1-based number, sets `var` to the
10623/// chosen word, runs the sub-chunk, then redisplays the menu and loops. On
10624/// invalid input redraws the menu without running the body. `break` from
10625/// inside the body exits the loop (handled by the body's own bytecode).
10626pub const BUILTIN_RUN_SELECT: u16 = 296;
10627
10628/// `m[k]+=value` — append onto an existing assoc-array value (string concat).
10629/// If the key doesn't exist, behaves like SET_ASSOC. Stack: [name, key, value].
10630
10631/// `break` from inside a body that runs on a sub-VM (select, future
10632/// loop-via-builtin constructs). Writes the canonical
10633/// `crate::ported::builtin::BREAKS` atomic (port of `Src/loop.c:46
10634/// breaks`). Outer-loop builtins drain BREAKS/CONTFLAG after each
10635/// body run, matching the loop.c:529-534 drain pattern.
10636pub const BUILTIN_SET_BREAK: u16 = 299;
10637
10638/// `continue` from inside a sub-VM body. Sets CONTFLAG=1 + bumps
10639/// BREAKS, matching `bin_break`'s WC_CONTINUE arm at Src/builtin.c
10640/// c:5836 `contflag = 1; FALLTHROUGH; breaks++;`.
10641pub const BUILTIN_SET_CONTINUE: u16 = 300;
10642
10643/// Brace expansion: `{a,b,c}` → 3 values, `{1..5}` → 5 values, `{01..05}` →
10644/// zero-padded numerics, `{a..e}` → letter range. Pops one string, returns
10645/// Value::Array of expansions (empty array → original string preserved).
10646pub const BUILTIN_BRACE_EXPAND: u16 = 301;
10647
10648/// Glob qualifier filter: `*(qualifier)` filters glob results by predicate.
10649/// Pops [pattern, qualifier_string]. Returns Value::Array of matching paths.
10650
10651/// Re-export the regex_match host method as a builtin so `[[ s =~ pat ]]`
10652/// works even when fusevm's Op::RegexMatch isn't routed (compat fallback).
10653
10654/// Word-split a string on IFS (default: whitespace). Pops one string,
10655/// returns Value::Array of fields. Used in array-literal context where
10656/// `arr=($(cmd))` should expand cmd's stdout into multiple elements.
10657pub const BUILTIN_WORD_SPLIT: u16 = 304;
10658
10659/// `${=name}` / SH_WORD_SPLIT forced IFS split — c:Src/subst.c:3920-3928
10660/// `aval = sepsplit(val, spsep, 0, 1);`.
10661///
10662/// Unlike BUILTIN_WORD_SPLIT (which routes through `multsub`'s
10663/// PREFORK_SPLIT walker — the c:553-620 loop that COLLAPSES runs of
10664/// separators and never emits an empty field), this is the *other* zsh
10665/// splitter: `sepsplit` → `Src/utils.c:3711 spacesplit(s, allownull=0)`,
10666/// which distinguishes the two IFS classes and preserves empty fields.
10667///
10668/// Stack: \[value\]. argc selects the empty-field rule:
10669///   * argc == 0 — the expansion is a bare unquoted word (`${=v}`): the
10670///     leading/trailing `""` fields spacesplit emits for skipped
10671///     IFS-WHITESPACE are empty argv words and prefork deletes them
10672///     (c:Src/subst.c:184-187 `uremnode`).
10673///   * argc == 1 — the expansion is quoted (`"${=v}"`) or has adjacent
10674///     word segments (`x${=v}y`): those fields survive, because in C the
10675///     word's `Dnull` quote markers / literal prefix+suffix attach to the
10676///     first and last elements (c:4386 / c:4429 strcatsub) and the node is
10677///     no longer empty. `v=$' a:b '` → `""`, `a:b`, `""` quoted; `x`,
10678///     `a:b`, `y` with surrounding text.
10679///
10680/// Empty fields that come from an IFS-NON-whitespace separator are the
10681/// `nulstring` (`Nularg`, c:Src/subst.c:36) and survive in BOTH cases —
10682/// `IFS=x; v=xaxbx` splits to `""`, `a`, `b`, `""` quoted or not.
10683pub const BUILTIN_FORCE_SPLIT: u16 = 643;
10684
10685/// Register a pre-compiled fusevm chunk as a function. Stack: [name,
10686/// base64-bincode-of-Chunk]. Used by compile_zsh's compile_funcdef to
10687/// register functions parsed via parse_init+parse without going through the
10688/// ShellCommand JSON serialization path.
10689pub const BUILTIN_REGISTER_COMPILED_FN: u16 = 305;
10690/// `BUILTIN_VAR_EXISTS` constant.
10691pub const BUILTIN_VAR_EXISTS: u16 = 306;
10692/// Native param-modifier builtins. Each takes a fixed argv shape and
10693/// returns the modified value as Value::Str.
10694///
10695/// `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
10696/// — pop [name, op_byte, rhs]. op_byte: 0=`:-`, 1=`:=`, 2=`:?`, 3=`:+`.
10697pub const BUILTIN_PARAM_DEFAULT_FAMILY: u16 = 307;
10698/// `${var:offset[:length]}` — pop [name, offset, length] (length=-1 means
10699/// "rest of value"; negative offset counts from end).
10700pub const BUILTIN_PARAM_SUBSTRING: u16 = 308;
10701/// `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}` — pop
10702/// [name, pattern, op_byte]. op_byte: 0=`#`, 1=`##`, 2=`%`, 3=`%%`.
10703pub const BUILTIN_PARAM_STRIP: u16 = 309;
10704/// `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
10705/// `${var/%pat/repl}` — pop [name, pattern, replacement, op_byte].
10706/// op_byte: 0=first, 1=all, 2=anchor-prefix, 3=anchor-suffix.
10707pub const BUILTIN_PARAM_REPLACE: u16 = 310;
10708/// `${#name}` — character length of a scalar value, or element count
10709/// of an indexed/assoc array. Pops \[name\], returns count as Value::Str.
10710pub const BUILTIN_PARAM_LENGTH: u16 = 311;
10711/// `$((expr))` arithmetic substitution. Pops \[expr_string\], evaluates
10712/// via the executor's MathEval (integer-aware), returns result as
10713/// Value::Str. Bypasses ArithCompiler's float-only Op::Div path so
10714/// `$((10/3))` returns "3" not "3.333...".
10715pub const BUILTIN_ARITH_EVAL: u16 = 312;
10716/// `(( ... ))` math command post-eval status hook. Pops nothing,
10717/// pushes Value::Status. If errflag is set (math error in the
10718/// preceding BUILTIN_ARITH_EVAL call), clears it and emits status=2
10719/// matching c:Src/math.c arith-failure semantics. Otherwise emits
10720/// the current vm.last_status. Used by compile_arith's `(( ... ))`
10721/// path so the math command swallows errors without halting the
10722/// script — `$((... ))` substitutions skip this hook so their
10723/// errflag propagates up to the containing command.
10724pub const BUILTIN_ARITH_CMD_FINISH: u16 = 527;
10725/// `$(cmd)` command substitution. Pops \[cmd_string\], runs through
10726/// `run_command_substitution` which compiles via parse_init+parse + ZshCompiler
10727/// and captures stdout via an in-process pipe. Returns trimmed output
10728/// as Value::Str. Avoids the sub-chunk word-emit quoting bug in the
10729/// raw Op::CmdSubst path.
10730pub const BUILTIN_CMD_SUBST_TEXT: u16 = 313;
10731/// Text-based word expansion. Pops \[preserved_text\]: the word with
10732/// quotes preserved (Dnull→`"`, Snull→`'`, Bnull→`\`), runs
10733/// `expand_string` (variable + cmd-sub + arith) then `xpandbraces`
10734/// then `expand_glob`. Returns Value::str (single match) or
10735/// Value::Array (multi-match brace/glob).
10736pub const BUILTIN_EXPAND_TEXT: u16 = 314;
10737
10738/// `[[ a -ef b ]]` — same-inode test. Stack: [a, b]. Pushes Bool true iff
10739/// both paths resolve to the same `(dev, inode)` pair (zsh + bash semantics).
10740pub const BUILTIN_SAME_FILE: u16 = 315;
10741
10742/// `[[ a -nt b ]]` — file `a` newer than file `b` (mtime strict).
10743/// Stack: [path_a, path_b]. Pushes Bool. zsh-compatible "missing"
10744/// rules: if both exist, compare mtime; if only `a` exists → true;
10745/// otherwise false.
10746pub const BUILTIN_FILE_NEWER: u16 = 324;
10747
10748/// `[[ a -ot b ]]` — mirror of `-nt`. If both exist, compare mtime;
10749/// if only `b` exists → true; otherwise false.
10750pub const BUILTIN_FILE_OLDER: u16 = 325;
10751
10752/// `[[ -k path ]]` — sticky bit (S_ISVTX) set on path.
10753pub const BUILTIN_HAS_STICKY: u16 = 326;
10754/// `[[ -u path ]]` — setuid bit (S_ISUID).
10755pub const BUILTIN_HAS_SETUID: u16 = 327;
10756/// `[[ -g path ]]` — setgid bit (S_ISGID).
10757pub const BUILTIN_HAS_SETGID: u16 = 328;
10758/// `[[ -O path ]]` — owned by effective UID.
10759pub const BUILTIN_OWNED_BY_USER: u16 = 329;
10760/// `[[ -G path ]]` — owned by effective GID.
10761pub const BUILTIN_OWNED_BY_GROUP: u16 = 330;
10762/// `[[ -N path ]]` — file modified since last accessed (atime <= mtime).
10763pub const BUILTIN_FILE_MODIFIED_SINCE_ACCESS: u16 = 341;
10764
10765/// `name+=val` (no parens) — runtime-dispatched append.
10766/// If name is an indexed array → push val as element.
10767/// If name is an assoc array → error (zsh requires `(k v)` form).
10768/// Else → scalar concat (existing SET_VAR behavior).
10769pub const BUILTIN_APPEND_SCALAR_OR_PUSH: u16 = 331;
10770
10771/// `[[ -c path ]]` — character device.
10772pub const BUILTIN_IS_CHARDEV: u16 = 332;
10773/// `[[ -b path ]]` — block device.
10774pub const BUILTIN_IS_BLOCKDEV: u16 = 333;
10775/// `[[ -p path ]]` — FIFO / named pipe.
10776pub const BUILTIN_IS_FIFO: u16 = 334;
10777/// `[[ -S path ]]` — socket.
10778pub const BUILTIN_IS_SOCKET: u16 = 335;
10779/// `BUILTIN_ERREXIT_CHECK` constant.
10780pub const BUILTIN_ERREXIT_CHECK: u16 = 336;
10781/// Fatal-error-only abort check, for use INSIDE an `&&` / `||` chain.
10782///
10783/// A chain suppresses the errexit check (a non-zero status is "consumed"
10784/// by the connector — `false && x` must not fire ERREXIT or the ZERR
10785/// trap). But an errflag — a *fatal* error such as a `[[ ]]` bad pattern
10786/// — is not a status the connector can consume: zsh abandons the whole
10787/// list. Without this, zshrs ran the `||` right-hand side after the
10788/// error (`[[ x = [a- ]] || touch f` created `f`; zsh does not) and the
10789/// aborted builtin then overwrote the cond's status 2 with 1.
10790///
10791/// Same errflag arm as `BUILTIN_ERREXIT_CHECK`, with the errexit/ZERR
10792/// half omitted.
10793pub const BUILTIN_FATAL_ABORT_CHECK: u16 = 641;
10794/// Post-`always`-arm checks for the canonical RETFLAG / BREAKS /
10795/// CONTFLAG atomics that mark try-block escapes. Each returns
10796/// Value::Int(1) when the corresponding atomic is set (and consumes
10797/// it so the next escape doesn't re-fire) and Value::Int(0) otherwise.
10798/// Paired with JumpIfFalse + Jump to outer return_patches /
10799/// break_patches / continue_patches by compile_zsh's `Try` arm.
10800pub const BUILTIN_RETFLAG_CHECK: u16 = 600;
10801/// `BUILTIN_BREAKS_CHECK` constant.
10802pub const BUILTIN_BREAKS_CHECK: u16 = 601;
10803/// `BUILTIN_CONTFLAG_CHECK` constant.
10804pub const BUILTIN_CONTFLAG_CHECK: u16 = 602;
10805/// Fire the DEBUG trap (SIGDEBUG) before each statement.
10806/// c:Src/exec.c:1357-1500 DEBUGBEFORECMD — when a "DEBUG" entry is
10807/// installed via `trap '...' DEBUG`, run the body just before the
10808/// next command. Cheap when no DEBUG trap is set (one hashmap lookup
10809/// returns None and we early-out).
10810pub const BUILTIN_DEBUG_TRAP: u16 = 603;
10811/// `set -n` / `set -o noexec` — parse but don't execute. Returns
10812/// Value::Int(1) when the noexec option is set so the caller's
10813/// JumpIfTrue skips the statement body. c:Src/exec.c:1390 main loop
10814/// check.
10815pub const BUILTIN_NOEXEC_CHECK: u16 = 604;
10816/// Block-level redirect-failure gate. Reads exec.redirect_failed
10817/// (set by host.redirect when a redirect open fails); returns
10818/// Value::Int(1) AND clears the flag if set, else 0. Emit-side at
10819/// compile_zsh.rs::compile_command's Redirected arm pairs with a
10820/// JumpIfTrue → WithRedirectsEnd to abandon the body. Without this,
10821/// a multi-statement block after a failed redir kept running every
10822/// statement after the first (the first builtin consumed the flag,
10823/// subsequent statements ran unimpeded).
10824pub const BUILTIN_REDIRECT_FAILED_CHECK: u16 = 605;
10825/// Drop-in replacement for fusevm's Op::Exec for the dynamic-first-
10826/// word path (`$cmd`, `$(cmd)`, `~/bin/foo`). Returns
10827/// Value::Status(vm.last_status) when post-expansion argv is empty
10828/// (preserves the inner cmd-subst's exit), Value::Status(126) with
10829/// "permission denied" when `argv[0]` is empty, otherwise routes
10830/// through executor.host_exec_external like Op::Exec did.
10831pub const BUILTIN_EXEC_DYNAMIC: u16 = 606;
10832/// Reset `use_cmdoutval` to 0 at the START of a dynamic command (before
10833/// its words expand), so a command substitution from a PREVIOUS command
10834/// can't leak into this command's null-command status decision
10835/// (c:Src/exec.c:3009 `use_cmdoutval = !args`). See BUILTIN_EXEC_DYNAMIC.
10836pub const BUILTIN_USE_CMDOUTVAL_RESET: u16 = 637;
10837/// `[[ lhs == pat ]]` / `!=` glob compare — cond-specific so the
10838/// bad-pattern diagnostic follows Src/cond.c:308-316: zwarnnam
10839/// "bad pattern: %s" WITHOUT errflag (the script continues) and the
10840/// cond statement exits 2. Stack: [lhs, pat] → Bool. On compile
10841/// failure pushes Bool(false) and arms COND_BAD_PATTERN so
10842/// BUILTIN_COND_STATUS_FROM_BOOL reports 2.
10843pub const BUILTIN_COND_STRMATCH: u16 = 624;
10844/// Pops the cond result Bool → Int status per Src/cond.c: true→0,
10845/// false→1, but 2 when COND_BAD_PATTERN was armed during this cond
10846/// (covers `!=` where LogNot flips the Bool before status time).
10847pub const BUILTIN_COND_STATUS_FROM_BOOL: u16 = 625;
10848/// `[[ ]]` unknown condition. Pops \[op_name\], emits `zerr("unknown
10849/// condition: %s")` and sets ERRFLAG_ERROR so the next BUILTIN_ERREXIT_CHECK
10850/// (trigger 4) aborts the input — matching zsh's COND_MODI "unknown condition"
10851/// path (Src/cond.c:150-188) for a `-X` op with no matching loadable module.
10852/// Returns Bool(false). Replaces a compile-time `eprintln!` hack that printed
10853/// the message but never set errflag (so the line didn't abort).
10854pub const BUILTIN_COND_UNKNOWN: u16 = 632;
10855/// Bare-`exec` redirect epilogue. Consumes `exec.redirect_failed` and
10856/// applies the C `done:` tail of execcmd_exec:
10857///   - c:Src/exec.c:252-259 execerr — `redir_err = lastval = 1` (the
10858///     failed redirect makes the exec statement exit 1, NOT fatal by
10859///     itself);
10860///   - c:Src/exec.c:4367-4386 — `if (isset(POSIXBUILTINS) && (cflags
10861///     & (BINF_PSPECIAL|BINF_EXEC)) ...) { if (redir_err || errflag)
10862///     { if (!isset(INTERACTIVE)) exit(1); } }` — POSIX_BUILTINS makes
10863///     a failed exec redirect fatal in a non-interactive shell.
10864/// Returns Value::Status(0|1) for the trailing SetStatus.
10865pub const BUILTIN_EXEC_REDIR_DONE: u16 = 626;
10866/// Assignment-prefix epilogue for bare `exec` redirects
10867/// (`x=$(cmd) exec >file`). c:Src/exec.c:3969-3976 — nullexec==1
10868/// runs addvars THEN, without POSIX_BUILTINS, restores the params
10869/// (`save_params` / `restore_params`): the RHS side effects fire but
10870/// the values don't persist. With POSIX_BUILTINS the assignments
10871/// stick. Pops the BEGIN_INLINE_ENV frame either way.
10872pub const BUILTIN_EXEC_INLINE_ENV_DONE: u16 = 627;
10873
10874/// `< file` / `> file` with no command word (NULLCMD path).
10875/// Resolves NULLCMD (default "cat") / READNULLCMD (default "more")
10876/// at runtime per Src/exec.c:3340-3364 and exec's it through
10877/// host_exec_external. Argc is 1: the int (0 or 1) on the stack
10878/// indicates whether this is a single REDIR_READ redirect
10879/// (selects READNULLCMD when set + non-empty).
10880pub const BUILTIN_NULLCMD_EXEC: u16 = 607;
10881/// `.` (dot) — alias of source/bin_dot but dispatches with the
10882/// literal name "." so the diagnostic prefix matches zsh's
10883/// (`zsh:.:1: …` vs source's `zsh:source:1: …`).
10884/// c:Src/builtin.c:9308 — `BUILTIN(".", BINF_PSPECIAL, bin_dot, …)`.
10885pub const BUILTIN_DOT: u16 = 608;
10886/// `logout` — fusevm maps this to BUILTIN_EXIT alongside `exit`/`bye`,
10887/// which drops the name and dispatches with BIN_EXIT funcid. zsh's
10888/// `logout` outside a login shell must emit "not login shell" + exit 1,
10889/// which only fires when bin_break sees BIN_LOGOUT funcid. Dedicated
10890/// opcode dispatches via BUILTINS table by literal name "logout".
10891pub const BUILTIN_LOGOUT: u16 = 610;
10892/// `BUILTIN_PARAM_SUBSTRING_EXPR` constant.
10893pub const BUILTIN_PARAM_SUBSTRING_EXPR: u16 = 337;
10894/// `BUILTIN_XTRACE_LINE` constant.
10895pub const BUILTIN_XTRACE_LINE: u16 = 338;
10896/// `BUILTIN_XTRACE_ARRAY_LINE` — xtrace an `arr=(...)` assignment from the
10897/// whole assembled `Value::Array` (see compile_zsh array-literal codegen).
10898pub const BUILTIN_XTRACE_ARRAY_LINE: u16 = 649;
10899/// `BUILTIN_MAKE_ARRAY_COUNTED` — like `Op::MakeArray(u16)` but the element
10900/// count is a runtime `Int` on the stack, so it is not capped at 65535. Used
10901/// by the array-literal codegen only when the literal has > u16::MAX elements
10902/// (e.g. a .zcompdump's ~103k-element `_comps=(...)`).
10903pub const BUILTIN_MAKE_ARRAY_COUNTED: u16 = 650;
10904/// `BUILTIN_ARRAY_JOIN_STAR` constant.
10905pub const BUILTIN_ARRAY_JOIN_STAR: u16 = 339;
10906/// `BUILTIN_SET_RAW_OPT` constant.
10907pub const BUILTIN_SET_RAW_OPT: u16 = 340;
10908
10909/// `time { compound; ... }` — wall-clock-time the sub-chunk and print
10910/// elapsed seconds. Stack: [sub_chunk_idx as Int]. Runs the sub-chunk
10911/// on the current VM (so positional/local state is shared) and prints
10912/// the timing summary to stderr in zsh's format. Pushes Status.
10913pub const BUILTIN_TIME_SUBLIST: u16 = 316;
10914
10915/// `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocation.
10916/// Stack: [path, varid, op_byte]. Opens `path` per `op_byte`, gets the
10917/// new fd (≥10 in zsh; we use libc::open with O_CLOEXEC bit cleared so
10918/// the inherited fd survives Command::new spawns), stores the fd number
10919/// as a string in `$varid`. Pushes Status (0 success, 1 error).
10920pub const BUILTIN_OPEN_NAMED_FD: u16 = 317;
10921
10922/// Word-segment concat that does cartesian-product distribution over
10923/// arrays. Stack: [lhs, rhs]. Used for RC_EXPAND_PARAM `${arr}` and
10924/// explicit-distribute forms (`${^arr}`, `${(@)…}`).
10925///
10926/// - both scalar: `Value::str(a + b)` (fast path, identical to Op::Concat)
10927/// - lhs Array, rhs scalar: `Value::Array([a + rhs for a in lhs])`
10928/// - lhs scalar, rhs Array: `Value::Array([lhs + b for b in rhs])`
10929/// - both Array: cartesian product `[a + b for a in lhs for b in rhs]`
10930pub const BUILTIN_CONCAT_DISTRIBUTE: u16 = 318;
10931
10932/// Forced-distribute concat — like `BUILTIN_CONCAT_DISTRIBUTE` but
10933/// always distributes cartesian regardless of the `rcexpandparam`
10934/// option. Emitted by the segments fast-path when an
10935/// `is_distribute_expansion` segment is present (`${^arr}`,
10936/// `${(@)arr}`, `${(s.…)arr}` etc.) per zsh: the source-level
10937/// distribution flag overrides the option default.
10938/// Direct port of Src/subst.c:1875 `case Hat: nojoin = 1` and the
10939/// `rcexpandparam` test bypass for the explicit-distribute flags.
10940pub const BUILTIN_CONCAT_DISTRIBUTE_FORCED: u16 = 522;
10941
10942/// Capture current `last_status` into the `TRY_BLOCK_ERROR` variable.
10943/// Emitted between the try block and the always block of `{ … } always
10944/// { … }` so the finally arm can read $TRY_BLOCK_ERROR.
10945pub const BUILTIN_SET_TRY_BLOCK_ERROR: u16 = 320;
10946/// `BUILTIN_RESTORE_TRY_BLOCK_STATUS` constant.
10947pub const BUILTIN_RESTORE_TRY_BLOCK_STATUS: u16 = 432;
10948/// `BUILTIN_BEGIN_INLINE_ENV` constant.
10949pub const BUILTIN_BEGIN_INLINE_ENV: u16 = 433;
10950/// `BUILTIN_END_INLINE_ENV` constant.
10951pub const BUILTIN_END_INLINE_ENV: u16 = 434;
10952
10953/// `[[ -o option ]]` — shell-option-set test. Stack: \[option_name\].
10954/// Normalizes the name (strip underscores, lowercase) and reads
10955/// `exec.options`. Pushes Bool.
10956pub const BUILTIN_OPTION_SET: u16 = 321;
10957/// Tri-state `[[ -o NAME ]]` — same lookup as BUILTIN_OPTION_SET
10958/// but returns a Value::Int (0=set, 1=unset, 3=invalid-name). The
10959/// 3-state code matches zsh's `[[ -o invalid ]]` exit (Src/cond.c
10960/// :502 `optison()`). Used by compile_cond's `-o` arm to skip the
10961/// generic bool→status conversion and preserve the invalid-name
10962/// signal in `$?`.
10963pub const BUILTIN_OPTION_CHECK_TRISTATE: u16 = 609;
10964
10965/// `${var:#pattern}` — array filter: remove elements matching `pattern`.
10966/// Stack: [name, pattern]. For scalar `var`, returns empty if it matches
10967/// the pattern, else the value. For array `var`, returns Array of
10968/// non-matching elements.
10969pub const BUILTIN_PARAM_FILTER: u16 = 322;
10970
10971/// `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()` —
10972/// subscripted-array assign with array-literal RHS. Stack:
10973/// [...elements, name, key]. Empty elements + single-int key `a[i]=()`
10974/// removes that element. Comma-key `a[i,j]=(...)` splices.
10975pub const BUILTIN_SET_SUBSCRIPT_RANGE: u16 = 323;
10976
10977/// `[[ -X file ]]` for unknown unary test op `-X`. Stack: \[op_name\].
10978/// Emits zsh's `unknown condition: -X` diagnostic to stderr and
10979/// pushes Bool(false). Without this, unknown conditions silently
10980/// returned false matching neither zsh's error format nor the
10981/// expected status code (zsh returns 2 for parse error).
10982
10983/// `[[ -t fd ]]` — fd-is-a-tty check. Stack: \[fd_string\].
10984/// Routes through libc::isatty. Pushes Bool.
10985///
10986/// ID 644 (unique, next free above the previous max of 643). This was
10987/// 325, which COLLIDED with BUILTIN_FILE_OLDER (also 325). The VM's
10988/// builtin table is last-registration-wins, and FILE_OLDER registered
10989/// after IS_TTY, so every `[[ -t fd ]]` silently dispatched to the
10990/// file-`-ot` handler: it compared the mtime of a file NAMED by the fd
10991/// string ("0", "1", …) — which never exists — so `[[ -t 0 ]]` was
10992/// always false. That broke interactive detection (`[[ -t 0 && -t 1 ]]`)
10993/// and any config gated on it. c:Src/cond.c:390 `return !isatty(...)`.
10994pub const BUILTIN_IS_TTY: u16 = 644;
10995/// Runtime rejection of a process substitution used inside a `[[ … ]]`
10996/// cond operand. c:Src/exec.c:4918/5040/5069 — `getoutputfile`/`getproc`
10997/// error `"process substitution %s cannot be used here"` when `thisjob ==
10998/// -1`, which is the case during cond evaluation. zshrs's THISJOB never
10999/// distinguishes that context at runtime, so the compiler emits this
11000/// builtin (gated on `in_cond_operand`) instead of the ProcessSubIn/Out
11001/// opcode. Pops the substitution text, zerrs, sets errflag (aborting the
11002/// statement → empty stdout, exit 1, matching zsh), returns empty.
11003pub const BUILTIN_PROCSUB_COND_ERROR: u16 = 645;
11004/// `${^arr}` cross-product concat — RC_EXPAND_PARAM forced ON by the `^` flag.
11005///
11006/// Distinct from BUILTIN_CONCAT_DISTRIBUTE_FORCED, which the other distribute
11007/// shapes (`${(@)a}`, `${(f)v}`, `${a[@]}`) share: those keep the word when the
11008/// array is EMPTY (`x${(@)a}y` → `xy`), but plan9 DELETES it
11009/// (c:Src/subst.c:4362-4365 `if (plan9) { uremnode(l, n); return n; }`), so
11010/// `x${^a}y` with `a=()` produces no word at all. One builtin cannot serve both
11011/// — the plan9-ness is known only at compile time, from the `^` flag itself.
11012/// Routes to `concat_plan9`, which already ports both c:4362's removal and the
11013/// c:4316-4350 cartesian emit, and is what the OPTION path
11014/// (`setopt rcexpandparam`) has always used.
11015pub const BUILTIN_CONCAT_PLAN9: u16 = 646;
11016/// `${^^arr}` concat — RC_EXPAND_PARAM forced OFF by the doubled flag
11017/// (c:Src/subst.c:2553-2555 `plan9 = 0`).
11018///
11019/// The mirror of BUILTIN_CONCAT_PLAN9. Needed because every other concat
11020/// builtin consults `plan9_active()` (the runtime OPTION) and so cross-products
11021/// anyway under `setopt rcexpandparam`, while `^^` must override the option:
11022///     setopt rcexpandparam; a=(a b c); print -rl -- ${^^a}.x
11023///     # zsh: `a`, `b`, `c.x`  — spliced, NOT `a.x b.x c.x`
11024/// The override is computed in paramsubst but the distribution call is made
11025/// here, so — like the `^` flag — the only place that knows is the compiler.
11026/// Routes straight to `concat_splice`, C's non-plan9 join-first-and-last path
11027/// (c:4366-4437).
11028pub const BUILTIN_CONCAT_SPLICE_NOPLAN9: u16 = 647;
11029/// `break N`/`continue N` runtime-count validator (see registration).
11030pub const BUILTIN_BREAK_COUNT_VALIDATE: u16 = 648;
11031/// `[[ -r/-w/-x file ]]` via access(2) (doaccess) — see handler.
11032pub const BUILTIN_COND_ACCESS: u16 = 638;
11033
11034/// Evaluate a `[[ ]]` module/completion condition (`-prefix`/`-suffix`/
11035/// `-after`/`-between`). Stack (top-first): argc operand words, then the
11036/// operator word. Dispatches to `complete::eval_mod_cond`. Result pushed as
11037/// Bool (true = condition matched). Used by the `ZshCond::ModCond` compile arm.
11038pub const BUILTIN_COND_MOD: u16 = 651;
11039
11040/// Update `$LINENO` to track the source line of the next statement.
11041/// Stack: \[n\] (the line number from `ZshPipe.lineno`). Direct port
11042/// of zsh's `lineno` global tracking (Src/input.c:330) — the
11043/// compiler emits one of these per top-level pipe so `$LINENO`
11044/// reflects the source position at runtime. ID 342 picked because
11045/// the previous `326` collided with `BUILTIN_HAS_STICKY` (the 325
11046/// collision between IS_TTY and FILE_OLDER has since been fixed by
11047/// moving IS_TTY to 644).
11048pub const BUILTIN_SET_LINENO: u16 = 342;
11049
11050/// Pop a scalar from the VM stack, run expand_glob on it, push the
11051/// result as Value::Array. Used by the segment-concat compile path
11052/// when var refs concatenate with glob meta literals (`$D/*`,
11053/// `${prefix}*`, etc.) — those skip the bridge's pathname-expansion
11054/// pass and would otherwise leak the glob meta to argv as a literal.
11055pub const BUILTIN_GLOB_EXPAND: u16 = 343;
11056
11057/// MULTIOS-gated glob expansion for redirect-target words
11058/// (c:Src/glob.c:2161-2167 xpandredir: "Globbing is only done for
11059/// multios."). Same stack shape as BUILTIN_GLOB_EXPAND; additionally
11060/// passes the word through literally when `unsetopt multios`.
11061// 624 is BUILTIN_COND_STRMATCH — the VM's builtin table is
11062// last-registration-wins, so a duplicate id silently shadows the
11063// earlier handler.
11064pub const BUILTIN_REDIR_GLOB_EXPAND: u16 = 628;
11065
11066/// Reset the default-word glob-pending carrier at the START of a word
11067/// whose source contains a glob metachar (so the flag never leaks from a
11068/// prior word/statement). Paired with BUILTIN_DEFAULT_WORD_GLOB.
11069pub const BUILTIN_DEFAULT_WORD_GLOB_RESET: u16 = 635;
11070
11071/// Filename-generate the ASSEMBLED word ONLY when the default/alternate
11072/// paramsubst arm took a SOURCE word carrying glob metachars
11073/// (subst::DEFAULT_WORD_GLOB_PENDING). A parameter VALUE never sets the
11074/// flag, so `x='*file'; ${x:-d}` stays literal while `${x:-*file}` /
11075/// `${x:-a*}bar` glob. Reads+clears the flag. c:Src/subst.c → globlist.
11076pub const BUILTIN_DEFAULT_WORD_GLOB: u16 = 636;
11077/// `BUILTIN_SET_LOOP_VAR` constant — for-loop variable binding via
11078/// `setloopvar` (Src/params.c:6362): a PM_NAMEREF loop var REBINDS
11079/// to each word (SETREFNAME + setscope) instead of assigning
11080/// through the chain. Returns Bool(false) when zerr fired
11081/// (read-only reference / invalid self reference) so the loop
11082/// driver aborts, mirroring C execfor's errflag check.
11083pub const BUILTIN_SET_LOOP_VAR: u16 = 629;
11084
11085/// EXTEND step of typeset paren-init packing. Pops `argc` values:
11086/// [base, e1, …, eN] — base is either the opener (`name=(` /
11087/// `name+=(`) or a previous EXTEND result. Pushes base with
11088/// `\u{1f}` + element appended per element. Array values SPLICE
11089/// their items as separate elements (`typeset b=( x $arr )` splat);
11090/// an empty Array contributes nothing (unquoted-empty elision).
11091/// CallBuiltin's argc is u8, so the compiler emits one EXTEND per
11092/// ≤200-element chunk — p10k's 408-element `__p9k_colors=( … )`
11093/// overflowed a single-shot pack (argc wrapped mod 256 and the
11094/// stack spilled into the arg list: "not an identifier: 173…").
11095/// BUILTIN_TYPESET_PAREN_CLOSE appends the final `\u{1f})`,
11096/// yielding the exact REJOIN_SEP-delimited one-arg form
11097/// bin_typeset's single-arg splitter consumes (builtin.rs ~4891,
11098/// empties preserved, leading/trailing sentinel-empties trimmed
11099/// once). One arg in → one arg out: bin_typeset's multi-arg rejoin
11100/// (paren-depth scan, unsafe on EXPANDED paren-literal elements
11101/// like p10k's `')' ''`) never runs.
11102pub const BUILTIN_TYPESET_PAREN_PACK: u16 = 630;
11103
11104/// CLOSE step — pops the EXTEND chain's result, pushes it with
11105/// `\u{1f})` appended. See BUILTIN_TYPESET_PAREN_PACK.
11106pub const BUILTIN_TYPESET_PAREN_CLOSE: u16 = 631;
11107
11108/// Shared body of BUILTIN_GLOB_EXPAND / BUILTIN_REDIR_GLOB_EXPAND.
11109/// c:Src/glob.c:1872 — `zglob` runs per-word in the argv pipeline.
11110/// When the upstream EXPAND_TEXT returned an array (e.g. `${a:e}`
11111/// splat → ["txt","md"]), glob each element separately, not a
11112/// sepjoin'd scalar. `skip_glob` short-circuits to a literal
11113/// pass-through (noglob, or a redirect target under
11114/// `unsetopt multios`).
11115fn glob_expand_word_value(raw: Value, skip_glob: bool) -> Value {
11116    let patterns: Vec<String> = match raw {
11117        Value::Array(items) => items.into_iter().map(|v| v.to_str()).collect(),
11118        other => vec![other.to_str()],
11119    };
11120    if skip_glob {
11121        return if patterns.is_empty() {
11122            Value::Array(Vec::new())
11123        } else if patterns.len() == 1 {
11124            Value::str(patterns.into_iter().next().unwrap())
11125        } else {
11126            Value::Array(patterns.into_iter().map(Value::str).collect())
11127        };
11128    }
11129    let mut out: Vec<String> = Vec::with_capacity(patterns.len());
11130    for pattern in &patterns {
11131        // c:Src/subst.c — filename generation runs `filesub` (tilde/`=`
11132        // expansion) BEFORE globbing. A `~`/`=` reaching this word-glob op
11133        // comes from `${~spec}` / GLOB_SUBST marking a substituted VALUE:
11134        // literal and quoted `~` words are filesub'd (or skip glob) upstream
11135        // and never arrive here. filesubstr matches the Tilde TOKEN, so
11136        // shtokenize first (raw `~`->Tilde; already-Tilde `${~a[@]}` results
11137        // pass through), run filesub, then untokenize surviving glob metas
11138        // for expand_glob. Gated on `~`/`=` (raw or token) so ordinary
11139        // substituted words skip the roundtrip. Fixes `${~x}` x="~/foo".
11140        let filesubbed = if pattern.contains('~')
11141            || pattern.contains('=')
11142            || pattern.contains(crate::ported::zsh_h::Tilde)
11143            || pattern.contains(crate::ported::zsh_h::Equals)
11144        {
11145            let mut tok = pattern.clone();
11146            crate::ported::glob::shtokenize(&mut tok);
11147            crate::ported::lex::untokenize(&crate::ported::subst::filesub(&tok, 0))
11148        } else {
11149            pattern.clone()
11150        };
11151        let matches = with_executor(|exec| exec.expand_glob(&filesubbed));
11152        if matches.is_empty() {
11153            // c:1872 nullglob — drop this word, don't emit a hole
11154            continue;
11155        }
11156        for m in matches {
11157            out.push(m);
11158        }
11159    }
11160    if out.is_empty() {
11161        return Value::Array(Vec::new());
11162    }
11163    if patterns.len() == 1 && out.len() == 1 && out[0] == patterns[0] {
11164        // No real matches; expand_glob returned the literal. Pass
11165        // back as scalar so downstream ops don't re-flatten.
11166        return Value::str(out.into_iter().next().unwrap());
11167    }
11168    Value::Array(out.into_iter().map(Value::str).collect())
11169}
11170
11171/// Push a `CmdState` token onto the command-context stack. Direct
11172/// port of zsh's `cmdpush(int cmdtok)` (Src/prompt.c:1623). The
11173/// stack is consulted by `%_` in PS4/prompt expansion to produce
11174/// the cumulative control-flow-context labels (`if`, `then`,
11175/// `cmdand`, `cmdor`, `cmdsubst`, …) that `zsh -x` xtrace shows
11176/// in the trace prefix. Compile_zsh emits push/pop pairs around
11177/// each compound command (if/while/[[…]]/((…))/$(…) etc.).
11178/// Token is a `CmdState as u8`.
11179pub const BUILTIN_CMD_PUSH: u16 = 344;
11180
11181/// Pop the top of the command-context stack. Direct port of zsh's
11182/// `cmdpop(void)` (Src/prompt.c:1631).
11183pub const BUILTIN_CMD_POP: u16 = 345;
11184
11185/// Emit an xtrace line built from the top `argc` values on the VM
11186/// stack, peeked WITHOUT consuming. Used to trace simple commands
11187/// AFTER expansion, so `echo for $i` shows as `echo for a` / `echo
11188/// for b`. Direct port of Src/exec.c:2055-2066.
11189pub const BUILTIN_XTRACE_ARGS: u16 = 346;
11190
11191/// Trace one assignment: emits `name=<quoted-value> ` (no newline)
11192/// to xtrerr if XTRACE is on. Coalesces with subsequent
11193/// XTRACE_ASSIGN / XTRACE_ARGS calls onto the SAME line via the
11194/// `XTRACE_DONE_PS4` flag so `a=1 b=2 echo $a $b` produces:
11195///   `<PS4>a=1 b=2 echo 1 2\n`
11196/// matching C zsh's `execcmd_exec` body (Src/exec.c:2517-2582):
11197///   xtr = isset(XTRACE);
11198///   if (xtr) { printprompt4(); doneps4 = 1; }
11199///   while (assign) {
11200///       if (xtr) fprintf(xtrerr, "%s=", name);
11201///       ... eval value ...
11202///       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
11203///   }
11204///
11205/// Stack contract on entry: [..., name, value]. Both peeked, NOT
11206/// consumed (the matching SET_VAR call pops them after). argc = 2.
11207pub const BUILTIN_XTRACE_ASSIGN: u16 = 525;
11208
11209/// Emit a trailing `\n` + flush iff XTRACE is on AND PS4 was
11210/// emitted by an earlier XTRACE_ASSIGN this line. Used at the end
11211/// of compile_simple's assignment-only path so the trace line gets
11212/// terminated. Mirrors C's exec.c:3397-3399 (the assign-only return
11213/// path through execcmd_exec which does `fputc('\n', xtrerr);
11214/// fflush(xtrerr)`).
11215///
11216/// Stack: untouched. argc = 0.
11217pub const BUILTIN_XTRACE_NEWLINE: u16 = 526;
11218
11219/// Push the live `xtrace` opt-state as `Value::Int(1)` (on) or
11220/// `Value::Int(0)` (off). Used by `compile_cond` to gate the
11221/// trace-string-building block on xtrace state at runtime — without
11222/// this the trace path's `compile_word_str` on each operand re-
11223/// evaluates side-effectful expressions (`$((i++))`) once for the
11224/// trace string and once for the actual condition, doubling the
11225/// effective increment. Bug #159 in docs/BUGS.md.
11226///
11227/// Stack: pushes Int(0|1). argc = 0.
11228pub const BUILTIN_XTRACE_IS_ON: u16 = 611;
11229
11230/// Reset the `DONETRAP` flag at the start of each top-level statement
11231/// (sublist boundary). Mirrors C `Src/exec.c:1455` — `donetrap = 0`.
11232/// Stack: untouched. argc = 0. Bug #303 in docs/BUGS.md.
11233pub const BUILTIN_DONETRAP_RESET: u16 = 612;
11234
11235/// `[[ -z X ]]` / `[[ -n X ]]` operand-empty test that honours zsh's
11236/// array-splice semantics. C zsh evaluates `[[ -z X ]]` per
11237/// `Src/cond.c:347` (case 'z'): `s` is the SCALAR operand passed
11238/// through `cond_str`'s singsub. For `"${arr[@]}"` zsh expands per
11239/// `Src/subst.c:multsub` which yields each element as its own word
11240/// list node; cond.c then sees the joined-or-single-element form.
11241///
11242/// The compile-side `-z` shortcut at `compile_zsh.rs:5371` used
11243/// `Op::StringLen` which calls `Value::len` — for `Value::Array`
11244/// that returns ARRAY LENGTH, not string length. `b=("")` produced
11245/// `Value::Array([""])` → `len = 1` → `-z` returned false.
11246///
11247/// This builtin pops one `Value` and pushes `1` (empty) or `0`
11248/// (non-empty) per the cond context:
11249///   - `Value::Str(s)` → s.is_empty()
11250///   - `Value::Array([])` → true (zero words → vacuous-empty)
11251///   - `Value::Array([s])` → s.is_empty() (single-word case)
11252///   - `Value::Array([_; n>=2])` → false (multiple non-empty
11253///     words; zsh would raise "unknown condition" but the
11254///     observable test result is non-empty/false)
11255///
11256/// Companion to BUILTIN_COND_STR_NONEMPTY (#185 in docs/BUGS.md).
11257pub const BUILTIN_COND_STR_EMPTY: u16 = 613;
11258
11259/// `[[ -n X ]]` operand-non-empty test (logical complement of
11260/// BUILTIN_COND_STR_EMPTY).
11261pub const BUILTIN_COND_STR_NONEMPTY: u16 = 614;
11262
11263/// `exec N<<<"str"` — herestring redirect to explicit fd, applied
11264/// permanently to the shell (no scope restoration). Pops `[content,
11265/// fd]` from the stack; creates a temp file, writes
11266/// `content + "\n"`, reopens read-only, dup2's to `fd`, unlinks the
11267/// temp path so it disappears on close. Mirrors C `Src/exec.c:4655
11268/// getherestr` + `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)`
11269/// at c:3766-3780 for the bare-exec-redir code path (nullexec=1).
11270/// Bug #205 in docs/BUGS.md.
11271///
11272/// Stack: pushes `Value::Status(0)` on success, `Status(1)` on
11273/// failure. argc = 2.
11274pub const BUILTIN_EXEC_HERESTR_FD: u16 = 615;
11275
11276/// MULTIOS write/append fan-out for `cmd > a > b` / `cmd > a >> b`
11277/// style redirects (Bug #36 in docs/BUGS.md). zsh's MULTIOS option
11278/// (Src/exec.c:2418 `mfds[fd1]` check + addfd splice) creates a
11279/// pipe at fd1, spawns an internal "tee" process that copies
11280/// stdin → every collected target file. Without this, only the
11281/// LAST redirect target survives because each dup2 overwrites the
11282/// previous binding.
11283///
11284/// Stack layout (pushed by compile_zsh's compile_redirs coalescing
11285/// pass): `[target_1, op_byte_1, target_2, op_byte_2, …, target_N,
11286/// op_byte_N, fd]`. Pops 2N+1 elements; `argc = 2*N + 1`. A target
11287/// may be a Value::Array of glob matches (spliced into one member
11288/// per match, c:Src/glob.c:2195-2203); an op may be DUP_WRITE for a
11289/// numeric `>&N` member (c:Src/exec.c:3895-3917).
11290///
11291/// Runtime (MULTIOS set):
11292///   1. Seed the member list with `dup(1)` when this command's
11293///      stdout is the pipeline output (c:Src/exec.c:3722-3724).
11294///   2. Open/dup all targets per their op_byte in redirect order
11295///      (WRITE truncate + noclobber gate / APPEND / DUP_WRITE live
11296///      dup); the first member replaces the fd (c:2448-2450).
11297///   3. Save `dup(fd)` onto the active redirect_scope_stack so
11298///      `host_redirect_scope_end` restores the original fd.
11299///   4. Create a pipe; spawn a thread that reads from the pipe
11300///      read-end and writes every chunk to every opened target.
11301///   5. dup2 the pipe write-end onto `fd` so the command's writes
11302///      go through the splitter.
11303///   6. Track `(pipe_write_fd, JoinHandle)` so scope-end can close
11304///      the pipe (draining the thread) and join before restoring.
11305///
11306/// MULTIOS unset (c:2418 `unset(MULTIOS)` replace arm): each entry
11307/// is applied as a plain sequential replace via host_apply_redirect
11308/// — every file still opened/truncated, last one wins.
11309pub const BUILTIN_MULTIOS_REDIRECT: u16 = 617;
11310
11311/// MULTIOS input-side concatenation for `cmd < a < b` shapes
11312/// (Bug #36 input arm). C zsh's `Src/exec.c:2418` mfds dispatch
11313/// also covers the read direction — when multiple `<` redirects
11314/// target the same fd, mfds[fd] grows and addfd splices a
11315/// concatenating cat into the pipe.
11316///
11317/// Stack layout (mirrors the write side): `[source_1, op_1,
11318/// source_2, op_2, …, source_N, op_N, fd]`. Pops 2N + 1 elements
11319/// (argc = 2N + 1). op is READ for file sources, DUP_READ for
11320/// numeric `<&N` members; a source may be a Value::Array of glob
11321/// matches (spliced, c:Src/glob.c:2195-2203).
11322///
11323/// Runtime (MULTIOS set):
11324///   1. Open/dup every source in redirect order; first member
11325///      replaces the fd (c:Src/exec.c:2448-2450).
11326///   2. Save `dup(fd)` onto the redirect_scope_stack.
11327///   3. Create a pipe; spawn a thread that reads each source in
11328///      order and writes every chunk to the pipe write-end. Close
11329///      write-end when done so the consumer sees EOF.
11330///   4. dup2 the pipe read-end onto `fd`.
11331///   5. Track the JoinHandle so scope-end joins (no fd-close needed
11332///      here — the producer thread closes its own pipe write-end
11333///      on exit).
11334///
11335/// MULTIOS unset: sequential replace via host_apply_redirect — last
11336/// source wins (c:2418).
11337pub const BUILTIN_MULTIOS_READ: u16 = 618;
11338
11339/// Toggle `ShellExecutor::exec_redirs_permanent`. Emitted by
11340/// compile_zsh's bare-`exec`-with-redirects arm tightly around each
11341/// `Op::Redirect`: `LoadInt(1); CallBuiltin; …Redirect…; LoadInt(0);
11342/// CallBuiltin`. While set, `host_apply_redirect` skips pushing the
11343/// saved fd into the enclosing redirect scope, making the fd change
11344/// permanent.
11345///
11346/// c:Src/exec.c:3978-3986 — nullexec==1 (`exec` carrying only
11347/// redirections): "If nullexec is 1 we specifically *don't* restore
11348/// the original fd's before returning" — the per-execcmd `save[]`
11349/// dups are closed unrestored. An ENCLOSING group's own saves are a
11350/// different execcmd's `save[]` and still restore (verified:
11351/// `{ exec 2>/dev/null; } 2>&1; ls /nope` prints the ls error in zsh).
11352pub const BUILTIN_EXEC_PERM_REDIRS: u16 = 619;
11353
11354/// Set `ShellExecutor::pipe_output_pending`. Emitted by compile_pipe
11355/// at the head of a NON-LAST pipeline-stage sub-chunk when that
11356/// stage's top-level command carries redirects (`Simple` with redirs
11357/// or `Redirected` compound). The forked stage child runs the chunk
11358/// with stdout already dup2'd onto the pipe; the first
11359/// `host_redirect_scope_begin` (the stage command's own redirect
11360/// list) consumes the flag into `pipe_output_scope`, enabling the
11361/// MULTIOS stream-split for fd-1 write redirects in that list.
11362///
11363/// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output, 1,
11364/// NULL)` registers the pipe in mfds[1] in the SAME execcmd that
11365/// walks the stage command's redirect list; mfds is per-execcmd, so
11366/// nested body commands (`{ echo a > f; } | cat`) never see it.
11367pub const BUILTIN_PIPE_OUTPUT_MARK: u16 = 620;
11368
11369/// Install the pipeline stage's parked fds onto 0/1.
11370///
11371/// c:Src/exec.c:3720-3724 — `addfd(forked, save, mfds, 0, input, 0,
11372/// NULL)` / `addfd(..., 1, output, 1, NULL)`. Runs after prefork
11373/// (c:3304) and globlist (c:3702) have expanded the stage's argument
11374/// words, which is why a `$(...)` in those words reads the shell's
11375/// original stdin rather than the pipe. Emitted by
11376/// `compile_zsh.rs::emit_stage_fds_install`; the fds themselves are
11377/// parked by `BUILTIN_RUN_PIPELINE`.
11378pub const BUILTIN_PIPE_FDS_INSTALL: u16 = 642;
11379
11380/// Magic-equals prefork for a single arg word of a
11381/// `BINF_MAGICEQUALS` builtin head (`alias`). Direct port of
11382/// c:Src/exec.c:3298-3304 — `esprefork = PREFORK_TYPESET;
11383/// prefork(args, esprefork, NULL)` runs on the argv BEFORE the addfd
11384/// redirect loop at c:3720, so an expansion zerr (`alias bad===` →
11385/// equalsubstr "= not found" at Src/subst.c:726) prints to the
11386/// command's UN-redirected stderr. argc=1: pops the just-pushed
11387/// word value, runs shtokenize → prefork(PREFORK_TYPESET) →
11388/// untokenize on it (each element for Array splices), pushes the
11389/// result back. Emitted by compile_simple per arg word when the
11390/// dispatch head is `alias`; BUILTIN_ALIAS itself no longer
11391/// preforks (it would double-fire the diagnostic).
11392pub const BUILTIN_MAGIC_EQUALS_PREFORK: u16 = 621;
11393
11394/// Bare (unbraced) `$name[idx]` subscript — same dispatch as
11395/// `BUILTIN_ARRAY_INDEX` while KSHARRAYS is unset, but under
11396/// KSHARRAYS the unbraced form does NOT subscript (c:Src/subst.c:
11397/// 2800-2802 + 2867): `$name` expands bare and `[idx]` stays literal
11398/// trailing text that undergoes filename generation. Operands:
11399/// [name, idx, suffix, quoted].
11400pub const BUILTIN_ARRAY_INDEX_UNBRACED: u16 = 622;
11401
11402/// Assignment-only simple-command exit status. Direct port of
11403/// `lv = (errflag ? errflag : cmdoutval)` (c:Src/exec.c:1322,
11404/// execsimple's WC_ASSIGN arm) / `if (errflag) lastval = 1; else
11405/// lastval = cmdoutval;` (c:Src/exec.c:3393-3396, execcmd_exec's
11406/// no-command-word varspc path; redir variant at c:3977). Pops
11407/// [had_cmd_subst]; cmdoutval is the live vm.last_status when a
11408/// `$()` ran in any RHS of the chain, 0 otherwise. Writes the
11409/// canonical LASTVAL (C's single `lastval` global) so the
11410/// non-interactive errflag abort exits with this value per
11411/// Src/init.c:234. Caller pairs with SetStatus.
11412pub const BUILTIN_ASSIGN_ONLY_STATUS: u16 = 623;
11413
11414/// c:Src/exec.c addvars — `if (!pm) { lastval = 1; if (!cmdoutval)
11415/// cmdoutval = 1; }`. Set by BUILTIN_SET_VAR on assignsparam
11416/// failure, consumed by BUILTIN_ASSIGN_ONLY_STATUS so the
11417/// assignment-only command reports status 1. Process-global like
11418/// C's `cmdoutval` (function bodies may run on a different thread
11419/// than the opcode that reads the status back).
11420pub static ASSIGN_FAILED_FLAG: std::sync::atomic::AtomicBool =
11421    std::sync::atomic::AtomicBool::new(false);
11422
11423/// `redirection with no command` parse-time error for bare
11424/// `builtin 2>&1` / `command < file` / `exec >&-` precmd-keyword
11425/// shapes with a redirect but no following command. Direct port
11426/// of `Src/exec.c:3342 zerr("redirection with no command")`.
11427/// argc=0; pushes Value::Status(1).
11428pub const BUILTIN_REDIR_NO_CMD: u16 = 616;
11429
11430/// GLOB_SUBST guard for `[[ x == $pat ]]` pattern RHS coming from
11431/// parameter / command substitution. C-zsh's `[[ == ]]` semantics
11432/// (Src/options.c GLOB_SUBST default OFF + Src/cond.c:552
11433/// `cond_match` + Src/pattern.c patcompile tokenization-based
11434/// meta detection) treat chars from substitution as LITERAL
11435/// unless GLOB_SUBST is on. The Rust patcompile accepts both
11436/// tokenized and raw-ASCII meta chars, losing the distinction,
11437/// so `pat="h*"; [[ hello == $pat ]]` matched in zshrs but not
11438/// in zsh. Bug #116 in docs/BUGS.md.
11439///
11440/// Compile-time signal: emitted by `compile_cond_expr` ONLY when
11441/// the RHS contains `$` or backtick. Runtime checks the live
11442/// option state. If GLOB_SUBST is OFF, the popped string has
11443/// its glob metachars escaped with `\` so the downstream StrMatch
11444/// → patcompile treats them as literals. If GLOB_SUBST is ON,
11445/// the value passes through unchanged so `setopt glob_subst`
11446/// restores zsh's pattern-on-expansion behavior.
11447///
11448/// Stack: pops one string, pushes the (possibly escaped) result.
11449/// argc = 1.
11450pub const BUILTIN_GLOB_SUBST_GUARD: u16 = 528;
11451
11452/// Coerce a string parameter value to a math number (Int or Float)
11453/// for arithmetic-context reads, mirroring C-zsh's `getmathparam`
11454/// (Src/math.c:337). When the variable holds a string like "hello"
11455/// that isn't numeric, C falls back to recursively evaluating the
11456/// raw string as an arith expression; if that fails too, returns 0.
11457///
11458/// Used by the ArithCompiler pre-load path so `(( y = x ))` with
11459/// `x="hello"` reads `x` as integer 0, then assigns y as integer 0
11460/// — matching zsh's behaviour. The previous Rust port used
11461/// BUILTIN_GET_VAR which returned the raw string "hello"; the
11462/// ArithCompiler stored it verbatim in y's slot, and the post-sync
11463/// BUILTIN_SET_VAR wrote y="hello" as scalar instead of y=0 as
11464/// integer. Bug #118 in docs/BUGS.md.
11465///
11466/// Stack: pops `name` (string), pushes coerced numeric Value.
11467/// argc = 1.
11468pub const BUILTIN_GET_MATH_VAR: u16 = 529;
11469
11470/// GLOB_SUBST runtime gate for words containing parameter / command
11471/// substitution. C-zsh's `prefork` (Src/subst.c) runs `shtokenize`
11472/// on the substituted value when `GLOB_SUBST` is set, making the
11473/// substituted chars eligible for filename generation. With the
11474/// option off, substituted chars stay literal.
11475///
11476/// The Rust port's compile_zsh emits `compile_word_str` for words
11477/// like `/tmp/X/$pat`, which returns the post-expansion string but
11478/// never runs glob expansion (no path here triggers
11479/// BUILTIN_GLOB_EXPAND). Bug #119 in docs/BUGS.md: with `setopt
11480/// glob_subst`, `for f in /tmp/X/$pat` (pat="*.txt") never matched
11481/// `*.txt` files.
11482///
11483/// This opcode wraps the substitution result and dispatches at
11484/// runtime: when GLOB_SUBST is OFF, return unchanged; when ON,
11485/// pass the value through `expand_glob` so glob metas become
11486/// active. Emitted by `compile_for_words` (and similar sites)
11487/// after WORD_SPLIT for words with unquoted expansion.
11488///
11489/// Stack: pops a Value (Str or Array of Str), pushes the glob-
11490/// expanded result (still Str or Array depending on input shape).
11491/// argc = 1.
11492pub const BUILTIN_GLOB_SUBST_EXPAND: u16 = 530;
11493/// `BUILTIN_ASSOC_HAS_KEY` constant — `${(k)assoc[name]}` key-existence
11494/// query. Returns the key text on hit, empty string on miss. Bug #145.
11495pub const BUILTIN_ASSOC_HAS_KEY: u16 = 531;
11496/// `BUILTIN_ARRAY_DROP_EMPTY` constant — filter empty elements from
11497/// an Array on the stack. Used by `for x in $@` / `for x in $*`
11498/// unquoted forms. Bug #166.
11499pub const BUILTIN_ARRAY_DROP_EMPTY: u16 = 532;
11500/// `BUILTIN_QUOTEDZPUTS` constant — run top-of-stack value through
11501/// `crate::ported::utils::quotedzputs` and push the quoted result.
11502/// Used by the cond xtrace path so non-printable bytes (e.g.
11503/// `$'\C-[OP'` expanded ESC+OP) get re-wrapped in `$'…'` form for
11504/// the trace prefix line, matching zsh's `Src/exec.c` cond trace
11505/// which calls `quotedzputs(operand, xtrerr)` on each side. Bug
11506/// surfaced when `[[ -n $'\C-[OP' ]]` traced as `[[ -n OP ]]`
11507/// (raw bytes leaked through the terminal) vs zsh's
11508/// `[[ -n $'\C-[OP' ]]` source-form preservation.
11509pub const BUILTIN_QUOTEDZPUTS: u16 = 533;
11510/// `BUILTIN_QUOTE_TOKENIZED_OUTPUT` — port of
11511/// `crate::ported::exec::quote_tokenized_output` (Src/exec.c:2114)
11512/// applied to top-of-stack scalar. Used by cond xtrace for the RHS
11513/// of pattern-context comparisons (`=` / `==` / `!=`) where C zsh
11514/// emits the SOURCE form: untokenize lexer tokens (Star → `*`,
11515/// Inpar → `(`, …) and backslash-escape special chars, but
11516/// preserve literal ASCII unchanged. Distinct from quotedzputs
11517/// which wraps the whole string in `'…'` / `$'…'` based on
11518/// non-printability — that's wrong for `[[ x = a* ]]` which must
11519/// render as `[[ x = a* ]]`, not `'a*'`.
11520pub const BUILTIN_QUOTE_TOKENIZED_OUTPUT: u16 = 534;
11521
11522/// Bridge into subst_port::substitute_brace_array for nested forms
11523/// that need to PRESERVE array shape across the expand_string
11524/// boundary. Stack: `[content_string]`. Returns Value::Array of the
11525/// per-element words. Used by the compile path for
11526/// `${(@)<nested>...##pat}` shapes — the standard substitute_brace
11527/// returns String which collapses array→scalar; this builtin
11528/// preserves the multi-word output via paramsubst's third return
11529/// (`nodes` vec, the C source's `aval` thread).
11530pub const BUILTIN_BRIDGE_BRACE_ARRAY: u16 = 347;
11531
11532/// Word-segment concat with FIRST/LAST sticking. Stack: [lhs, rhs].
11533/// Used for default unquoted splice forms (`${arr[@]}`, `$@`, `$*`)
11534/// where prefix sticks to first element only and suffix to last only.
11535///
11536/// Distribution table:
11537/// - both scalar: `Value::str(a + b)` (fast path)
11538/// - lhs scalar, rhs Array(b₀..bₙ): `Value::Array([lhs+b₀, b₁, …, bₙ])`
11539/// - lhs Array(a₀..aₙ), rhs scalar: `Value::Array([a₀, …, aₙ₋₁, aₙ+rhs])`
11540/// - both Array: `Value::Array([a₀, …, aₙ₋₁, aₙ+b₀, b₁, …, bₙ])`
11541///   (last of lhs merges with first of rhs; the rest stay separate)
11542///
11543/// This is the default zsh semantics for `print -l X${arr[@]}Y` →
11544/// "Xa", "b", "cY" — three distinct args, surrounding text only on ends.
11545pub const BUILTIN_CONCAT_SPLICE: u16 = 319;
11546
11547/// `${(flags)name}` — zsh parameter expansion flags. Stack: [name, flags].
11548/// Flags applied left-to-right. Supported subset (high-value, used by zpwr):
11549///
11550///   `L` — lowercase the value (scalar; or each element if array)
11551///   `U` — uppercase
11552///   `j:sep:` — join array with `sep` (delim is the char after `j`)
11553///   `s:sep:` — split scalar on `sep` (returns Value::Array)
11554///   `f` — split on newlines (shorthand for `s.\n.`)
11555///   `o` — sort array ascending
11556///   `O` — sort array descending
11557///   `P` — indirect: read name's value as another var name, return that's value
11558///   `@` — keep as array (returns Value::Array — useful before `j` etc.)
11559///   `k` — keys of assoc array
11560///   `v` — values of assoc array
11561///   `#` — word count (array length as scalar)
11562///
11563/// Flags can stack: `(jL)` joins then lowercases; `(s.,.U)` splits on `,`
11564/// then uppercases each element. The long-tail flags (`q`, `qq`, `qqq` for
11565/// quoting, `A` for assoc, `%` for prompt expansion, `e`/`g` for re-eval,
11566/// `n`/`p` for numeric, `t` for type, etc.) are deferred — they hit the
11567/// runtime fallback via the catch-all expansion path.
11568pub const BUILTIN_PARAM_FLAG: u16 = 297;
11569
11570/// `ShellHost` implementation that delegates to the current `ShellExecutor`
11571/// via the `with_executor` thread-local.
11572///
11573/// Construct fresh on each VM run (it carries no state itself). The VM
11574/// dispatches host method calls during `vm.run()`, and `with_executor`
11575/// resolves to the executor pointer set by `ExecutorContext::enter`.
11576/// fusevm-host implementation tying bytecode ops to the
11577/// shell executor.
11578/// zshrs-original — no C counterpart. C zsh has no bytecode VM
11579/// to host; everything runs through `execlist()`/`execpline()`
11580/// directly (Src/exec.c lines 1349/1668).
11581pub struct ZshrsHost;
11582
11583impl fusevm::ShellHost for ZshrsHost {
11584    fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> {
11585        with_executor(|exec| exec.expand_glob(pattern))
11586    }
11587
11588    fn tilde_expand(&mut self, s: &str) -> String {
11589        with_executor(|exec| s.to_string())
11590    }
11591
11592    fn brace_expand(&mut self, s: &str) -> Vec<String> {
11593        // Direct call to the canonical brace expander
11594        // (Src/glob.c::xpandbraces port at glob.rs:1678). Was
11595        // routing through singsub which uses PREFORK_SINGLE — that
11596        // flag explicitly suppresses brace expansion in subst.c:166,
11597        // so `print X{1,2,3}Y` returned the literal string.
11598        //
11599        // brace_ccl: respect the BRACE_CCL option which the bracket-
11600        // class form `{a-z}` requires. Pull from executor options.
11601        let brace_ccl = with_executor(|exec| opt_state_get("braceccl").unwrap_or(false));
11602        crate::ported::glob::xpandbraces(s, brace_ccl)
11603    }
11604
11605    fn str_match(&mut self, s: &str, pattern: &str) -> bool {
11606        // Shell glob match — `*`, `?`, `[...]`, alternation. After the
11607        // cond path moved to BUILTIN_COND_STRMATCH, the consumer here
11608        // is the `case` arm dispatch, whose bad-pattern semantics are
11609        // Src/loop.c:663-667: `if (!(pprog = patcompile(pat, ...)))
11610        // zerr("bad pattern: %s", pat);` — errflag set, the arm
11611        // doesn't match, and the script aborts at the next command
11612        // boundary (matching `zsh -fc 'case x in [a-) ...'` printing
11613        // the diagnostic with exit 0 = untouched lastval).
11614        let mut pat_tok = pattern.to_string();
11615        crate::ported::glob::tokenize(&mut pat_tok);
11616        if crate::ported::pattern::patcompile(
11617            &pat_tok,
11618            crate::ported::zsh_h::PAT_STATIC as i32,
11619            None,
11620        )
11621        .is_none()
11622        {
11623            crate::ported::utils::zerr(&format!("bad pattern: {}", pattern)); // c:667
11624            return false;
11625        }
11626        glob_match_static(s, pattern)
11627    }
11628
11629    fn expand_param(&mut self, name: &str, _modifier: u8, _args: &[Value]) -> Value {
11630        // Sole funnel: route through `getsparam` matching C zsh's
11631        // `getsparam(name)` → `getvalue` → `getstrvalue` →
11632        // `Param.gsu->getfn` dispatch (Src/params.c:3076 / 2335).
11633        //
11634        // The lookup chain (GSU dispatch + variables + env + array-
11635        // join) lives in `params::getsparam`; subst.rs and this
11636        // bridge both call into it so the logic is in exactly one
11637        // place — mirroring C's "every read goes through getsparam"
11638        // architecture. fuseVM bytecode triggers this bridge when
11639        // the VM hits a PARAM opcode, equivalent to C's wordcode VM
11640        // resolving a parameter read during `exec.c` execution.
11641        //
11642        // Modifier handling: the `_modifier` / `_args` parameters
11643        // are populated by the bytecode compiler but applied by
11644        // separate VM opcodes (LENGTH/STRIP/SUBST/etc.) downstream
11645        // of this fetch — matching C's split between getsparam
11646        // (value fetch) and paramsubst's modifier-walk loop. This
11647        // bridge is the value-fetch step only.
11648        let val_str = crate::ported::params::getsparam(name).unwrap_or_default();
11649        Value::str(val_str)
11650    }
11651
11652    fn process_sub_in(&mut self, sub: &fusevm::Chunk) -> String {
11653        // c:Src/exec.c:4906 getoutputfile — `=(cmd)` (marked "equalsubst" by the
11654        // compiler) is the TEMP-FILE flavor: create a real regular file, fork a
11655        // writer whose stdout is the file, WAIT for it (so the file is complete
11656        // and seekable before the consumer runs), and return the file path. It
11657        // is unlinked at job end. This differs from `<(cmd)` below, which is a
11658        // /dev/fd pipe that is never waited on.
11659        if sub.source == "equalsubst" {
11660            let nam = crate::ported::utils::gettempname(None, true)
11661                .unwrap_or_else(|| format!("/tmp/zshrs_eqsub_{}", std::process::id()));
11662            let cpath = match std::ffi::CString::new(nam.as_str()) {
11663                Ok(c) => c,
11664                Err(_) => return String::from("/dev/null"),
11665            };
11666            // c:4945 — O_WRONLY|O_CREAT|O_EXCL|O_NOCTTY, 0600.
11667            let fd = unsafe {
11668                libc::open(
11669                    cpath.as_ptr(),
11670                    libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOCTTY,
11671                    0o600,
11672                )
11673            };
11674            if fd < 0 {
11675                return String::from("/dev/null");
11676            }
11677            let sub_for_child = sub.clone();
11678            match unsafe { libc::fork() } {
11679                -1 => {
11680                    unsafe { libc::close(fd) };
11681                    let _ = fs::remove_file(&nam);
11682                    return String::from("/dev/null");
11683                }
11684                0 => {
11685                    // c:4985 — child: stdout → the temp file, run the body, exit.
11686                    // Clear the inherited pending-file list so this child never
11687                    // unlinks the PARENT's =() temp files when its own commands
11688                    // dispatch (fork copies the list; unlink hits the shared fs).
11689                    PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
11690                    unsafe {
11691                        libc::dup2(fd, libc::STDOUT_FILENO);
11692                        libc::close(fd);
11693                    }
11694                    let mut vm = fusevm::VM::new(sub_for_child);
11695                    register_builtins(&mut vm);
11696                    vm.set_shell_host(Box::new(ZshrsHost));
11697                    let _ = vm.run();
11698                    let _ = std::io::stdout().flush();
11699                    unsafe { libc::_exit(0) };
11700                }
11701                child_pid => {
11702                    // c:4976-4980 — parent: close the write fd and WAIT so the
11703                    // file is fully written before the consumer opens it.
11704                    unsafe {
11705                        libc::close(fd);
11706                        let mut status: libc::c_int = 0;
11707                        libc::waitpid(child_pid, &mut status, 0);
11708                    }
11709                    let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
11710                    PSUB_PENDING_FILES.with(|v| v.borrow_mut().push((depth, nam.clone())));
11711                    return nam;
11712                }
11713            }
11714        }
11715        // c:Src/exec.c::getproc — `<(cmd)` uses pipe + fork + the
11716        // `/dev/fd/N` filesystem entry (where N is the read end of
11717        // the pipe held open in the parent). Consumer opens
11718        // `/dev/fd/N`, reads the cmd's stdout through the pipe.
11719        // Both macOS and Linux expose `/dev/fd` for held-open file
11720        // descriptors. Previous Rust port captured stdout into
11721        // `/tmp/zshrs_psub_*` tempfiles synchronously — works for
11722        // `diff <(a) <(b)` style readers that scan once but diverges
11723        // from zsh's observable path string and breaks any consumer
11724        // that introspects the path or expects a non-seekable pipe.
11725        let mut fds: [libc::c_int; 2] = [-1, -1];
11726        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
11727            // Pipe creation failed — fall back to tempfile so we at
11728            // least return SOMETHING.
11729            let fifo_path = format!(
11730                "/tmp/zshrs_psub_fallback_{}_{}",
11731                std::process::id(),
11732                with_executor(|e| {
11733                    let n = e.process_sub_counter;
11734                    e.process_sub_counter += 1;
11735                    n
11736                })
11737            );
11738            let _ = fs::remove_file(&fifo_path);
11739            return fifo_path;
11740        }
11741        let (read_end, write_end) = (fds[0], fds[1]);
11742        let sub_for_child = sub.clone();
11743        match unsafe { libc::fork() } {
11744            -1 => {
11745                unsafe {
11746                    libc::close(read_end);
11747                    libc::close(write_end);
11748                }
11749                return String::from("/dev/null");
11750            }
11751            0 => {
11752                // Child: close read end, dup write end to stdout,
11753                // run the sub-chunk, exit. The exit closes the
11754                // write end automatically, so the parent's reader
11755                // gets EOF when the cmd finishes.
11756                PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
11757                unsafe {
11758                    libc::close(read_end);
11759                    libc::dup2(write_end, libc::STDOUT_FILENO);
11760                    libc::close(write_end);
11761                }
11762                // c:Src/exec.c:5101/5150 — `execode(prog, 0, 1, out ?
11763                // "outsubst" : "insubst");`. execode (c:1245-1266) APPENDS its
11764                // context for the duration of the body, so `<(cmd)` — whose child WRITES (out=1) — runs as `…:outsubst`.
11765                // zshrs's ported getproc carries these citations but is NOT the
11766                // live path (established in #1062) — the VM forks here instead,
11767                // so the push belongs in this child. No pop needed: this runs
11768                // INSIDE the forked child and dies with it. Bug #1069 (procsub
11769                // legs).
11770                if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
11771                    ctx.push("outsubst".to_string());
11772                    let joined = ctx.join(":");
11773                    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
11774                        if let Some(pm) = tab.get_mut("zsh_eval_context") {
11775                            pm.u_arr = Some(ctx.clone());
11776                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
11777                        }
11778                        if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
11779                            pm.u_str = Some(joined);
11780                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
11781                        }
11782                    }
11783                }
11784                crate::fusevm_disasm::maybe_print_stdout("process_subst_in", &sub_for_child);
11785                let mut vm = fusevm::VM::new(sub_for_child);
11786                register_builtins(&mut vm);
11787                vm.set_shell_host(Box::new(ZshrsHost));
11788                let _ = vm.run();
11789                let _ = std::io::stdout().flush();
11790                unsafe { libc::_exit(0) };
11791            }
11792            child_pid => {
11793                // c:Src/exec.c:5092 `procsubstpid = pid;` — record the
11794                // forked child's PID so `${sysparams[procsubstpid]}`
11795                // returns it (was reading the never-updated atomic, so it
11796                // always came back 0). p10k's gitstatus daemon reads
11797                // `sysparams[procsubstpid]` right after `sysopen <(cmd)`
11798                // to track its worker PID; with 0 the daemon's self-check
11799                // failed and gitstatus fell back to re-downloading
11800                // gitstatusd — surfacing as "no prebuilt gitstatusd".
11801                crate::ported::exec::procsubstpid
11802                    .store(child_pid, std::sync::atomic::Ordering::Relaxed);
11803                // Parent: close write end, keep read end open under
11804                // the same fd value so `/dev/fd/N` resolves to the
11805                // pipe's read side. NOTE: FD_CLOEXEC must STAY clear
11806                // — consumers like `cat <(cmd)` and `diff <(a) <(b)`
11807                // discover the fd via exec inheritance, so closing
11808                // on exec defeats the whole point. C zsh's getproc
11809                // (Src/exec.c:5045+) leaves the fd open across exec.
11810                unsafe {
11811                    libc::close(write_end);
11812                }
11813                // Park read_end for close-after-consuming-command,
11814                // exactly like process_sub_out does for its write_end
11815                // (c:Src/exec.c addfilelist(NULL, fd) → deletefilelist).
11816                // WITHOUT this the parent's read_end stayed open for
11817                // the whole shell lifetime: p10k's async worker /
11818                // realtime clock do `exec {fd}< <(cmd)` on every prompt,
11819                // so each keystroke/redraw leaked a pipe fd until the
11820                // ~256-fd limit was hit and the shell locked up
11821                // (107 leaked pipes + 107 unreaped children observed).
11822                let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
11823                PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, read_end)));
11824                // Reap the forked child so it doesn't linger as a
11825                // zombie. `<(cmd)` children are fire-and-forget (their
11826                // output flows through the pipe); C reaps them via the
11827                // job machinery. A non-blocking reap here is scheduled;
11828                // do a best-effort WNOHANG now and the rest drain on
11829                // subsequent proc-subs / prompt cycles.
11830                crate::fusevm_bridge::note_psub_child(child_pid);
11831            }
11832        }
11833        format!("/dev/fd/{}", read_end)
11834    }
11835
11836    fn process_sub_out(&mut self, sub: &fusevm::Chunk) -> String {
11837        // c:Src/exec.c:5025 getproc, PATH_DEV_FD branch — `>(cmd)`
11838        // (out == 0): `mpipe(pipes)`, fork; the CHILD `redup(pipes[0],
11839        // 0)` (pipe read end onto stdin) and `closem` drops the write
11840        // end; the PARENT closes pipes[0] and hands the consumer
11841        // `/dev/fd/<pipes[1]>` (the write end). The previous Rust port
11842        // used mkfifo + a child that BLOCKED in open(FIFO, O_RDONLY)
11843        // before running cmd — with no writer the child never started,
11844        // never exited, and kept its inherited stdout (e.g. a `$()`
11845        // capture pipe) open forever: `a=$(print -r -- >(true))` hung.
11846        // With the pipe shape the child runs immediately and exits,
11847        // releasing inherited fds exactly like zsh (verified: zsh
11848        // blocks ~2s on `a=$(print -r -- >(sleep 2))`, then EOFs).
11849        let mut fds: [libc::c_int; 2] = [-1, -1];
11850        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
11851            // Pipe creation failed — fall back to a plain temp file so
11852            // the consumer at least has a writable path.
11853            let fallback = format!(
11854                "/tmp/zshrs_psub_out_{}_{}",
11855                std::process::id(),
11856                with_executor(|e| {
11857                    let n = e.process_sub_counter;
11858                    e.process_sub_counter += 1;
11859                    n
11860                })
11861            );
11862            let _ = fs::write(&fallback, "");
11863            return fallback;
11864        }
11865        let (read_end, write_end) = (fds[0], fds[1]);
11866        let sub_for_child = sub.clone();
11867        match unsafe { libc::fork() } {
11868            -1 => {
11869                unsafe {
11870                    libc::close(read_end);
11871                    libc::close(write_end);
11872                }
11873                String::from("/dev/null")
11874            }
11875            0 => {
11876                // Child: close the write end (c: closem after redup),
11877                // dup the read end onto stdin (c: redup(pipes[0], 0)),
11878                // run the sub-chunk, exit. Other std fds stay
11879                // inherited — zsh's child keeps the surrounding
11880                // command's stdout/stderr.
11881                unsafe {
11882                    libc::close(write_end);
11883                    libc::dup2(read_end, libc::STDIN_FILENO);
11884                    libc::close(read_end);
11885                }
11886                // c:Src/exec.c:5101/5150 — `execode(prog, 0, 1, out ?
11887                // "outsubst" : "insubst");`. execode (c:1245-1266) APPENDS its
11888                // context for the duration of the body, so `>(cmd)` — whose child READS (out=0) — runs as `…:insubst`.
11889                // zshrs's ported getproc carries these citations but is NOT the
11890                // live path (established in #1062) — the VM forks here instead,
11891                // so the push belongs in this child. No pop needed: this runs
11892                // INSIDE the forked child and dies with it. Bug #1069 (procsub
11893                // legs).
11894                if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
11895                    ctx.push("insubst".to_string());
11896                    let joined = ctx.join(":");
11897                    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
11898                        if let Some(pm) = tab.get_mut("zsh_eval_context") {
11899                            pm.u_arr = Some(ctx.clone());
11900                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
11901                        }
11902                        if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
11903                            pm.u_str = Some(joined);
11904                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
11905                        }
11906                    }
11907                }
11908                crate::fusevm_disasm::maybe_print_stdout("process_subst_out:child", &sub_for_child);
11909                let mut vm = fusevm::VM::new(sub_for_child);
11910                register_builtins(&mut vm);
11911                vm.set_shell_host(Box::new(ZshrsHost));
11912                let _ = vm.run();
11913                unsafe { libc::_exit(0) };
11914            }
11915            child_pid => {
11916                // c:Src/exec.c:5143 `procsubstpid = pid;` — same fix as
11917                // the `<(cmd)` in-path above: record the forked child's
11918                // PID for `${sysparams[procsubstpid]}` (was always 0).
11919                crate::ported::exec::procsubstpid
11920                    .store(child_pid, std::sync::atomic::Ordering::Relaxed);
11921                // Parent: close the read end, keep the write end open
11922                // under its fd value so `/dev/fd/N` resolves to the
11923                // pipe's write side. FD_CLOEXEC must STAY clear —
11924                // consumers (`tee >(cmd)`) discover the fd via exec
11925                // inheritance, matching process_sub_in above and C's
11926                // fdtable[fd] = FDT_PROC_SUBST bookkeeping. Park the
11927                // fd for close-after-consuming-command (c: addfilelist
11928                // (NULL, fd) → deletefilelist) so the child's reader
11929                // EOFs — without this `tee >(wc -c) </dev/null` left
11930                // wc blocked until shell exit.
11931                unsafe {
11932                    libc::close(read_end);
11933                }
11934                let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
11935                PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, write_end)));
11936                format!("/dev/fd/{}", write_end)
11937            }
11938        }
11939    }
11940
11941    fn subshell_begin(&mut self) {
11942        with_executor(|exec| {
11943            // Special parameters whose value lives in a process GLOBAL behind a
11944            // GSU (`Src/params.c`'s `ifs`, `wordchars`, `home`, `histsiz`, …)
11945            // rather than in the param table. Mirrors the getfn dispatch list at
11946            // params.rs:12548. C isolates these for free by forking `(...)`;
11947            // zshrs's in-process subshell has to snapshot them by hand, or a
11948            // subshell-local `IFS=,` rewrites the parent's word-splitting.
11949            const SUBSHELL_SPECIAL_GLOBALS: &[&str] = &[
11950                "IFS",
11951                "HOME",
11952                "TERM",
11953                "USERNAME",
11954                "WORDCHARS",
11955                "TERMINFO",
11956                "TERMINFO_DIRS",
11957                "KEYBOARD_HACK",
11958                "histchars",
11959                "HISTSIZE",
11960                "SAVEHIST",
11961            ];
11962            // An UNSET special yields None and is skipped: the paramtab snapshot
11963            // restores its PM_UNSET flag, and the getfn dispatch refuses to read
11964            // the (stale) global while PM_UNSET is set (params.rs:12552).
11965            let special_globals_snap: Vec<(String, String)> = SUBSHELL_SPECIAL_GLOBALS
11966                .iter()
11967                .filter_map(|n| {
11968                    crate::ported::params::getsparam(n).map(|v| ((*n).to_string(), v))
11969                })
11970                .collect();
11971            // libc::umask returns the previous mask AND sets the new
11972            // one; call with current value to read without changing.
11973            let cur_umask = unsafe {
11974                let m = libc::umask(0o022);
11975                libc::umask(m);
11976                m as u32
11977            };
11978            // Snapshot paramtab + hashed-storage too (step 1 of the
11979            // store unification mirrors writes there; restoring only
11980            // the HashMaps leaks subshell-scoped writes to the parent
11981            // via paramtab readers like `paramsubst → vars_get`).
11982            let paramtab_snap = crate::ported::params::paramtab()
11983                .read()
11984                .ok()
11985                .map(|t| t.clone())
11986                .unwrap_or_default();
11987            let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
11988                .lock()
11989                .ok()
11990                .map(|m| m.clone())
11991                .unwrap_or_default();
11992            exec.subshell_snapshots.push(SubshellSnapshot {
11993                paramtab: paramtab_snap,
11994                paramtab_hashed_storage: paramtab_hashed_snap,
11995                special_globals: special_globals_snap,
11996                positional_params: exec.pparams(),
11997                env_vars: env::vars().collect(),
11998                // Save the LOGICAL pwd ($PWD env), not `current_dir()`'s
11999                // symlink-resolved path. zsh's subshell isolation per
12000                // Src/exec.c at the `entersubsh` path treats `pwd` (the
12001                // shell-tracked logical PWD) as the carrier — see
12002                // `Src/builtin.c:1239-1242` where cd writes the logical
12003                // dest into `pwd`. Falling back to current_dir() only
12004                // when PWD is unset matches `setupvals` at
12005                // `Src/init.c:1100+`.
12006                cwd: env::var("PWD")
12007                    .ok()
12008                    .map(PathBuf::from)
12009                    .or_else(|| env::current_dir().ok()),
12010                umask: cur_umask,
12011                // Snapshot canonical `traps_table` — bin_trap writes
12012                // there (`Src/builtin.c`).
12013                traps: crate::ported::builtin::traps_table()
12014                    .lock()
12015                    .map(|t| t.clone())
12016                    .unwrap_or_default(),
12017                // Snapshot option store so `(set -e)` /
12018                // `(setopt extendedglob)` don't leak to parent.
12019                opts: crate::ported::options::opt_state_snapshot(),
12020                // c:Src/exec.c — fork() copies the alias table to
12021                // the subshell. `(alias x=y)` inside the subshell
12022                // dies with the child; the parent doesn't see x.
12023                // Snapshot here so subshell_end can restore.
12024                // Bug #209 in docs/BUGS.md.
12025                aliases: crate::ported::hashtable::aliastab_lock()
12026                    .read()
12027                    .ok()
12028                    .map(|t| {
12029                        t.iter()
12030                            .map(|(k, v)| (k.clone(), v.text.clone(), v.node.flags))
12031                            .collect()
12032                    })
12033                    .unwrap_or_default(),
12034                // c:Src/exec.c::entersubsh — same fork-copy
12035                //   semantics for shfunctab. `(f() { ... })` defined
12036                //   inside the subshell dies with the child; parent's
12037                //   `type f` reports "not found". Bug #208 in
12038                //   docs/BUGS.md.
12039                shfuncs: crate::ported::hashtable::shfunctab_lock()
12040                    .read()
12041                    .ok()
12042                    .map(|t| t.snapshot())
12043                    .unwrap_or_default(),
12044                functions_compiled: exec.functions_compiled.clone(),
12045                function_source: exec.function_source.clone(),
12046                // c:Src/exec.c::entersubsh — subshell forks its own
12047                // modulestab. A `(zmodload zsh/X)` inside the
12048                // subshell flips MOD_INIT_B on the CHILD's
12049                // modulestab; when the child exits the change
12050                // dies with it. zshrs's in-process subshell would
12051                // otherwise leak the load to the parent.
12052                // Bug #210 in docs/BUGS.md. Snapshot just the
12053                // (name → flags) pairs since the only mutating
12054                // field is the flags bitmask (MOD_INIT_B for
12055                // loaded, MOD_UNLOAD for unloaded).
12056                modules: crate::ported::module::MODULESTAB
12057                    .lock()
12058                    .ok()
12059                    .map(|t| {
12060                        t.modules
12061                            .iter()
12062                            .map(|(k, v)| (k.clone(), v.node.flags))
12063                            .collect()
12064                    })
12065                    .unwrap_or_default(),
12066                // c:Src/exec.c::entersubsh — fork-copy semantics for
12067                // THINGYTAB (ZLE widget registry). A subshell `zle -N`
12068                // / `zle -D` mutation dies with the child in C zsh;
12069                // mirror via in-process snapshot. Bug #453.
12070                thingytab: crate::ported::zle::zle_thingy::thingytab()
12071                    .lock()
12072                    .ok()
12073                    .map(|t| t.clone())
12074                    .unwrap_or_default(),
12075                // c:Src/exec.c::entersubsh — same fork-copy for the
12076                // KEYMAPNAMTAB (named keymap registry). `bindkey -N km`
12077                // / `bindkey -D km` inside a subshell dies with the
12078                // child. Bug #454.
12079                keymapnamtab: crate::ported::zle::zle_keymap::keymapnamtab()
12080                    .lock()
12081                    .ok()
12082                    .map(|t| t.clone())
12083                    .unwrap_or_default(),
12084                // c:Src/exec.c::entersubsh fork semantics — `$!`
12085                // (clone::lastpid) set by a `&` INSIDE the subshell
12086                // dies with the child: `( : & ); echo $!` -> 0.
12087                lastpid: crate::ported::modules::clone::lastpid
12088                    .load(std::sync::atomic::Ordering::Relaxed),
12089                // c:Src/exec.c::entersubsh fork semantics — the
12090                // subshell gets a COPY of the job table; its disown/
12091                // wait/`&` mutations die with it. Bug #462.
12092                jobtab: crate::ported::jobs::JOBTAB
12093                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
12094                    .lock()
12095                    .map(|t| t.clone())
12096                    .unwrap_or_default(),
12097                curjob: *crate::ported::jobs::CURJOB
12098                    .get_or_init(|| std::sync::Mutex::new(-1))
12099                    .lock()
12100                    .unwrap(),
12101                prevjob: *crate::ported::jobs::PREVJOB
12102                    .get_or_init(|| std::sync::Mutex::new(-1))
12103                    .lock()
12104                    .unwrap(),
12105                maxjob: *crate::ported::jobs::MAXJOB
12106                    .get_or_init(|| std::sync::Mutex::new(0))
12107                    .lock()
12108                    .unwrap(),
12109                thisjob: *crate::ported::jobs::THISJOB
12110                    .get_or_init(|| std::sync::Mutex::new(-1))
12111                    .lock()
12112                    .unwrap(),
12113                // c:Src/exec.c entersubsh — fork copies the fd table;
12114                // the child's `exec >file` / `exec N<&-` mutations die
12115                // with it. Dup each user-range fd to >= 10 so
12116                // subshell_end can restore the parent's exact table.
12117                saved_fds: (0..10)
12118                    .map(|fd| {
12119                        let dup = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
12120                        (fd, dup)
12121                    })
12122                    .collect(),
12123            });
12124            // C forks for `(...)` — count the fork-equivalent so
12125            // `time (builtin)` reports like zsh (see FORK_EVENTS).
12126            crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
12127            // c:Src/exec.c:1088-1092 — entersubsh resets traps in the child:
12128            //     if (!(flags & ESUB_KEEPTRAP))
12129            //         for (sig = 0; sig <= SIGCOUNT; sig++)
12130            //             if (!(sigtrapped[sig] & ZSIG_FUNC) &&
12131            //                 !(isset(POSIXTRAPS) && (sigtrapped[sig] & ZSIG_IGNORED)))
12132            //                 unsettrap(sig);
12133            //
12134            // A subshell does NOT inherit string-form traps. Two exemptions:
12135            // FUNCTION-form traps (`TRAPUSR1() { … }`, ZSIG_FUNC) survive, and
12136            // under POSIX_TRAPS an IGNORED trap (`trap '' SIG`) survives.
12137            //
12138            // The ZSIG_FUNC exemption is structural here rather than a flag
12139            // test: zshrs keeps string-form bodies in traps_table and
12140            // function-form ones in shfunctab as TRAPxxx, so filtering only
12141            // traps_table leaves the function form untouched by construction.
12142            // ZSIG_IGNORED is `trap '' SIG`, which stores an empty body.
12143            //
12144            // The LOOP BOUND is also part of the spec, not an implementation
12145            // detail: `sig <= SIGCOUNT` never reaches the PSEUDO-signals,
12146            // which zsh numbers above the real ones —
12147            //     #define SIGZERR   (SIGCOUNT+1)
12148            //     #define SIGDEBUG  (SIGCOUNT+2)      (c:Src/signals.h:34-35)
12149            // so ERR/ZERR and DEBUG traps SURVIVE a subshell, while SIGEXIT
12150            // (sig 0) is inside the loop and is cleared. Verified against the
12151            // oracle: `trap 'print e' ERR; (trap)` lists the ERR trap;
12152            // `trap 'print u' USR1; (trap)` lists nothing.
12153            //
12154            // Without this a subshell kept the parent's traps: `(trap)` listed
12155            // them where zsh lists nothing, and — the part that isn't
12156            // cosmetic — an inherited trap FIRED inside the child, so
12157            // `trap 'print p' USR1; (kill -USR1 $$; print after)` printed
12158            // p before after instead of after…p (the signal is meant to reach
12159            // the parent, whose trap runs there).
12160            //
12161            // The snapshot pushed above restores the parent's table at
12162            // subshell_end, which is what makes clearing safe for zshrs's
12163            // in-process subshell.
12164            {
12165                let posixtraps = crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXTRAPS);
12166                if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
12167                    t.retain(|name, body| {
12168                        // Above SIGCOUNT — outside c:1088's loop entirely.
12169                        if name == "ERR" || name == "ZERR" || name == "DEBUG" {
12170                            return true;
12171                        }
12172                        // c:1090-1092 — otherwise keep ONLY (POSIXTRAPS && ignored).
12173                        posixtraps && body.is_empty()
12174                    });
12175                }
12176            }
12177            // c:Src/exec.c:2862 — subshell fork flags carry ESUB_PGRP,
12178            // so entersubsh runs `clearjobtab(monitor)` (c:1219): the
12179            // child gets an EMPTY job table plus the procless control
12180            // job grabbed at Src/jobs.c:1828 (`thisjob = initjob()`).
12181            // That's why zsh's `(jobs)` prints nothing and `(kill %1)`
12182            // hits the empty control job instead of the parent's job 1.
12183            // The snapshot pushed above restores the parent's table at
12184            // subshell_end. Bug #462.
12185            let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
12186            crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
12187            // clearjobtab left THISJOB on the control job (Src/jobs.c:
12188            // 1828). In C the very next pipeline's execpline reassigns
12189            // thisjob (Src/exec.c:1700 `thisjob = newjob = initjob()`),
12190            // so by the time any builtin runs, thisjob never aliases
12191            // the control job. zshrs has no per-pipeline job slot —
12192            // model the between-pipelines state (-1) so getjob's
12193            // `jobnum != thisjob` (c:jobs.c:2107) doesn't reject %1 and
12194            // setcurjob doesn't demote an inherited curjob that
12195            // collides with the control slot.
12196            *crate::ported::jobs::THISJOB
12197                .get_or_init(|| std::sync::Mutex::new(-1))
12198                .lock()
12199                .unwrap() = -1;
12200            // Subshell starts with EXIT trap cleared so the parent's
12201            // EXIT handler doesn't fire when the subshell ends. zsh:
12202            // each subshell has its own trap context. Other signals
12203            // are inherited (well, parent's are still in place — but
12204            // a trap set INSIDE the subshell shouldn't leak out).
12205            if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
12206                t.remove("EXIT");
12207            }
12208            let level = exec
12209                .scalar("ZSH_SUBSHELL")
12210                .and_then(|s| s.parse::<i32>().ok())
12211                .unwrap_or(0);
12212            // c:Src/exec.c — ZSH_SUBSHELL carries PM_READONLY (declared
12213            // in params.rs special_params); setsparam would be rejected
12214            // by assignstrvalue's PM_READONLY guard. Write u_val
12215            // directly — same bypass pattern as BUILTIN_SET_LINENO at
12216            // line 2784. C zsh's PM_SPECIAL GSU vtable handles this
12217            // implicitly via the setfn callback.
12218            let new_level = (level + 1) as i64;
12219            if let Ok(mut tab) = crate::ported::params::paramtab().write() {
12220                if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
12221                    pm.u_val = new_level;
12222                    pm.u_str = Some(new_level.to_string());
12223                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
12224                }
12225            }
12226        });
12227        // Bump SUBSHELL_DEPTH so zexit defers process::exit (see
12228        // SUBSHELL_DEPTH declaration in src/ported/builtin.rs for
12229        // rationale).
12230        crate::ported::builtin::SUBSHELL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
12231        // c:Src/exec.c::entersubsh — C zsh's subshell is a forked
12232        // child process: signals sent to the parent (via `kill $$`
12233        // inside the subshell, where `$$` is the parent's pid)
12234        // never reach the child's signal handlers. zshrs's
12235        // in-process subshell shares the process pid with the
12236        // parent, so without queueing the subshell's trap handler
12237        // fires for signals that zsh would deliver only to the
12238        // parent. Queue signals across the subshell body so the
12239        // parent's restored trap table sees them after
12240        // subshell_end's unqueue drain. Bug #450.
12241        crate::ported::signals_h::queue_signals();
12242    }
12243
12244    fn subshell_end(&mut self) -> Option<i32> {
12245        // Fire subshell's EXIT trap BEFORE restoring parent state so
12246        // the trap body sees the subshell's vars and exit status. zsh
12247        // forks for `(...)` so the trap runs in the child process,
12248        // before exit. We mirror by running it here, just before the
12249        // pop+restore. REMOVE the trap before firing so the inner
12250        // execute_script doesn't fire it again at its own end.
12251        let exit_trap_body = crate::ported::builtin::traps_table()
12252            .lock()
12253            .ok()
12254            .and_then(|mut t| t.remove("EXIT"));
12255        if let Some(body) = exit_trap_body {
12256            // Execute the trap body. Errors during trap execution
12257            // don't bubble — zsh ignores trap-body errors.
12258            with_executor(|exec| {
12259                let _ = exec.execute_script(&body);
12260            });
12261        }
12262        with_executor(|exec| {
12263            if let Some(snap) = exec.subshell_snapshots.pop() {
12264                // Restore paramtab + hashed storage so subshell-scoped
12265                // writes via setsparam/setaparam/sethparam don't leak
12266                // to the parent via paramtab readers.
12267                if let Some(tab) = crate::ported::params::paramtab()
12268                    .write()
12269                    .ok()
12270                    .as_deref_mut()
12271                {
12272                    *tab = snap.paramtab;
12273                }
12274                // Restore the global-backed specials (see
12275                // SubshellSnapshot::special_globals). MUST run after the
12276                // paramtab restore above: setsparam writes through the GSU setfn
12277                // to BOTH the process global and the param node, so the paramtab
12278                // overwrite would otherwise clobber the node half of it.
12279                for (name, val) in &snap.special_globals {
12280                    crate::ported::params::setsparam(name, val);
12281                }
12282                // c:Src/exec.c::entersubsh fork semantics — restore
12283                // the parent's `$!`; a background job inside `(...)`
12284                // dies with the child in C zsh.
12285                crate::ported::modules::clone::lastpid
12286                    .store(snap.lastpid, std::sync::atomic::Ordering::Relaxed);
12287                // c:Src/exec.c::entersubsh fork semantics — restore the
12288                // parent's job table + curjob/prevjob/maxjob/thisjob.
12289                // The subshell mutated only its own copy. Bug #462.
12290                if let Ok(mut t) = crate::ported::jobs::JOBTAB
12291                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
12292                    .lock()
12293                {
12294                    *t = snap.jobtab;
12295                }
12296                *crate::ported::jobs::CURJOB
12297                    .get_or_init(|| std::sync::Mutex::new(-1))
12298                    .lock()
12299                    .unwrap() = snap.curjob;
12300                *crate::ported::jobs::PREVJOB
12301                    .get_or_init(|| std::sync::Mutex::new(-1))
12302                    .lock()
12303                    .unwrap() = snap.prevjob;
12304                *crate::ported::jobs::MAXJOB
12305                    .get_or_init(|| std::sync::Mutex::new(0))
12306                    .lock()
12307                    .unwrap() = snap.maxjob;
12308                *crate::ported::jobs::THISJOB
12309                    .get_or_init(|| std::sync::Mutex::new(-1))
12310                    .lock()
12311                    .unwrap() = snap.thisjob;
12312                if let Some(m) = crate::ported::params::paramtab_hashed_storage()
12313                    .lock()
12314                    .ok()
12315                    .as_deref_mut()
12316                {
12317                    *m = snap.paramtab_hashed_storage;
12318                }
12319                exec.set_pparams(snap.positional_params);
12320                // Restore the OS env to its pre-subshell state.
12321                // Removes any `export` writes the subshell made, and
12322                // restores any vars the subshell unset. Without this
12323                // `(export y=sub)` would leak `y` to the parent shell.
12324                let current: HashMap<String, String> = env::vars().collect();
12325                for k in current.keys() {
12326                    if !snap.env_vars.contains_key(k) {
12327                        env::remove_var(k);
12328                    }
12329                }
12330                for (k, v) in &snap.env_vars {
12331                    if current.get(k) != Some(v) {
12332                        env::set_var(k, v);
12333                    }
12334                }
12335                if let Some(cwd) = snap.cwd {
12336                    let _ = env::set_current_dir(&cwd);
12337                    // Resync $PWD env so a parent `pwd` doesn't read
12338                    // the cwd the subshell `cd`'d into.
12339                    env::set_var("PWD", &cwd);
12340                }
12341                // Restore umask. zsh's `(umask 077)` doesn't leak to
12342                // parent because the subshell forks; we run in-process
12343                // so we manually reset.
12344                unsafe {
12345                    libc::umask(snap.umask as libc::mode_t);
12346                }
12347                // Restore parent's traps (the subshell's own traps die
12348                // with it). zsh: `(trap "X" USR1)` doesn't leak the
12349                // USR1 trap out of the subshell. Write back to the
12350                // canonical `traps_table` (bin_trap writes there).
12351                if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
12352                    *t = snap.traps;
12353                }
12354                // Restore parent's option store so `(set -e)` /
12355                // `(setopt extendedglob)` don't leak. zsh forks
12356                // subshells so child option changes die with the
12357                // child; we run in-process and must restore.
12358                crate::ported::options::opt_state_restore(snap.opts);
12359                // c:Src/exec.c — fork() means alias mutations in a
12360                // subshell die with the child. Restore parent's
12361                // alias table from snapshot. Clear current entries
12362                // then re-add parent's. Bug #209 in docs/BUGS.md.
12363                if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
12364                    tab.clear();
12365                    for (name, text, flags) in snap.aliases {
12366                        tab.add(crate::ported::zsh_h::alias {
12367                            node: crate::ported::zsh_h::hashnode {
12368                                next: None,
12369                                nam: name,
12370                                // ALIAS_GLOBAL / DISABLED must survive the
12371                                // round-trip — flags:0 turned every global
12372                                // alias regular on ANY subshell exit.
12373                                flags,
12374                            },
12375                            text,
12376                            inuse: 0,
12377                        });
12378                    }
12379                }
12380                // c:Src/exec.c::entersubsh — same fork-copy
12381                //   semantics for shfunctab. Restore parent's function
12382                //   table from snapshot so `(f() { ... })` definitions
12383                //   inside the subshell don't leak to the parent.
12384                //   Bug #208 in docs/BUGS.md.
12385                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
12386                    tab.restore(snap.shfuncs);
12387                }
12388                // Restore the runtime dispatch tables (compiled chunks
12389                // + source). Without these, a subshell-defined
12390                // override leaves its bytecode in place even after
12391                // shfunctab is restored — `g` after the subshell would
12392                // still run the override.
12393                exec.functions_compiled = snap.functions_compiled;
12394                exec.function_source = snap.function_source;
12395                // c:Src/exec.c::entersubsh — restore parent's
12396                // modulestab so a subshell `(zmodload zsh/X)` doesn't
12397                // leak to the parent. Bug #210 in docs/BUGS.md.
12398                // Restore via per-module flag write since the
12399                // snapshot is `(name → flags)` only.
12400                if let Ok(mut t) = crate::ported::module::MODULESTAB.lock() {
12401                    for (name, saved_flags) in &snap.modules {
12402                        if let Some(m) = t.modules.get_mut(name) {
12403                            m.node.flags = *saved_flags;
12404                        }
12405                    }
12406                }
12407                // c:Src/exec.c::entersubsh — restore parent's THINGYTAB
12408                // so a subshell's `zle -N w f` / `zle -D w` doesn't
12409                // affect the parent's widget registry. Bug #453.
12410                if let Ok(mut t) = crate::ported::zle::zle_thingy::thingytab().lock() {
12411                    *t = snap.thingytab;
12412                }
12413                // Same for KEYMAPNAMTAB. Bug #454.
12414                if let Ok(mut t) = crate::ported::zle::zle_keymap::keymapnamtab().lock() {
12415                    *t = snap.keymapnamtab;
12416                }
12417                // c:Src/exec.c entersubsh fork semantics — restore the
12418                // parent's user-range fd table. A bare `exec >file` /
12419                // `exec N>&-` inside `(...)` died with the C child;
12420                // the in-process subshell must undo it here. Flush
12421                // Rust's stdout buffer FIRST so bytes the subshell
12422                // printed drain to the SUBSHELL's fd 1, not the
12423                // restored parent fd.
12424                {
12425                    use std::io::Write;
12426                    let _ = std::io::stdout().flush();
12427                }
12428                for (fd, saved) in snap.saved_fds {
12429                    unsafe {
12430                        if saved >= 0 {
12431                            libc::dup2(saved, fd);
12432                            libc::close(saved);
12433                        } else {
12434                            // fd was closed at entry; close whatever
12435                            // the subshell opened on that slot.
12436                            libc::close(fd);
12437                        }
12438                    }
12439                }
12440            }
12441        });
12442        // Decrement SUBSHELL_DEPTH. If a deferred subshell exit
12443        // landed inside (EXIT_PENDING set with depth > 0), promote
12444        // the deferred status into the subshell's exit status now
12445        // that we're at the boundary, then clear so the parent
12446        // continues. Matches C zsh's "subshell-exit-via-fork"
12447        // boundary where the child's process::exit(N) becomes
12448        // $WAITSTATUS / $? in the parent.
12449        crate::ported::builtin::SUBSHELL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
12450        // c:Src/exec.c — drain the signal queue against the now-
12451        // restored parent trap table. Pairs with the
12452        // queue_signals() call at the end of subshell_begin.
12453        // Any `kill $$` from inside the subshell is processed
12454        // here against OUTER's trap, matching C zsh's
12455        // signal-delivery-to-parent semantics. Bug #450.
12456        crate::ported::signals_h::unqueue_signals();
12457        // c:Src/exec.c — a `( … )` subshell is a FORK in C: an errflag
12458        // abort inside the child ends the child with its lastval as
12459        // the exit status, and the flag dies with the child process.
12460        // The parent's $? picks up the status and the parent's lists
12461        // keep running. zsh 5.9: `(readonly r=1; r=2); echo "after
12462        // $?"` prints `after 1`. zshrs runs the subshell in-process,
12463        // so mirror the fork isolation by clearing ERRFLAG_ERROR at
12464        // the subshell boundary — exec.last_status() already carries
12465        // the child's lastval (synced by ERREXIT_CHECK trigger 4).
12466        //
12467        // ERRFLAG_HARD must die at this boundary too: `${u:?msg}` sets
12468        // errflag |= ERRFLAG_HARD (c:Src/subst.c:3344) and then, in a
12469        // C forked subshell, `_exit(1)` (c:3353) — the parent never
12470        // sees ANY errflag bit. A leaked HARD bit here made every
12471        // subsequent zerr() take the silent arm (c:Src/utils.c:175-177
12472        // `if (errflag || noerrs) { errflag |= ERRFLAG_ERROR; return; }`),
12473        // so the next eval/source's parse silently "failed" and the
12474        // D04 harness shell wedged after chunk 10's
12475        // `(print ${unset1:?exiting1})`.
12476        crate::ported::utils::errflag.fetch_and(
12477            !(crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
12478            std::sync::atomic::Ordering::Relaxed,
12479        );
12480        let exit_pending =
12481            crate::ported::builtin::EXIT_PENDING.load(std::sync::atomic::Ordering::Relaxed);
12482        if exit_pending != 0 {
12483            // c:Src/builtin.c — `exit N` masks N to 8 bits because
12484            // POSIX _exit takes the low byte as status. `(exit 256)`
12485            // and `(exit 0)` are indistinguishable to the parent;
12486            // `(exit 257)` exits with 1. Without the mask zshrs's
12487            // in-process subshell propagated the full i32 (256) into
12488            // the parent's $?, diverging from zsh.
12489            let raw = crate::ported::builtin::EXIT_VAL.load(std::sync::atomic::Ordering::Relaxed);
12490            let val = raw & 0xFF;
12491            with_executor(|exec| exec.set_last_status(val));
12492            crate::ported::builtin::EXIT_PENDING.store(0, std::sync::atomic::Ordering::Relaxed);
12493            crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
12494            crate::ported::builtin::BREAKS.store(0, std::sync::atomic::Ordering::Relaxed);
12495            // Return the deferred-exit status so the VM updates its
12496            // own `last_status`. Otherwise run_chunk's post-script
12497            // `set_last_status(vm.last_status)` would clobber LASTVAL
12498            // back to the stale pre-subshell value.
12499            return Some(val);
12500        }
12501        None
12502    }
12503
12504    fn redirect(&mut self, fd: u8, op: u8, target: &str) {
12505        // Apply a redirection at the OS level for the next command/builtin.
12506        // The host tracks saved fds in a per-executor stack so a future
12507        // `with_redirects_end` can restore. For now, this is a thin wrapper
12508        // that performs the dup2; pairing with explicit save/restore is
12509        // delivered by `with_redirects_begin/end`.
12510        with_executor(|exec| exec.host_apply_redirect(fd, op, target));
12511    }
12512
12513    fn with_redirects_begin(&mut self, count: u8) {
12514        with_executor(|exec| exec.host_redirect_scope_begin(count));
12515    }
12516
12517    fn regex_match(&mut self, s: &str, regex: &str) -> bool {
12518        // c:Src/Modules/regex.c:54 `zcond_regex_match` — POSIX ERE
12519        // matching + populate `$MATCH` / `$MBEGIN` / `$MEND` /
12520        // `$match[]` / `$mbegin[]` / `$mend[]` (or `$BASH_REMATCH`
12521        // under BASHREMATCH). Direct delegation to the canonical
12522        // port at src/ported/modules/regex.rs:58.
12523        //
12524        // The bridge passthru path delivers TOKEN-form bytes here
12525        // (Inbrack \u{91}, Outbrack \u{92}, Star \u{87}, Quest
12526        // \u{86}, etc.) since the lexer tokenizes regex meta chars
12527        // inside `[[ ]]`. The host regex engine expects ASCII, so
12528        // untokenize the pattern (and subject, for safety) once at
12529        // this boundary. zsh C reaches its POSIX-ERE engine through
12530        // the same untokenize path inside zcond_regex_match.
12531        let s_clean = crate::lex::untokenize(s);
12532        let regex_clean = crate::lex::untokenize(regex);
12533        // c:Src/cond.c:113-119 — WHICH engine `=~` uses is an option:
12534        //
12535        //   char *modname = isset(REMATCHPCRE) ? "zsh/pcre" : "zsh/regex";
12536        //
12537        // and the two speak different languages (POSIX ERE vs PCRE), so the
12538        // option decides whether `\d` is a digit class or a literal `d`, and
12539        // whether `(?<name>…)` compiles at all. This dispatch was missing:
12540        // `=~` always used the regex module, so `setopt rematchpcre` silently
12541        // did nothing.
12542        if crate::ported::zsh_h::isset(crate::ported::zsh_h::REMATCHPCRE) {
12543            // c:115 — "zsh/pcre" → the `-pcre-match` cond.
12544            crate::ported::modules::pcre::cond_pcre_match(
12545                &[s_clean, regex_clean],
12546                crate::ported::modules::pcre::CPCRE_PLAIN,
12547            ) != 0
12548        } else {
12549            // c:115 — "zsh/regex" → the `-regex-match` cond.
12550            crate::ported::modules::regex::zcond_regex_match(
12551                &[s_clean.as_str(), regex_clean.as_str()],
12552                crate::ported::modules::regex::ZREGEX_EXTENDED,
12553            ) != 0
12554        }
12555    }
12556
12557    fn with_redirects_end(&mut self) {
12558        with_executor(|exec| exec.host_redirect_scope_end());
12559        // c:Src/exec.c:5172 — if any redirect in this scope failed
12560        // (noclobber-blocked, ENOENT for read, etc.), the command's
12561        // exit status is forced to 1 regardless of what the (still-
12562        // executed) command's own exit was. C zsh prevents the
12563        // command from running at all when a redirect fails; the
12564        // Rust port still runs it (sinking output to /dev/null in
12565        // the noclobber arm at host_apply_redirect:5481) and then
12566        // overrides $? here. Same observable effect for the common
12567        // pattern `echo x > existing-file` under noclobber.
12568        let failed = with_executor(|exec| {
12569            let f = exec.redirect_failed;
12570            exec.redirect_failed = false;
12571            f
12572        });
12573        if failed {
12574            with_executor(|exec| exec.set_last_status(1));
12575        }
12576    }
12577
12578    fn heredoc(&mut self, content: &str) {
12579        // C `Src/exec.c:4641` — `parsestr(&buf)` runs parameter +
12580        // command substitution on the heredoc body. The lexer's
12581        // quoted-delimiter detection (`<<'EOF'`) routes through the
12582        // `Op::HereDoc` path in `compile_zsh.rs` which short-circuits
12583        // before reaching here; unquoted forms route through the
12584        // BUILTIN_EXPAND_TEXT mode-4 emit path that calls singsub.
12585        // This handler covers the verbatim/quoted case.
12586        with_executor(|exec| exec.host_set_pending_stdin(content.to_string()));
12587    }
12588
12589    fn herestring(&mut self, content: &str) {
12590        // Shell semantics: herestring appends a newline. `<<<` body
12591        // substitution (`Src/exec.c:4655 getherestr` calls
12592        // `quotesubst` + `untokenize`) lands here verbatim; the
12593        // upstream compiler routes through `Op::HereString` after
12594        // BUILTIN_EXPAND_TEXT for the substitution pass, so callers
12595        // of `host.herestring` see the already-expanded form.
12596        let mut s = content.to_string();
12597        s.push('\n');
12598        with_executor(|exec| exec.host_set_pending_stdin(s));
12599    }
12600
12601    fn exec(&mut self, args: Vec<String>) -> i32 {
12602        // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close
12603        // any `>(cmd)` write ends owned by this command once it
12604        // finishes (drops on every return path below).
12605        let _psub_fds = PsubFdGuard;
12606        // c:Src/subst.c paramsubst — when `${var:?msg}` or `${var?msg}`
12607        // triggered the "parameter null or not set" error, errflag
12608        // is raised and zsh aborts the simple command without
12609        // attempting exec. The expansion may have produced empty
12610        // argv[0] which falls into the c:?/permission-denied path
12611        // below, masking the real diagnostic with a spurious
12612        // "permission denied:" line and rc=126 instead of rc=1.
12613        // Honour errflag here so the script ends with the
12614        // paramsubst error as the sole diagnostic. Bug #86.
12615        //
12616        // c:Src/exec.c — C's execlist loop clears ERRFLAG_ERROR
12617        // between sublists when the error came from a NOMATCH-style
12618        // command failure (glob no-match, etc.) so subsequent
12619        // sublists run. zshrs's vm dispatch handles this at the
12620        // post-command-boundary HERE: if THIS command has its
12621        // `current_command_glob_failed` cell set (meaning the glob
12622        // NOMATCH happened during this command's argv prep), surface
12623        // status 1 and clear BOTH the cell AND ERRFLAG_ERROR so the
12624        // NEXT exec call sees a clean state. The errflag from
12625        // genuine script-fatal errors (parse, redirect, paramsubst
12626        // `${:?msg}`) does NOT come paired with glob_failed, so
12627        // those still short-circuit + propagate.
12628        consume_tilde_globsubst_carrier();
12629        let glob_failed = with_executor(|exec| {
12630            let f = exec.current_command_glob_failed.get();
12631            exec.current_command_glob_failed.set(false);
12632            f
12633        });
12634        if glob_failed {
12635            crate::ported::utils::errflag.fetch_and(
12636                !crate::ported::zsh_h::ERRFLAG_ERROR,
12637                std::sync::atomic::Ordering::Relaxed,
12638            );
12639            with_executor(|exec| exec.set_last_status(1));
12640            return 1;
12641        }
12642        // c:Src/subst.c:505-507 — CSH_NULL_GLOB external-path
12643        // boundary: command skipped with `no match` but the NEXT
12644        // sublist runs (zsh -fc 'setopt cshnullglob; ls *nope*;
12645        // print after' prints the error then `after` — verified
12646        // zsh 5.9.1), so clear ERRFLAG like the glob_failed arm.
12647        if consume_badcshglob() {
12648            crate::ported::utils::errflag.fetch_and(
12649                !crate::ported::zsh_h::ERRFLAG_ERROR,
12650                std::sync::atomic::Ordering::Relaxed,
12651            );
12652            with_executor(|exec| exec.set_last_status(1));
12653            return 1;
12654        }
12655        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
12656            & crate::ported::zsh_h::ERRFLAG_ERROR)
12657            != 0
12658        {
12659            return 1;
12660        }
12661        // c:Src/exec.c — two distinct empty-command cases:
12662        //
12663        // 1. args=[""]  — an explicit empty-string command word
12664        //    (`""`, `"\$unset"`, `\$'\$x'`). zsh attempts exec(2)
12665        //    on the empty path → EACCES → "permission denied", \$?
12666        //    = 126.
12667        //
12668        // 2. args=[]    — the WORD LIST is empty (unquoted \$(\$cmd)
12669        //    that produced empty, or an unquoted unset \$var that
12670        //    elided). zsh: no exec is attempted; \$? becomes the
12671        //    last cmd-subst's exit status (the inner sub-VM
12672        //    already set last_status), and the line completes
12673        //    silently. Critically NOT 126.
12674        if args.is_empty() {
12675            // c:Src/exec.c — empty word list passes through to a
12676            // no-op; preserve whatever the inner cmd-subst's exit
12677            // is. Return last_status so the caller's SetStatus
12678            // round-trips correctly.
12679            return with_executor(|exec| exec.last_status());
12680        }
12681        if args[0].is_empty() {
12682            let script_name =
12683                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
12684            let lineno: u64 = with_executor(|exec| {
12685                exec.scalar("LINENO")
12686                    .and_then(|s| s.parse::<u64>().ok())
12687                    .unwrap_or(1)
12688            });
12689            eprintln!("{}:{}: permission denied: ", script_name, lineno);
12690            return 126;
12691        }
12692        // c:Src/exec.c — when any redirect in the current scope
12693        // failed (e.g. noclobber blocked a `>` overwrite), zsh
12694        // refuses to execute the command and exits with status 1.
12695        // The Rust port still applied the command (writing to the
12696        // /dev/null sink installed by host_apply_redirect's
12697        // noclobber arm), but the success status overwrote the
12698        // intended `1`. Short-circuit here so the exec returns 1
12699        // without running the body.
12700        let redir_failed = with_executor(|exec| {
12701            let f = exec.redirect_failed;
12702            exec.redirect_failed = false;
12703            f
12704        });
12705        if redir_failed {
12706            return 1;
12707        }
12708        // Track `$_` as the last argument of the last command (zsh /
12709        // bash convention). Empty arglists leave it untouched.
12710        if let Some(last) = args.last() {
12711            with_executor(|exec| {
12712                exec.set_scalar("_".to_string(), last.clone());
12713            });
12714        }
12715        // Route external command spawning through `executor.execute_external`
12716        // so intercepts (AOP before/after/around), command_hash lookups,
12717        // pre/postexec hooks, and zsh-specific fork-then-exec all apply.
12718        // Without this override, fusevm's default `host.exec` calls
12719        // `Command::new` directly, bypassing zshrs's dispatch logic.
12720        let status = with_executor(|exec| exec.host_exec_external(&args));
12721        // c:Src/jobs.c:1748 waitonejob (no-procs else-branch). zshrs's
12722        // exec model routes external commands through host_exec_external
12723        // (which already waitpid'd in-line); the canonical waitonejob
12724        // expects a Job to derive lastval, but here we already know
12725        // it. Synthesize a procs-less job so waitonejob's no-procs
12726        // branch fires the `pipestats[0]=lastval; numpipestats=1;`
12727        // update via the canonical port.
12728        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
12729        let mut synth = crate::ported::zsh_h::job::default();
12730        crate::ported::jobs::waitonejob(&mut synth);
12731        status
12732    }
12733
12734    fn cmd_subst(&mut self, sub: &fusevm::Chunk) -> String {
12735        // Run the sub-chunk on a nested VM with the same host wired up,
12736        // capturing stdout. The current executor remains active via the
12737        // thread-local — the nested VM uses CallBuiltin to dispatch shell
12738        // ops back through `with_executor`.
12739        let (read_end, write_end) = match os_pipe::pipe() {
12740            Ok(p) => p,
12741            Err(_) => return String::new(),
12742        };
12743        let saved_stdout = unsafe { libc::fcntl(libc::STDOUT_FILENO, libc::F_DUPFD, 10) };
12744        if saved_stdout < 0 {
12745            return String::new();
12746        }
12747        let saved_stderr = unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD, 10) };
12748        let write_fd = AsRawFd::as_raw_fd(&write_end);
12749        unsafe {
12750            libc::dup2(write_fd, libc::STDOUT_FILENO);
12751        }
12752        drop(write_end);
12753
12754        // c:Bug #56 — publish the saved outer fds so a trap firing
12755        // during the nested VM run can route its body output to the
12756        // PARENT's stdout instead of the cmdsub's pipe-bound fd 1.
12757        // zsh's forked cmdsub gets this for free (trap runs in the
12758        // parent process whose fd 1 is untouched). zshrs's
12759        // in-process cmdsub needs this thread-local stack so the
12760        // trap dispatcher can find the right destination fd.
12761        CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().push((saved_stdout, saved_stderr)));
12762
12763        // Nested scope for `>(cmd)` fd ownership — commands inside
12764        // the cmdsub must not drain the enclosing command's pending
12765        // psub fds (see PSUB_SCOPE_DEPTH).
12766        let _psub_scope = PsubScope::enter();
12767
12768        // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
12769        // which does `zsh_subshell++`; in-process equivalent.
12770        let _subshell_bump = CmdSubstSubshellBump::enter();
12771
12772        crate::fusevm_disasm::maybe_print_stdout("host.cmd_subst", sub);
12773        let mut vm = fusevm::VM::new(sub.clone());
12774        register_builtins(&mut vm);
12775        vm.set_shell_host(Box::new(ZshrsHost));
12776        let _ = vm.run();
12777        let cmd_status = vm.last_status;
12778
12779        CMDSUBST_OUTER_FDS.with(|s| {
12780            s.borrow_mut().pop();
12781        });
12782
12783        unsafe {
12784            libc::dup2(saved_stdout, libc::STDOUT_FILENO);
12785            libc::close(saved_stdout);
12786            if saved_stderr >= 0 {
12787                libc::close(saved_stderr);
12788            }
12789        }
12790
12791        // Inner cmd's status not propagated for the same reason as
12792        // run_command_substitution — see GAPS.md.
12793        let _ = cmd_status;
12794
12795        let mut buf = String::new();
12796        let mut reader = read_end;
12797        let _ = reader.read_to_string(&mut buf);
12798        // Strip trailing newlines (POSIX command substitution semantics)
12799        while buf.ends_with('\n') {
12800            buf.pop();
12801        }
12802        buf
12803    }
12804
12805    fn call_function(&mut self, name: &str, args: Vec<String>) -> Option<i32> {
12806        // c:Src/exec.c — when the command word is empty (e.g. `""`
12807        // or `"$nonexistent"`), zsh attempts the exec(2) which
12808        // returns EACCES ("permission denied") and exits 126. The
12809        // Rust port silently treated empty as a no-op (status 0).
12810        // Match zsh by emitting the diagnostic and returning 126.
12811        if name.is_empty() {
12812            let script_name =
12813                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
12814            let lineno: u64 = with_executor(|exec| {
12815                exec.scalar("LINENO")
12816                    .and_then(|s| s.parse::<u64>().ok())
12817                    .unwrap_or(1)
12818            });
12819            eprintln!("{}:{}: permission denied: ", script_name, lineno);
12820            with_executor(|exec| exec.set_last_status(126));
12821            return Some(126);
12822        }
12823        // c:Src/exec.c — redirect failure in this scope means the
12824        // command should NOT run. The Host::exec path already has
12825        // this gate (at fn exec above); call_function takes external
12826        // commands like `cat <&3` through a different code path, so
12827        // gate here too. Without this, bad-fd redirects produced
12828        // the diagnostic but the external command still ran, so $?
12829        // came out from the command's natural exit instead of the
12830        // forced 1.
12831        let redir_failed = with_executor(|exec| {
12832            let f = exec.redirect_failed;
12833            exec.redirect_failed = false;
12834            f
12835        });
12836        if redir_failed {
12837            with_executor(|exec| exec.set_last_status(1));
12838            return Some(1);
12839        }
12840        // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
12841        // functions, NOT builtins. zshrs ships fast native impls, but they
12842        // must behave like the zsh functions — command-not-found until
12843        // `autoload -Uz <name>` creates a function entry. When autoloaded we
12844        // run the native impl here (short-circuiting the fpath source, which
12845        // can hang zshrs's parser on zsh-specific syntax); when NOT autoloaded
12846        // we fall through (return None → resolution ends in command-not-found),
12847        // matching `zsh -f; zmv` → "command not found: zmv".
12848        if matches!(name, "zmv" | "zcp" | "zln" | "zcalc")
12849            && !with_executor(|exec| exec.function_exists(name))
12850        {
12851            return None;
12852        }
12853        match name {
12854            "zmv" => {
12855                return Some(crate::extensions::ext_builtins::zmv(&args, "mv"));
12856            }
12857            "zcp" => {
12858                return Some(crate::extensions::ext_builtins::zmv(&args, "cp"));
12859            }
12860            "zln" => {
12861                return Some(crate::extensions::ext_builtins::zmv(&args, "ln"));
12862            }
12863            "zcalc" => {
12864                return Some(crate::extensions::ext_builtins::zcalc(&args));
12865            }
12866            // znative — the plugin package manager (src/extensions/pkg/). Installs
12867            // + loads zsh script and native (Rust cdylib) plugins from a global
12868            // content-addressed store. `znative add owner/repo`, `znative load`, ...
12869            "znative" => {
12870                return Some(crate::extensions::pkg::builtin::znative(&args));
12871            }
12872            // ztest framework (src/extensions/ztest.rs — port of
12873            // ../strykelang's unit-test framework). All zassert_*/
12874            // ztest_* names route through the single try_dispatch
12875            // helper so adding/removing assertions only touches
12876            // ztest.rs.
12877            n if crate::extensions::ztest::try_dispatch_known(n) => {
12878                let status = with_executor(|exec| {
12879                    crate::extensions::ztest::try_dispatch(exec, n, &args).unwrap_or(1)
12880                });
12881                return Some(status);
12882            }
12883            // Daemon-managed z* builtins — thin IPC wrappers. Short-circuit BEFORE
12884            // the function-lookup path so a missing daemon doesn't fall through to
12885            // "command not found". The name list is owned by the daemon crate
12886            // (zshrs_daemon::builtins::ZSHRS_BUILTIN_NAMES); routing through
12887            // try_dispatch keeps this site zero-touch as new z* builtins land.
12888            n if crate::daemon::builtins::is_zshrs_builtin(n) => {
12889                let argv: Vec<String> = std::iter::once(name.to_string()).chain(args).collect();
12890                return Some(crate::daemon::builtins::try_dispatch(n, &argv).unwrap_or(1));
12891            }
12892            _ => {}
12893        }
12894
12895        // c:Src/exec.c:3050-3068 — module-provided builtins (registered
12896        // via each module's `bintab` and folded into the canonical
12897        // `builtintab` by `createbuiltintable`) must dispatch BEFORE
12898        // PATH lookup. fusevm's `shell_builtins::builtin_id` doesn't
12899        // know about per-module entries like `log`
12900        // (Src/Modules/watch.c:693) — they reach call_function as
12901        // CallFunction ops. Consult the merged builtintab here so
12902        // `log` runs the canonical `bin_log` instead of falling
12903        // through to `/usr/bin/log` on macOS. Bug #72 in docs/BUGS.md.
12904        //
12905        // User-defined functions still take precedence over builtins
12906        // (zsh's `alias → function → builtin → external` resolution
12907        // order, c:Src/exec.c:3038-3068). Check `functions_compiled`
12908        // first so a user `log() { ... }` shadows the module bin_log.
12909        // c:Src/exec.c — shfunctab->getnode (the DISABLED-filtering
12910        // accessor) returns NULL for entries flipped to DISABLED via
12911        // `disable -f NAME`. functions_compiled holds the body
12912        // independently of the DISABLED flag, so check shfunctab first
12913        // and mask the lookup when the entry is disabled. Bug #221
12914        // in docs/BUGS.md.
12915        let user_fn_disabled = crate::ported::hashtable::shfunctab_lock()
12916            .read()
12917            .ok()
12918            .and_then(|t| {
12919                let entry = t.get_including_disabled(name)?;
12920                Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
12921            })
12922            .unwrap_or(false);
12923        let has_user_fn =
12924            !user_fn_disabled && with_executor(|exec| exec.functions_compiled.contains_key(name));
12925        if !has_user_fn {
12926            // c:Src/exec.c:3056 — `builtintab->getnode(builtintab,
12927            // cmdarg)` returns NULL for DISABLED entries, falling
12928            // execcmd through to PATH lookup. Mirror by gating the
12929            // bn_in_tab match on the BUILTINS_DISABLED set. Bug #106
12930            // in docs/BUGS.md.
12931            let disabled = crate::ported::builtin::BUILTINS_DISABLED
12932                .lock()
12933                .map(|s| s.contains(name))
12934                .unwrap_or(false);
12935            let bn_in_tab =
12936                !disabled && crate::ported::builtin::createbuiltintable().contains_key(name);
12937            if bn_in_tab {
12938                return Some(dispatch_builtin_raw(name, args));
12939            }
12940            // zshrs-original opcode builtins (async, doctor, peach, …) are not
12941            // in builtintab, so a run-time-resolved name (`$var`) never reaches
12942            // them. Dispatch by name here — after ported builtins, before
12943            // external — matching the shell's function -> builtin -> external
12944            // order (`has_user_fn` was checked above, so functions still win).
12945            if let Some(status) = try_run_registered_builtin(name, &args) {
12946                return Some(status);
12947            }
12948        }
12949
12950        // c:Src/lex.c — alias expansion is a LEXER-TIME pass, not a
12951        // run-time lookup. zsh parses the whole `-c` argument (or
12952        // script) before executing, so aliases defined in the same
12953        // parse unit don't apply to commands parsed earlier. Only at
12954        // an INTERACTIVE prompt does each line parse separately with
12955        // the latest aliastab visible.
12956        //
12957        // Gate the run-time alias-rewrite path on `interactive` so
12958        // `alias hi='echo hello'; hi` in `-c` mode falls through to
12959        // "command not found" (matching zsh) while interactive REPL
12960        // input still re-parses with the live aliastab.
12961        let interactive = crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE);
12962        let already_expanding = if interactive {
12963            crate::ported::hashtable::aliastab_lock()
12964                .read()
12965                .ok()
12966                .and_then(|tab| tab.get(name).map(|a| a.inuse != 0))
12967                .unwrap_or(false)
12968        } else {
12969            true // suppress lookup entirely in non-interactive mode
12970        };
12971        let alias_body = if already_expanding {
12972            None
12973        } else {
12974            with_executor(|exec| exec.alias(name))
12975        };
12976        if let Some(body) = alias_body {
12977            let combined = if args.is_empty() {
12978                body
12979            } else {
12980                let quoted: Vec<String> = args
12981                    .iter()
12982                    .map(|a| {
12983                        let escaped = a.replace('\'', "'\\''");
12984                        format!("'{}'", escaped)
12985                    })
12986                    .collect();
12987                format!("{} {}", body, quoted.join(" "))
12988            };
12989            // Bump inuse → run → clear, matching C's lexer behavior.
12990            if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
12991                if let Some(a) = tab.get_mut(name) {
12992                    a.inuse += 1;
12993                }
12994            }
12995            let status = with_executor(|exec| exec.execute_script(&combined).unwrap_or(1));
12996            if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
12997                if let Some(a) = tab.get_mut(name) {
12998                    a.inuse = (a.inuse - 1).max(0);
12999                }
13000            }
13001            return Some(status);
13002        }
13003
13004        // $_ pre-body bump and pending-underscore tracking are
13005        // ZshrsHost-only concerns (prompt rendering). Apply BEFORE
13006        // delegating to dispatch_function_call so the body sees the
13007        // bumped value.
13008        //
13009        // c:Src/exec.c:3491 — `setunderscore((args && nonempty(args))
13010        // ? ((char *) getdata(lastnode(args))) : "")`. C sets $_ to
13011        // the LAST node of the WHOLE args list (which includes argv[0]
13012        // == the function name). So for a no-arg `f`, $_ becomes "f"
13013        // inside the function body. The Rust port at the CallFunction
13014        // op-handler receives `args` WITHOUT the command name
13015        // (compile_zsh.rs:1571 only pushes simple.words[1..]). The
13016        // last() fallback `|| fn_name.clone()` already covers the
13017        // no-arg case, but `exec.set_scalar("_", ...)` writes paramtab
13018        // — the canonical `$_` read goes through `underscoregetfn`
13019        // (params.rs:7836) which reads the `zunderscore` Mutex.
13020        // setsparam("_") doesn't update that mutex, so the body's
13021        // `${_}` returned empty. Bug #279 in docs/BUGS.md. Mirror the
13022        // C `setunderscore` by writing via `set_zunderscore` directly.
13023        let fn_name = name.to_string();
13024        with_executor(|exec| {
13025            let dollar_underscore = args.last().cloned().unwrap_or_else(|| fn_name.clone());
13026            exec.set_scalar("_".to_string(), dollar_underscore.clone());
13027            crate::ported::params::set_zunderscore(std::slice::from_ref(&dollar_underscore));
13028            exec.pending_underscore = Some(dollar_underscore);
13029        });
13030
13031        // Delegate the actual function dispatch to the canonical
13032        // `dispatch_function_call` (which itself wraps the canonical
13033        // `doshfunc` port from `Src/exec.c:5823`). Single doshfunc
13034        // call-site keeps scope-mgmt invariants in one place.
13035        let status = with_executor(|exec| exec.dispatch_function_call(&fn_name, &args));
13036
13037        // Anonymous functions (`() { … } args`, compiled by
13038        // parse_anon_funcdef as `_zshrs_anon_N` / `_zshrs_anon_kw_N`)
13039        // execute exactly ONCE and must not persist. zsh runs the body and
13040        // frees the function, so `${functions}` / `typeset -f` never show
13041        // it. Remove every trace right after the single invocation —
13042        // AFTER `status` is captured, so the body's exit code is preserved
13043        // ($? — calling `unfunction` here would reset it to 0 instead).
13044        // Without this, real plugins that use `() { … }` (fzf-tab, zinit,
13045        // p10k, …) leaked dozens of `_zshrs_anon_N` into `$functions`,
13046        // diverging from zsh's function table on every such config.
13047        if fn_name.starts_with("_zshrs_anon_") {
13048            // `${functions}` / `typeset -f` enumerate the canonical
13049            // `shfunctab` (via scanpmfunctions); the bytecode call path
13050            // also keeps the body in the executor's compiled-fn maps. Clear
13051            // BOTH so no trace of the one-shot anon survives.
13052            crate::ported::hashtable::removeshfuncnode(&fn_name);
13053            with_executor(|exec| {
13054                exec.functions_compiled.remove(&fn_name);
13055                exec.function_source.remove(&fn_name);
13056                exec.function_line_base.remove(&fn_name);
13057                exec.function_def_file.remove(&fn_name);
13058            });
13059        }
13060
13061        // $_ post-body — last call-arg or function name. Mirrors the
13062        // C `setunderscore` invocation after the body returns.
13063        with_executor(|exec| {
13064            let last_call_arg = args.last().cloned().unwrap_or_else(|| fn_name.clone());
13065            exec.set_scalar("_".to_string(), last_call_arg.clone());
13066            exec.pending_underscore = Some(last_call_arg);
13067        });
13068
13069        status
13070    }
13071}
13072
13073// ───────────────────────────────────────────────────────────────────────────
13074/// Render a failed-redirect open error the way C's `zerrmsg` `%e` format
13075/// code does (Src/utils.c): `strerror(errno)` with the first character
13076/// lowercased, except `EIO` (kept capitalized) and `EINTR` (→ "interrupt").
13077/// C's redirect open failures call `zwarn("%e: %s", errno, fname)`
13078/// (Src/exec.c:3741); zshrs's `zwarning` takes a pre-built string, so the
13079/// `%e` part is built here. Replaces the prior hardcoded `ErrorKind` match
13080/// that fell back to a generic "redirect failed" for `EROFS`/`EACCES`/etc.
13081fn redir_errno_msg(err: &std::io::Error) -> String {
13082    let errno = match err.raw_os_error() {
13083        Some(n) if n != 0 => n,
13084        _ => return "redirect failed".to_string(),
13085    };
13086    if errno == libc::EINTR {
13087        return "interrupt".to_string(); // c:zerrmsg %e — EINTR special-case
13088    }
13089    let cptr = unsafe { libc::strerror(errno) };
13090    if cptr.is_null() {
13091        return "redirect failed".to_string();
13092    }
13093    let msg = unsafe { std::ffi::CStr::from_ptr(cptr) }.to_string_lossy();
13094    if errno == libc::EIO {
13095        return msg.into_owned(); // c:zerrmsg %e — EIO keeps capitalization
13096    }
13097    // c:zerrmsg %e — `fputc(tulower(errmsg[0])); fputs(errmsg + 1)`.
13098    let mut chars = msg.chars();
13099    match chars.next() {
13100        Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
13101        None => "redirect failed".to_string(),
13102    }
13103}
13104
13105// Host-routed shell ops: ShellExecutor methods invoked by ZshrsHost from the
13106// fusevm VM. Not a port of Src/exec.c (see file-level docs above) — they're
13107// the bridge between fusevm opcodes and ShellExecutor state.
13108// ───────────────────────────────────────────────────────────────────────────
13109impl ShellExecutor {
13110    // ─── Host-routed shell ops (called by ZshrsHost from fusevm) ────────────
13111
13112    /// Apply a single redirection. The current scope's saved-fd vec gets a
13113    /// dup of the original fd so it can be restored by `host_redirect_scope_end`.
13114    /// `op_byte` matches `fusevm::op::redirect_op::*`.
13115    /// Apply a file-open result to a redirect fd; on error, emit
13116    /// zsh-format diagnostic, set redirect_failed, sink fd to /dev/null.
13117    /// Shared between WRITE/APPEND/READ/CLOBBER arms in
13118    /// host_apply_redirect to keep the error-handling identical.
13119    fn redir_open_or_fail(
13120        fd: i32,
13121        result: std::io::Result<fs::File>,
13122        target: &str,
13123        redirect_failed: &mut bool,
13124    ) -> bool {
13125        match result {
13126            Ok(file) => {
13127                let new_fd = file.into_raw_fd();
13128                unsafe {
13129                    // When the target fd was already closed (e.g. `exec 0<&-;
13130                    // cmd < file`), open() returns the lowest free fd, which is
13131                    // `fd` itself. Then `dup2(fd, fd)` is a no-op: closing new_fd
13132                    // would CLOSE the fd we just opened, AND — since Rust's
13133                    // File::open sets O_CLOEXEC and a no-op dup2 does NOT clear
13134                    // it — an exec'd child would lose the descriptor. So in the
13135                    // reuse case, keep the fd and clear its close-on-exec flag;
13136                    // otherwise dup2 (which clears cloexec on the copy) + close.
13137                    if new_fd != fd {
13138                        libc::dup2(new_fd, fd);
13139                        libc::close(new_fd);
13140                    } else {
13141                        libc::fcntl(fd, libc::F_SETFD, 0);
13142                    }
13143                }
13144                true
13145            }
13146            Err(e) => {
13147                // c:Src/exec.c:3741 — zwarn("%e: %s", errno, fname) with the
13148                // real lineno prefix; redir_errno_msg builds the `%e` errno
13149                // message for all errnos (not just the few hardcoded before).
13150                let msg = redir_errno_msg(&e);
13151                crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
13152                *redirect_failed = true;
13153                // The /dev/null sink keeps a failed scoped redirect
13154                // from leaking the aborted command's output to the
13155                // wrong fd until scope-end restores it. For a bare
13156                // `exec` redirect (permanent, no scope restore) C
13157                // leaves the fd UNTOUCHED — execerr() aborts the
13158                // statement and the original fd 1 keeps flowing
13159                // (A04redirect: `exec >./nonexistent/x` then `echo
13160                // output` still prints). c:Src/exec.c:3735-3742.
13161                let permanent = with_executor(|exec| exec.exec_redirs_permanent);
13162                if !permanent {
13163                    if let Ok(devnull) = fs::OpenOptions::new()
13164                        .read(true)
13165                        .write(true)
13166                        .open("/dev/null")
13167                    {
13168                        let new_fd = devnull.into_raw_fd();
13169                        unsafe {
13170                            if new_fd != fd {
13171                                libc::dup2(new_fd, fd);
13172                                libc::close(new_fd);
13173                            } else {
13174                                libc::fcntl(fd, libc::F_SETFD, 0);
13175                            }
13176                        }
13177                    }
13178                }
13179                false
13180            }
13181        }
13182    }
13183    /// `host_apply_redirect` — see implementation.
13184    pub fn host_apply_redirect(&mut self, fd: u8, op_byte: u8, target: &str) {
13185        // `&>` / `&>>` always target both fd 1 and fd 2 regardless of the
13186        // fd byte the parser supplied (the lexer's tokfd clamp makes the
13187        // raw value unreliable for these forms).
13188        let fd: i32 = if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
13189            1
13190        } else {
13191            fd as i32
13192        };
13193        // c:Src/exec.c — for DUP_READ / DUP_WRITE forms (<&N / >&N),
13194        // validate the source fd is open BEFORE the save-and-dup
13195        // dance below. The save's `dup(fd)` reclaims the lowest free
13196        // fd, which on closed-fd reuse would let dup2(src=N, …)
13197        // succeed against the freshly-claimed slot — masking the
13198        // user's "bad file descriptor" error. Check src_fd first.
13199        if matches!(op_byte, r::DUP_READ | r::DUP_WRITE) {
13200            let n_check = target.trim_start_matches('&');
13201            if n_check != "-" {
13202                if let Ok(src_fd) = n_check.parse::<i32>() {
13203                    if unsafe { libc::fcntl(src_fd, libc::F_GETFD) } == -1 {
13204                        // c:Src/exec.c — zwarn with real lineno prefix.
13205                        crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src_fd));
13206                        self.set_last_status(1);
13207                        self.redirect_failed = true;
13208                        return;
13209                    }
13210                }
13211            }
13212        }
13213        // c:Src/exec.c:3978-3986 — bare `exec` redirects (nullexec==1)
13214        // skip the save entirely: "we specifically *don't* restore the
13215        // original fd's". C's save[] is per-execcmd, so exec's redirs
13216        // never enter an enclosing group's save list either; pushing
13217        // into `redirect_scope_stack.last_mut()` here (the enclosing
13218        // group's scope) made `{ exec 1>&-; … } 2>/dev/null` restore
13219        // stdout at group end — diverging from zsh, which keeps fd 1
13220        // closed for the rest of the script.
13221        if !self.exec_redirs_permanent {
13222            let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
13223            if saved >= 0 {
13224                if let Some(top) = self.redirect_scope_stack.last_mut() {
13225                    top.push((fd, saved));
13226                } else {
13227                    // No scope — leave saved fd open and let the next scope
13228                    // reclaim it. (Caller without a scope leaks the dup; this
13229                    // matches `WithRedirects` parser construction always wrapping.)
13230                    unsafe { libc::close(saved) };
13231                }
13232            }
13233            // For `&>` / `&>>` also save fd 2 so the scope restores it after
13234            // the body. Otherwise stderr stays redirected past the command.
13235            if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
13236                let saved2 = unsafe { libc::fcntl(2, libc::F_DUPFD, 10) };
13237                if saved2 >= 0 {
13238                    if let Some(top) = self.redirect_scope_stack.last_mut() {
13239                        top.push((2, saved2));
13240                    } else {
13241                        unsafe { libc::close(saved2) };
13242                    }
13243                }
13244            }
13245        }
13246        // c:Src/exec.c:3722-3724 + 2447-2480 — MULTIOS split when this
13247        // command's stdout IS the pipeline output. C registers the pipe
13248        // in mfds[1] (`addfd(forked, save, mfds, 1, output, 1, NULL)`)
13249        // BEFORE walking the explicit redirect list, so a write-side
13250        // redirect of fd 1 finds mfds[1] occupied and, with MULTIOS
13251        // set, "split[s] the stream": fd 1 becomes the write end of an
13252        // internal pipe whose reader tees every chunk to BOTH the
13253        // pipeline pipe and the new target. That is why
13254        // `{ echo a; echo b >&2; } 3>&1 1>&2 2>&3 3>&- | cat` sends
13255        // `a` to the pipe (via the tee) AND to stderr — plain dup2
13256        // replacement loses the pipe stream. The scope-depth gate
13257        // mirrors mfds being per-execcmd: only the redirect list
13258        // attached to the stage's own command joins the pipe; nested
13259        // commands inside the body (`{ echo a > f; } | cat`) get a
13260        // fresh "mfds" and replace as usual.
13261        if fd == 1
13262            && self
13263                .pipe_output_scope
13264                .is_some_and(|d| d + 1 == self.redirect_scope_stack.len())
13265            && crate::ported::options::opt_state_get("multios").unwrap_or(true)
13266        {
13267            // Resolve the new write target exactly as the plain arms
13268            // below would, but as a raw fd for the tee.
13269            let new_target_fd: i32 = match op_byte {
13270                r::DUP_WRITE => {
13271                    // Numeric `>&N` only; `-` (close) and `p` (coproc)
13272                    // fall through to the plain arms.
13273                    target
13274                        .trim_start_matches('&')
13275                        .parse::<i32>()
13276                        .map(|src| unsafe { libc::fcntl(src, libc::F_DUPFD, 10) })
13277                        .unwrap_or(-1)
13278                }
13279                r::WRITE | r::CLOBBER => fs::File::create(target)
13280                    .map(|f| f.into_raw_fd())
13281                    .unwrap_or(-1),
13282                r::APPEND => fs::OpenOptions::new()
13283                    .create(true)
13284                    .append(true)
13285                    .open(target)
13286                    .map(|f| f.into_raw_fd())
13287                    .unwrap_or(-1),
13288                _ => -1,
13289            };
13290            if new_target_fd >= 0 {
13291                let pipe_dup = unsafe { libc::fcntl(1, libc::F_DUPFD, 10) };
13292                match (pipe_dup >= 0).then(os_pipe::pipe) {
13293                    Some(Ok((read_end, write_end))) => {
13294                        // Splitter: same read-loop shape as
13295                        // BUILTIN_MULTIOS_REDIRECT, with one ordering
13296                        // refinement. C's tee is a forked process
13297                        // (closemn → teeproc) whose wakeup latency lets
13298                        // the stage's DIRECT pipe writes land first —
13299                        // observed zsh output for `{ echo a; echo b >&2; }
13300                        // 3>&1 1>&2 2>&3 3>&- | cat` is `b` then `a`,
13301                        // 15/15 runs. A Rust thread wakes faster than
13302                        // the debug-build VM dispatches the next echo,
13303                        // inverting the order. Emulate the C timing
13304                        // observably: stream to the NEW target (file /
13305                        // stderr dup) immediately, but defer the
13306                        // pipe-bound copy until EOF (or a 64KB cap so a
13307                        // long-running stream still flows instead of
13308                        // growing memory unboundedly).
13309                        let write_now = |tfd: i32, data: &[u8]| {
13310                            let mut off = 0;
13311                            while off < data.len() {
13312                                let w = unsafe {
13313                                    libc::write(
13314                                        tfd,
13315                                        data[off..].as_ptr() as *const libc::c_void,
13316                                        data.len() - off,
13317                                    )
13318                                };
13319                                if w <= 0 {
13320                                    break;
13321                                }
13322                                off += w as usize;
13323                            }
13324                        };
13325                        let handle = std::thread::spawn(move || {
13326                            let mut rd = read_end;
13327                            let mut buf = [0u8; 8192];
13328                            let mut pipe_pending: Vec<u8> = Vec::new();
13329                            loop {
13330                                match std::io::Read::read(&mut rd, &mut buf) {
13331                                    Ok(0) | Err(_) => break,
13332                                    Ok(n) => {
13333                                        write_now(new_target_fd, &buf[..n]);
13334                                        pipe_pending.extend_from_slice(&buf[..n]);
13335                                        if pipe_pending.len() >= 65536 {
13336                                            write_now(pipe_dup, &pipe_pending);
13337                                            pipe_pending.clear();
13338                                        }
13339                                    }
13340                                }
13341                            }
13342                            write_now(pipe_dup, &pipe_pending);
13343                            unsafe {
13344                                libc::close(pipe_dup);
13345                                libc::close(new_target_fd);
13346                            }
13347                        });
13348                        let write_raw = AsRawFd::as_raw_fd(&write_end);
13349                        unsafe { libc::dup2(write_raw, 1) };
13350                        drop(write_end);
13351                        // Scope-end closes this dup (the last writer once
13352                        // the saved fd 1 is restored) → EOF → join.
13353                        let close_on_end = unsafe { libc::fcntl(1, libc::F_DUPFD, 10) };
13354                        if let Some(top) = self.multios_scope_stack.last_mut() {
13355                            top.push((close_on_end, handle));
13356                        } else {
13357                            unsafe { libc::close(close_on_end) };
13358                            let _ = handle.join();
13359                        }
13360                        return;
13361                    }
13362                    _ => unsafe {
13363                        // pipe()/dup failure — fall through to plain replace.
13364                        if pipe_dup >= 0 {
13365                            libc::close(pipe_dup);
13366                        }
13367                        libc::close(new_target_fd);
13368                    },
13369                }
13370            }
13371        }
13372        match op_byte {
13373            r::WRITE => {
13374                // Honor `setopt noclobber`: refuse to overwrite an
13375                // existing regular file unless `>!` / `>|` (CLOBBER).
13376                // zsh internally stores the inverted-name `clobber`
13377                // (default ON); `setopt noclobber` writes
13378                // `clobber=false`. Honor both keys.
13379                //
13380                // c:Src/exec.c:2241-2245 clobber_open recover path:
13381                // after O_EXCL fails, reopen and `if (!S_ISREG(...))
13382                // return fd;` — non-regular targets (char/block-
13383                // special, FIFO, socket) bypass the noclobber check.
13384                // Bug #30 in docs/BUGS.md: this bridge-side check did
13385                // a bare `Path::exists()` and treated `/dev/null` as
13386                // a protected file, breaking `setopt no_clobber; echo
13387                // hi > /dev/null` and every `2> /dev/null` idiom.
13388                // Add a regular-file stat gate that matches the C
13389                // semantic. The canonical clobber_open at
13390                // src/ported/exec.rs:2123 already handles this; the
13391                // bridge duplicates a stripped-down version here and
13392                // must mirror the same check.
13393                let noclobber = opt_state_get("noclobber").unwrap_or(false)
13394                    || !opt_state_get("clobber").unwrap_or(true);
13395                let target_meta = std::fs::metadata(target).ok();
13396                let target_is_regular_file = target_meta
13397                    .as_ref()
13398                    .map(|m| m.file_type().is_file())
13399                    .unwrap_or(false);
13400                // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY permits
13401                // re-using an EMPTY regular file under noclobber: `setopt
13402                // noclobber clobberempty; : >f; echo hi >f` overwrites f.
13403                // The inline bridge check ignored this and errored.
13404                let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
13405                    && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
13406                if noclobber && target_is_regular_file && !clobber_empty_ok {
13407                    eprintln!(
13408                        "{}:{}: file exists: {}",
13409                        shname(),
13410                        crate::ported::lex::lineno(),
13411                        target
13412                    );
13413                    self.set_last_status(1);
13414                    // c:Src/exec.c — set redirect_failed so the scope-end
13415                    // hook (`with_redirects_end` in this file) forces
13416                    // $? to 1 regardless of the still-running command's
13417                    // own exit. Without this the next command (e.g.
13418                    // `echo x` writing to /dev/null below) succeeds
13419                    // and overwrites the redirect-failure status,
13420                    // making noclobber unobservable from $?.
13421                    self.redirect_failed = true;
13422                    // Sink the upcoming command's stdout to /dev/null
13423                    // so we don't leak its output to the terminal.
13424                    // zsh skips the command entirely; we approximate by
13425                    // discarding the output (the redirect target was
13426                    // the user's chosen sink, but with noclobber the
13427                    // file is protected — discarding matches the
13428                    // user's intent better than printing to terminal).
13429                    if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
13430                        let new_fd = file.into_raw_fd();
13431                        unsafe {
13432                            libc::dup2(new_fd, fd);
13433                            libc::close(new_fd);
13434                        }
13435                    }
13436                    return;
13437                }
13438                if !Self::redir_open_or_fail(
13439                    fd,
13440                    fs::File::create(target),
13441                    target,
13442                    &mut self.redirect_failed,
13443                ) {
13444                    self.set_last_status(1);
13445                }
13446            }
13447            r::CLOBBER => {
13448                if !Self::redir_open_or_fail(
13449                    fd,
13450                    fs::File::create(target),
13451                    target,
13452                    &mut self.redirect_failed,
13453                ) {
13454                    self.set_last_status(1);
13455                }
13456            }
13457            r::APPEND => {
13458                // c:Src/exec.c:3924-3927 — `>>` honors NO_CLOBBER+!APPENDCREATE
13459                // by opening O_APPEND|O_WRONLY WITHOUT O_CREAT, so missing
13460                // files yield ENOENT. zsh source:
13461                //   if (!isset(CLOBBER) && !isset(APPENDCREATE) &&
13462                //       !IS_CLOBBER_REDIR(fn->type))
13463                //       mode = O_WRONLY|O_APPEND|O_NOCTTY;
13464                //   else mode = O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY;
13465                // (IS_CLOBBER_REDIR — `>>!`/`>>|` — is currently flattened
13466                // to plain APPEND at compile time in
13467                // src/extensions/compile_zsh.rs:1654-1655, so the bang/pipe
13468                // forms can't be distinguished here yet.)
13469                let noclobber = opt_state_get("noclobber").unwrap_or(false)
13470                    || !opt_state_get("clobber").unwrap_or(true);
13471                let append_create = opt_state_get("appendcreate").unwrap_or(false)
13472                    || opt_state_get("append_create").unwrap_or(false);
13473                let open_result = if noclobber && !append_create {
13474                    fs::OpenOptions::new().append(true).open(target) // no create
13475                } else {
13476                    fs::OpenOptions::new()
13477                        .create(true)
13478                        .append(true)
13479                        .open(target)
13480                };
13481                if !Self::redir_open_or_fail(fd, open_result, target, &mut self.redirect_failed) {
13482                    self.set_last_status(1);
13483                }
13484            }
13485            r::READ => {
13486                if !Self::redir_open_or_fail(
13487                    fd,
13488                    fs::File::open(target),
13489                    target,
13490                    &mut self.redirect_failed,
13491                ) {
13492                    self.set_last_status(1);
13493                }
13494            }
13495            r::READ_WRITE => {
13496                if let Ok(file) = fs::OpenOptions::new()
13497                    .create(true)
13498                    .truncate(false) // <> opens existing-or-new without truncating
13499                    .read(true)
13500                    .write(true)
13501                    .open(target)
13502                {
13503                    let new_fd = file.into_raw_fd();
13504                    unsafe {
13505                        // See redir_open_or_fail: when the opened fd IS the
13506                        // destination (target fd was closed), keep it and clear
13507                        // O_CLOEXEC; else dup2 + close.
13508                        if new_fd != fd {
13509                            libc::dup2(new_fd, fd);
13510                            libc::close(new_fd);
13511                        } else {
13512                            libc::fcntl(fd, libc::F_SETFD, 0);
13513                        }
13514                    }
13515                }
13516            }
13517            r::DUP_READ | r::DUP_WRITE => {
13518                // Target is a numeric fd reference like `&3`. The parser
13519                // strips the `&` prefix before we get here in some paths,
13520                // others retain it — accept both. Also support `-` for
13521                // close-fd (`<&-` / `>&-`) per POSIX. The src_fd
13522                // validity check ran above before the save-and-dup.
13523                let n = target.trim_start_matches('&');
13524                if n == "-" {
13525                    unsafe { libc::close(fd) };
13526                } else if n == "p" {
13527                    // c:Src/exec.c — `<&p` / `>&p` route through the
13528                    // coprocin / coprocout globals. zsh's `coproc CMD`
13529                    // launch publishes those fds; the canonical
13530                    // bin_print / bin_read `-p` arms already consume
13531                    // them. The DUP redirect form is the third
13532                    // consumer: it must dup the coproc fd onto the
13533                    // target slot so the next command's stdin/stdout
13534                    // is wired to the running coprocess. Bug #388.
13535                    let coproc_fd = if op_byte == r::DUP_READ {
13536                        crate::ported::modules::clone::coprocin
13537                            .load(std::sync::atomic::Ordering::Relaxed)
13538                    } else {
13539                        crate::ported::modules::clone::coprocout
13540                            .load(std::sync::atomic::Ordering::Relaxed)
13541                    };
13542                    if coproc_fd < 0 {
13543                        eprintln!("{}:1: no coprocess", shname());
13544                        self.set_last_status(1);
13545                        self.redirect_failed = true;
13546                    } else {
13547                        unsafe {
13548                            libc::dup2(coproc_fd, fd);
13549                        }
13550                    }
13551                } else if let Ok(src_fd) = n.parse::<i32>() {
13552                    unsafe { libc::dup2(src_fd, fd) };
13553                } else if op_byte == r::DUP_WRITE {
13554                    // c:Src/glob.c:2184-2187 xpandredir — a MERGEOUT
13555                    // word that expands to a non-number becomes
13556                    // REDIR_ERRWRITE: `cmd >& word` opens `word` and
13557                    // routes BOTH fd 1 and fd 2 there. Reached only
13558                    // for dynamic words (`>&$var`); static filenames
13559                    // were converted at compile time.
13560                    if let Ok(file) = fs::File::create(target) {
13561                        let new_fd = file.into_raw_fd();
13562                        unsafe {
13563                            libc::dup2(new_fd, 1);
13564                            libc::dup2(new_fd, 2);
13565                            libc::close(new_fd);
13566                        }
13567                    }
13568                } else {
13569                    // c:Src/glob.c:2185 — MERGEIN non-number:
13570                    // `zerr("file number expected")`.
13571                    crate::ported::utils::zerr("file number expected");
13572                    self.set_last_status(1);
13573                    self.redirect_failed = true;
13574                }
13575            }
13576            r::WRITE_BOTH => {
13577                if let Ok(file) = fs::File::create(target) {
13578                    let new_fd = file.into_raw_fd();
13579                    unsafe {
13580                        libc::dup2(new_fd, 1);
13581                        libc::dup2(new_fd, 2);
13582                        libc::close(new_fd);
13583                    }
13584                }
13585            }
13586            r::APPEND_BOTH => {
13587                if let Ok(file) = fs::OpenOptions::new()
13588                    .create(true)
13589                    .append(true)
13590                    .open(target)
13591                {
13592                    let new_fd = file.into_raw_fd();
13593                    unsafe {
13594                        libc::dup2(new_fd, 1);
13595                        libc::dup2(new_fd, 2);
13596                        libc::close(new_fd);
13597                    }
13598                }
13599            }
13600            _ => {}
13601        }
13602    }
13603
13604    /// Push a fresh redirect scope. `_count` is informational — the actual
13605    /// saved fds are appended by host_apply_redirect into the top scope.
13606    pub fn host_redirect_scope_begin(&mut self, _count: u8) {
13607        // c:Src/exec.c:3722-3724 — the pipeline child set
13608        // `pipe_output_pending` right after dup2'ing its stdout onto
13609        // the pipe; the FIRST redirect scope opened in that child is
13610        // the stage command's own redirect list (same execcmd as the
13611        // pipe's addfd into mfds[1]). Capture the depth so only THAT
13612        // list's fd-1 write redirects MULTIOS-join the pipe.
13613        if self.pipe_output_pending {
13614            self.pipe_output_pending = false;
13615            self.pipe_output_scope = Some(self.redirect_scope_stack.len());
13616        }
13617        self.redirect_scope_stack.push(Vec::new());
13618        self.multios_scope_stack.push(Vec::new());
13619    }
13620
13621    /// Pop the top redirect scope, restoring saved fds.
13622    pub fn host_redirect_scope_end(&mut self) {
13623        // c:Src/exec.c — restore saved fds FIRST so the multios
13624        // pipe-write end is released from `fd`, then close our
13625        // tracked close_on_end (the last surviving writer dup), then
13626        // join the splitter thread. If we closed close_on_end before
13627        // restoring saved, `fd` would still hold a pipe writer and
13628        // the thread would block forever waiting for EOF.
13629        if let Some(saved) = self.redirect_scope_stack.pop() {
13630            for (fd, saved_fd) in saved.into_iter().rev() {
13631                unsafe {
13632                    libc::dup2(saved_fd, fd);
13633                    libc::close(saved_fd);
13634                }
13635            }
13636        }
13637        if let Some(scope) = self.multios_scope_stack.pop() {
13638            // Close ALL tracked writer dups BEFORE joining any
13639            // thread. When one splitter holds a dup of another's
13640            // pipe write-end (two multios in one scope where a later
13641            // one duped fd 1 while an earlier splitter owned it),
13642            // joining in push order deadlocks: splitter A's EOF
13643            // waits on splitter B's writer dup, which only closes
13644            // after B's thread exits — blocked behind A's join.
13645            let mut handles = Vec::with_capacity(scope.len());
13646            for (write_fd, handle) in scope {
13647                if write_fd >= 0 {
13648                    unsafe {
13649                        libc::close(write_fd);
13650                    }
13651                }
13652                handles.push(handle);
13653            }
13654            for handle in handles {
13655                let _ = handle.join();
13656            }
13657        }
13658        // The scope that captured the pipeline-output marker is gone;
13659        // deeper-nested future scopes must not re-match its depth.
13660        if self.pipe_output_scope == Some(self.redirect_scope_stack.len()) {
13661            self.pipe_output_scope = None;
13662        }
13663    }
13664
13665    /// Set up `content` as stdin (fd 0) for the next command.
13666    /// Used by `Op::HereDoc(idx)` and `Op::HereString`.
13667    ///
13668    /// c:Src/exec.c:4655 getherestr — C writes the body to a TEMP
13669    /// FILE (gettempfile → write_loop → close → reopen O_RDONLY →
13670    /// unlink), NOT a pipe. The previous pipe+writer-thread shape
13671    /// SIGPIPE'd the whole shell when the consumer never read the
13672    /// body (`: <<< ${(F)x/y}` — D04parameter chunk 211, flaky
13673    /// rc=141): the redirect-scope teardown closed the read end
13674    /// while the detached thread was still in write_all, and the
13675    /// shell's SIGPIPE disposition is SIG_DFL. A temp file has no
13676    /// reader/writer coupling — matching C exactly, including
13677    /// lseek-ability of fd 0, which pipes don't give.
13678    pub fn host_set_pending_stdin(&mut self, content: String) {
13679        // c:4673 — `gettempfile(NULL, 1, &s)`.
13680        let mut tmp = std::env::temp_dir();
13681        tmp.push(format!(
13682            "zshrs-herestr-{}-{:x}",
13683            std::process::id(),
13684            std::time::SystemTime::now()
13685                .duration_since(std::time::UNIX_EPOCH)
13686                .map(|d| d.as_nanos())
13687                .unwrap_or(0)
13688        ));
13689        // c:4675 — `write_loop(fd, t, len); close(fd);`
13690        if std::fs::write(&tmp, content.as_bytes()).is_err() {
13691            return; // c:4674 — tempfile failure → no redirect
13692        }
13693        // c:Src/utils.c gettempfile → mkstemp creates the temp file mode
13694        // 0600 IGNORING the umask, so the O_RDONLY reopen below always
13695        // succeeds. `std::fs::write` honors the umask, so under `umask
13696        // 0777` the file landed mode 0000 and the reopen failed with
13697        // EACCES — `cat <<<x` then read empty stdin. Force 0600 to match
13698        // mkstemp's umask-independent permissions.
13699        let _ = std::fs::set_permissions(
13700            &tmp,
13701            <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
13702        );
13703        // c:4677 — `fd = open(s, O_RDONLY | O_NOCTTY);`
13704        let file = match std::fs::File::open(&tmp) {
13705            Ok(f) => f,
13706            Err(_) => {
13707                let _ = std::fs::remove_file(&tmp);
13708                return;
13709            }
13710        };
13711        // c:4678 — `unlink(s);` — fd stays valid, name disappears.
13712        let _ = std::fs::remove_file(&tmp);
13713        let saved = unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD, 10) };
13714        if saved >= 0 {
13715            if let Some(top) = self.redirect_scope_stack.last_mut() {
13716                top.push((libc::STDIN_FILENO, saved));
13717            } else {
13718                unsafe { libc::close(saved) };
13719            }
13720        }
13721        let read_fd = AsRawFd::as_raw_fd(&file);
13722        unsafe { libc::dup2(read_fd, libc::STDIN_FILENO) };
13723        drop(file);
13724    }
13725
13726    /// Spawn an external command using zshrs's full dispatch logic
13727    /// (intercepts, command_hash, redirect handling). Used by
13728    /// `ZshrsHost::exec` so the bytecode VM's `Op::Exec` and
13729    /// `Op::CallFunction` external fallback get the same semantics as
13730    /// the tree-walker's `execute_external` rather than a plain
13731    /// `Command::new` shortcut. Returns the exit status.
13732    pub fn host_exec_external(&mut self, args: &[String]) -> i32 {
13733        // Native p10k API: the `p10k(){ zshrs-p10k-api "$@" }` stub's
13734        // body lands here (the name is neither function nor builtin).
13735        // Route into the engine instead of a PATH miss.
13736        if let Some(name) = args.first() {
13737            if let Some(status) = crate::p10k::maybe_intercept_command(name, &args[1..]) {
13738                self.set_last_status(status);
13739                return status;
13740            }
13741        }
13742        // If a glob expansion in this command's argv triggered the
13743        // nomatch error path, suppress the actual exec and return
13744        // status 1 — mirrors zsh's command-aborted-on-glob-error
13745        // behaviour. The flag is reset BEFORE returning so the next
13746        // command starts clean.
13747        //
13748        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH sets
13749        // ERRFLAG_ERROR but C's execlist clears the bit per-sublist
13750        // so subsequent commands run. Symmetric with the builtin
13751        // dispatcher's clear at fusevm_bridge.rs:299 — clear it here
13752        // too at the external-command post-command-boundary.
13753        consume_tilde_globsubst_carrier();
13754        if self.current_command_glob_failed.get() {
13755            self.current_command_glob_failed.set(false);
13756            crate::ported::utils::errflag.fetch_and(
13757                !crate::ported::zsh_h::ERRFLAG_ERROR,
13758                std::sync::atomic::Ordering::Relaxed,
13759            );
13760            self.set_last_status(1);
13761            return 1;
13762        }
13763        // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the
13764        // NOMATCH gate above, same external-path semantics (skip
13765        // command, `no match`, clear ERRFLAG so the next sublist
13766        // runs).
13767        if consume_badcshglob() {
13768            crate::ported::utils::errflag.fetch_and(
13769                !crate::ported::zsh_h::ERRFLAG_ERROR,
13770                std::sync::atomic::Ordering::Relaxed,
13771            );
13772            self.set_last_status(1);
13773            return 1;
13774        }
13775        let Some((cmd, rest)) = args.split_first() else {
13776            return 0;
13777        };
13778        // Empty command name (e.g. result of an empty `$(false)`
13779        // command-sub being the only word) — zsh: no command runs,
13780        // exit status preserved from prior step. Was hitting the
13781        // "command not found: " path with empty name.
13782        if cmd.is_empty() && rest.is_empty() {
13783            return self.last_status();
13784        }
13785        let rest_vec: Vec<String> = rest.to_vec();
13786        // Update `$_` with the just-arriving argv so the next command
13787        // reads `_=<last_arg>`. Mirrors C zsh's writeback in
13788        // `execcmd_exec` (Src/exec.c). Per `args.last()` semantics,
13789        // when invoked as `cmd a b c`, `$_` becomes "c" — for a bare
13790        // command with no args, `$_` becomes the command name itself.
13791        crate::ported::params::set_zunderscore(args);
13792
13793        // Builtins not in fusevm's name→id table fall through to
13794        // host.exec. Catch them here before the OS-level exec attempts
13795        // to spawn a non-existent binary.
13796        match cmd.as_str() {
13797            "sched" => return dispatch_builtin("sched", rest_vec.clone()),
13798            "echotc" => return dispatch_builtin("echotc", rest_vec.clone()),
13799            "echoti" => return dispatch_builtin("echoti", rest_vec.clone()),
13800            "zpty" => return dispatch_builtin("zpty", rest_vec.clone()),
13801            "ztcp" => return dispatch_builtin("ztcp", rest_vec.clone()),
13802            "zsocket" => {
13803                // c:Src/Modules/socket.c:276 BUILTIN spec — BUILTINS["zsocket"]
13804                // optstr "ad:ltv" parsed by execbuiltin.
13805                return dispatch_builtin("zsocket", rest_vec.clone());
13806            }
13807            "private" => {
13808                // c:Src/Modules/param_private.c:217 — bin_private via
13809                // BUILTINS["private"]. The autoload require_module
13810                // (exec.c:2700-2717) fires inside
13811                // dispatch_builtin_raw, the chokepoint for all routes.
13812                return dispatch_builtin("private", rest_vec.clone());
13813            }
13814            "zformat" => return dispatch_builtin("zformat", rest_vec.clone()),
13815            "zregexparse" => return dispatch_builtin("zregexparse", rest_vec.clone()),
13816            // `unalias`/`unhash`/`unfunction` share `bin_unhash` but
13817            // each carries its own funcid (BIN_UNALIAS / BIN_UNHASH /
13818            // BIN_UNFUNCTION) — dispatch_builtin handles the BUILTINS
13819            // lookup + funcid propagation via execbuiltin.
13820            "unalias" | "unhash" | "unfunction" => {
13821                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
13822            }
13823            // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
13824            // functions — implemented natively in Rust so `autoload -Uz zmv`
13825            // works without shipping the function source (and without the
13826            // fpath source hanging the parser). The `function_exists` guard
13827            // keeps them command-not-found until autoloaded, exactly like zsh;
13828            // an un-guarded arm ran them for bare `zmv`, diverging from
13829            // `zsh -f; zmv` → "command not found: zmv".
13830            "zmv" if self.function_exists("zmv") => {
13831                return crate::extensions::ext_builtins::zmv(&rest_vec, "mv")
13832            }
13833            "zcp" if self.function_exists("zcp") => {
13834                return crate::extensions::ext_builtins::zmv(&rest_vec, "cp")
13835            }
13836            "zln" if self.function_exists("zln") => {
13837                return crate::extensions::ext_builtins::zmv(&rest_vec, "ln")
13838            }
13839            "zcalc" if self.function_exists("zcalc") => {
13840                return crate::extensions::ext_builtins::zcalc(&rest_vec)
13841            }
13842            "zselect" => {
13843                // Route through canonical dispatch_builtin which goes
13844                // via execbuiltin → BUILTINS["zselect"] (zselect.c:272).
13845                return dispatch_builtin("zselect", rest_vec.clone());
13846            }
13847            "cap" => return dispatch_builtin("cap", rest_vec.clone()),
13848            "getcap" => return dispatch_builtin("getcap", rest_vec.clone()),
13849            "setcap" => return dispatch_builtin("setcap", rest_vec.clone()),
13850            "yes" => return self.builtin_yes(&rest_vec),
13851            "nl" => return self.builtin_nl(&rest_vec),
13852            "env" => return self.builtin_env(&rest_vec),
13853            "printenv" => return self.builtin_printenv(&rest_vec),
13854            "tty" => return self.builtin_tty(&rest_vec),
13855            // c:Src/Modules/files.c:806 — BUILTINS["chgrp"] with
13856            // BIN_CHGRP funcid + "hRs" optstr.
13857            "chgrp" => return dispatch_builtin("chgrp", rest_vec.clone()),
13858            "nproc" => return self.builtin_nproc(&rest_vec),
13859            "expr" => return self.builtin_expr(&rest_vec),
13860            "sha256sum" => return self.builtin_sha256sum(&rest_vec),
13861            "base64" => return self.builtin_base64(&rest_vec),
13862            "tac" => return self.builtin_tac(&rest_vec),
13863            "expand" => return self.builtin_expand(&rest_vec),
13864            "unexpand" => return self.builtin_unexpand(&rest_vec),
13865            "paste" => return self.builtin_paste(&rest_vec),
13866            "fold" => return self.builtin_fold(&rest_vec),
13867            "shuf" => return self.builtin_shuf(&rest_vec),
13868            "comm" => return self.builtin_comm(&rest_vec),
13869            "cksum" => return self.builtin_cksum(&rest_vec),
13870            "factor" => return self.builtin_factor(&rest_vec),
13871            "tsort" => return self.builtin_tsort(&rest_vec),
13872            "sum" => return self.builtin_sum(&rest_vec),
13873            "mkfifo" => return self.builtin_mkfifo(&rest_vec),
13874            "link" => return self.builtin_link(&rest_vec),
13875            "unlink" => return self.builtin_unlink(&rest_vec),
13876            "dircolors" => return self.builtin_dircolors(&rest_vec),
13877            "groups" => return self.builtin_groups(&rest_vec),
13878            "arch" => return self.builtin_arch(&rest_vec),
13879            "nice" => return self.builtin_nice(&rest_vec),
13880            "logname" => return self.builtin_logname(&rest_vec),
13881            "tput" => return self.builtin_tput(&rest_vec),
13882            "users" => return self.builtin_users(&rest_vec),
13883            // "sync" => return self.bin_sync(&rest_vec),
13884            "zbuild" => return self.builtin_zbuild(&rest_vec),
13885            // `zf_*` aliases from `zsh/files` (Src/Modules/files.c
13886            // BUILTIN table at line 816-824). The C source binds
13887            // both unprefixed (`chmod`) and prefixed (`zf_chmod`)
13888            // names to the SAME `bin_chmod` etc. handlers — the
13889            // prefixed forms exist so a script can portably reach
13890            // the builtin even when a function or alias has shadowed
13891            // the bare name. Each arm routes through the canonical
13892            // zf_* aliases route through canonical BUILTINS entries
13893            // (files.c:816-824) — execbuiltin parses each fn's optstr
13894            // automatically.
13895            "mkdir" | "zf_mkdir" | "zf_rm" | "zf_rmdir" | "zf_chmod" | "zf_chown" | "zf_chgrp"
13896            | "zf_ln" | "zf_mv" | "zf_sync"
13897                // `--zsh` parity gate: zsh -fc has zsh/files UNLOADED
13898                // — bare `mkdir` is /bin/mkdir (so `command mkdir -p`
13899                // honors the system flag set; zconvey.plugin.zsh:44
13900                // got "File exists" from the in-process bin_mkdir
13901                // that this arm intercepted) and `zf_*` names are
13902                // command-not-found 127 until `zmodload zsh/files`.
13903                // Fall through to the external/exec path in --zsh
13904                // mode; default zshrs mode keeps the anti-fork
13905                // intercept.
13906                if !crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) =>
13907            {
13908                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
13909            }
13910            // `zstat` — port of zsh/stat module (Src/Modules/stat.c
13911            // BUILTIN("zstat", …)). Returns file metadata as
13912            // `field value` pairs / an assoc / a plus-separated
13913            // list depending on flags. zsh ALSO registers `stat`
13914            // bound to the same handler, but that name conflicts
13915            // with the system `stat(1)` binary (every script that
13916            // calls `stat -f '%Lp' …` would break). zsh resolves
13917            // this through opt-in `zmodload`; zshrs's modules are
13918            // statically linked so we keep `stat` routing to the
13919            // external command and only intercept the unambiguous
13920            // `zstat` name.
13921            "zstat" => {
13922                // Canonical bin_stat per stat.c:638 via BUILTINS["zstat"].
13923                return dispatch_builtin("zstat", rest_vec.clone());
13924            }
13925            _ => {}
13926        }
13927
13928        // AOP intercepts: when an `intercept :before/:around/:after foo` block
13929        // is registered, dynamic-command-name dispatch must consult it before
13930        // spawning. Without this, `cmd=ls; $cmd` bypasses every intercept that
13931        // a literal `ls` would trigger. The full_cmd string mirrors what the
13932        // tree-walker era passed (cmd + args joined by space) so existing
13933        // pattern matchers continue to work.
13934        if !self.intercepts.is_empty() {
13935            let full_cmd = if rest_vec.is_empty() {
13936                cmd.clone()
13937            } else {
13938                format!("{} {}", cmd, rest_vec.join(" "))
13939            };
13940            if let Some(intercept_result) = self.run_intercepts(cmd, &full_cmd, &rest_vec) {
13941                return intercept_result.unwrap_or(127);
13942            }
13943        }
13944
13945        // User-defined function lookup before OS-level exec. zsh's
13946        // dynamic-command-name dispatch (`cmd=hook1; $cmd`) checks
13947        // the function table FIRST — without this, `$f` for a
13948        // function-name `f` was always falling through to
13949        // `execute_external` and erroring "command not found".
13950        // Plugin code uses this pattern constantly:
13951        //   for f in "${precmd_functions[@]}"; do "$f"; done
13952        if self.function_exists(cmd) {
13953            if let Some(status) = self.dispatch_function_call(cmd, &rest_vec) {
13954                return status;
13955            }
13956        }
13957
13958        self.execute_external(cmd, &rest_vec, &[]).unwrap_or(127)
13959    }
13960}