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.
320/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
321///
322/// C keeps a shell function in exactly ONE place — the `Shfunc` node in
323/// `shfunctab` — so `dosavetrap` copies a `TRAP<SIG>` function by
324/// duplicating that node (`Src/signals.c:638-656`) and `endtrapscope`
325/// puts it back with a single `shfunctab->addnode` (c:929-931).
326///
327/// zshrs splits one function across FIVE stores: the `shfunc` node in
328/// `shfunctab` (metadata + body text) plus the executor's
329/// `functions_compiled` (the fusevm `Chunk` that actually runs),
330/// `function_source`, `function_line_base` and `function_def_file`.
331/// Snapshotting only the hashtable node therefore restores the outer
332/// function's *metadata* while the inner function's *bytecode* keeps
333/// running. `FuncSnapshot` captures all five so the C-level "copy the
334/// node" / "add the node back" steps are faithful in behaviour.
335#[derive(Default)]
336pub struct FuncSnapshot {
337    pub shf: Option<crate::ported::zsh_h::shfunc>,
338    pub chunk: Option<fusevm::Chunk>,
339    pub source: Option<String>,
340    pub line_base: Option<i64>,
341    pub def_file: Option<Option<String>>,
342}
343
344impl FuncSnapshot {
345    /// True when `name` had no definition at snapshot time — restoring
346    /// such a snapshot deletes the function everywhere.
347    pub fn is_empty(&self) -> bool {
348        self.shf.is_none() && self.chunk.is_none() && self.source.is_none()
349    }
350}
351
352/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
353/// Capture every store that holds `name`'s definition. See
354/// [`FuncSnapshot`].
355pub fn snapshot_function(name: &str) -> FuncSnapshot {
356    let shf = crate::ported::hashtable::shfunctab_lock()
357        .read()
358        .ok()
359        .and_then(|t| t.get(name).cloned());
360    let (chunk, source, line_base, def_file) = try_with_executor(|exec| {
361        (
362            exec.functions_compiled.get(name).cloned(),
363            exec.function_source.get(name).cloned(),
364            exec.function_line_base.get(name).copied(),
365            exec.function_def_file.get(name).cloned(),
366        )
367    })
368    .unwrap_or((None, None, None, None));
369    FuncSnapshot {
370        shf,
371        chunk,
372        source,
373        line_base,
374        def_file,
375    }
376}
377
378/// !!! WARNING: RUST-ONLY HELPER — NO C COUNTERPART !!!
379/// Put a [`FuncSnapshot`] back under `name`. An empty snapshot (the
380/// function did not exist when it was taken) removes it from every
381/// store, mirroring C's `removehashnode(shfunctab, …)`.
382pub fn restore_function(name: &str, snap: FuncSnapshot) {
383    if let Ok(mut t) = crate::ported::hashtable::shfunctab_lock().write() {
384        match snap.shf {
385            Some(shf) => {
386                t.add(shf);
387            }
388            None => {
389                t.remove(name);
390            }
391        }
392    }
393    let _ = try_with_executor(|exec| {
394        match snap.chunk {
395            Some(c) => {
396                exec.functions_compiled.insert(name.to_string(), c);
397            }
398            None => {
399                exec.functions_compiled.remove(name);
400            }
401        }
402        match snap.source {
403            Some(s) => {
404                exec.function_source.insert(name.to_string(), s);
405            }
406            None => {
407                exec.function_source.remove(name);
408            }
409        }
410        match snap.line_base {
411            Some(n) => {
412                exec.function_line_base.insert(name.to_string(), n);
413            }
414            None => {
415                exec.function_line_base.remove(name);
416            }
417        }
418        match snap.def_file {
419            Some(f) => {
420                exec.function_def_file.insert(name.to_string(), f);
421            }
422            None => {
423                exec.function_def_file.remove(name);
424            }
425        }
426    });
427}
428
429pub fn cmdsubst_outer_stdout() -> Option<i32> {
430    CMDSUBST_OUTER_FDS.with(|s| s.borrow().last().map(|(o, _)| *o))
431}
432
433thread_local! {
434    /// The pipeline fds a stage still has to install onto 0/1, as
435    /// `(input, output)` with -1 meaning "leave this fd alone".
436    ///
437    /// c:Src/exec.c:3720-3724 — the pipe's read/write ends are the
438    /// FIRST entries of the command's multio table:
439    ///     /* Add pipeline input/output to mnodes */
440    ///     if (input)  addfd(forked, save, mfds, 0, input, 0, NULL);
441    ///     if (output) addfd(forked, save, mfds, 1, output, 1, NULL);
442    /// and that runs AFTER prefork (c:3304) + globlist (c:3702) have
443    /// expanded the command's argument words. So a `$(...)` inside a
444    /// stage's ARGS reads the shell's original fd 0, not the pipe:
445    /// `print -rl -- c a b | print -r -- "[$(cat)]"` prints `[]`.
446    /// (The stage's own fork at c:3000 happens before the expansion,
447    /// which is why `${x::=v}` in a non-last stage doesn't survive —
448    /// but the fds are still installed after it.)
449    ///
450    /// zshrs's [`BUILTIN_RUN_PIPELINE`] forks per stage, so it parks
451    /// the stage's fds here instead of dup2'ing them itself, and the
452    /// compiled stage chunk installs them via
453    /// [`BUILTIN_PIPE_FDS_INSTALL`] at the C-faithful point: after the
454    /// arg-word ops, before the redirect scope
455    /// (compile_zsh.rs::emit_stage_fds_install). A compound stage
456    /// (`{ … }`, `( … )`, a function) installs at chunk entry — its
457    /// body legitimately reads the pipe.
458    static PENDING_STAGE_FDS: std::cell::Cell<(i32, i32)> = const { std::cell::Cell::new((-1, -1)) };
459}
460
461/// Park the current stage's `(input, output)` pipe fds for the stage
462/// chunk's `BUILTIN_PIPE_FDS_INSTALL` to pick up. Returns the previous
463/// value so a nested pipeline (`print -- "$(a | b)" | c`) can restore
464/// the outer stage's still-uninstalled fds when it finishes.
465fn stage_fds_park(input: i32, output: i32) -> (i32, i32) {
466    PENDING_STAGE_FDS.with(|c| c.replace((input, output)))
467}
468
469/// Take (and clear) the parked stage fds.
470fn stage_fds_take() -> (i32, i32) {
471    PENDING_STAGE_FDS.with(|c| c.replace((-1, -1)))
472}
473
474// Thread-local pointer to the current ShellExecutor.
475// Set before VM execution, cleared after. Used by builtin handlers.
476thread_local! {
477    static CURRENT_EXECUTOR: RefCell<Option<*mut ShellExecutor>> = const { RefCell::new(None) };
478    /// The installed session executor, registered by
479    /// `exec::install_session_executor`. Lets [`with_session_context`]
480    /// establish a VM execution context for STARTUP work that runs
481    /// before the loop's first `execode` enters one (rc-file sourcing in
482    /// `run_init_scripts`, c:1914). Mirrors exec.rs's own
483    /// `SESSION_EXECUTOR`; kept here so the context helper lives next to
484    /// `ExecutorContext`/`CURRENT_EXECUTOR`.
485    static SESSION_EXECUTOR_PTR: std::cell::Cell<Option<*mut ShellExecutor>> = const { std::cell::Cell::new(None) };
486    /// GLOB_ASSIGN eligibility carrier. Set true by BUILTIN_MARK_GLOB_ELIGIBLE
487    /// (emitted by the compiler ONLY when a scalar-assignment RHS carries an
488    /// UNQUOTED glob token — Star/Quest/Inbrack), read+cleared by the next
489    /// BUILTIN_SET_VAR. Matches C zsh's `GLOB_ASSIGN` (Src/exec.c:2554): only
490    /// a literal unquoted glob pattern in the wordcode is globbed; values from
491    /// `$param` / `$(cmd)` / quoted strings are NOT (verified against zsh).
492    /// The runtime SET_VAR value arrives untokenized (the compiler DQ-wraps to
493    /// suppress compile-time globbing), so quoting can no longer be recovered
494    /// from the value bytes — this flag carries the compile-time decision.
495    static SET_VAR_GLOB_ELIGIBLE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
496}
497
498/// Register the session executor pointer (called from
499/// `install_session_executor`). See [`with_session_context`].
500pub fn register_session_executor(exec: &mut ShellExecutor) {
501    SESSION_EXECUTOR_PTR.with(|c| c.set(Some(exec as *mut ShellExecutor)));
502}
503
504/// Run `f` with the registered session executor established as
505/// `CURRENT_EXECUTOR`, so code reaching the live executor via
506/// `try_with_executor` works even when no per-command `execode` context
507/// is active yet.
508///
509/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
510/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
511/// first `execode`. Without an active context those sourced bodies
512/// `try_with_executor` → `None` → no-op, so the shell silently ignored
513/// the user's dotfiles. The scope is entered once around the startup
514/// sourcing window and dropped before the loop begins — deliberately NOT
515/// Run `f` with the registered session executor established as
516/// `CURRENT_EXECUTOR`, so code reaching the live executor via
517/// `try_with_executor` works even when no per-command `execode` context
518/// is active yet.
519///
520/// Sole caller: `zsh_main`'s `run_init_scripts()` (c:1914), which
521/// sources `.zshenv`/`.zshrc`/`.zlogin` via `source()` BEFORE the loop's
522/// first `execode`. Without an active context those sourced bodies
523/// `try_with_executor` → `None` → no-op, so the shell silently ignored
524/// the user's dotfiles. The scope is entered once around the startup
525/// sourcing window and dropped before the loop begins — deliberately NOT
526/// a global fallback inside `execute_script_zsh_pipeline`, which would
527/// re-enter the executor on nested command substitution and block on
528/// input.
529pub fn with_session_context<R>(f: impl FnOnce() -> R) -> R {
530    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
531    match ptr {
532        // SAFETY: set by install_session_executor to an executor that
533        // outlives the single-threaded interactive session.
534        Some(ptr) => {
535            let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
536            f()
537        }
538        None => f(),
539    }
540}
541
542/// Merge finished background-compinit results into shell state, callable
543/// from any site (active VM context first, session executor otherwise —
544/// the ZLE completion path runs OUTSIDE a VM frame, where
545/// `try_with_executor` alone is None and $_comps stayed empty forever).
546pub fn drain_compinit_bg_hook() {
547    if try_with_executor(|exec| exec.drain_compinit_bg()).is_some() {
548        return;
549    }
550    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
551    if let Some(ptr) = ptr {
552        // SAFETY: per with_session_context.
553        let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
554        unsafe { (*ptr).drain_compinit_bg() }
555    }
556}
557
558/// Run the body of a PLAIN sourced file through the per-command loop —
559/// `ShellExecutor::execute_script_per_command`, the port of the
560/// `switch (loop(0, 0))` arm of C's `source()` (c:Src/init.c:1625-1641).
561///
562/// `bin_dot` (`src/ported/builtin.rs`, C's `Src/builtin.c:6118`
563/// `ret = source(enam = buf);`) is the only caller. It lives HERE rather
564/// than beside the other executor accessors in `src/ported/exec.rs`
565/// because everything under `src/ported/` is a line-by-line port and
566/// `build.rs` rejects a `fn` there with no C counterpart — this bridge
567/// shim has none, since C's `source()` reaches its interpreter through
568/// plain globals where Rust needs an explicit executor handle.
569///
570/// Executor ladder, in the order the sibling wrappers in `exec.rs` use:
571/// the innermost ACTIVE executor when one is in scope — inside
572/// `$(source f)` that is the sub-VM that owns the capture, which is why
573/// the substitution still collects the file's output — and the installed
574/// session executor otherwise, for a `source` that runs before the main
575/// loop's first `execode` has entered a context. `Ok(0)` with neither,
576/// matching `exec::execute_script`'s no-executor return.
577pub fn source_file_per_command(src: &str) -> Result<i32, String> {
578    if let Some(r) = try_with_executor(|exec| exec.execute_script_per_command(src)) {
579        return r;
580    }
581    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
582    match ptr {
583        // SAFETY: per with_session_context.
584        Some(ptr) => {
585            let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
586            unsafe { (*ptr).execute_script_per_command(src) }
587        }
588        None => Ok(0),
589    }
590}
591
592/// Run the deparsed text of an ALREADY-COMPILED `.zwc` program on the live
593/// executor — the compiled arm of `source()`, `execode(prog, 1, 0,
594/// "filecode")` (c:Src/init.c:1621).
595///
596/// Same executor resolution as [`source_file_per_command`]: the live
597/// executor when one is in scope, the installed session executor otherwise,
598/// `Ok(0)` with neither. See
599/// [`crate::vm_helper::ShellExecutor::execute_zwc_program`] for why the plain
600/// script entry point is wrong here.
601pub fn execute_zwc_program(src: &str) -> Result<i32, String> {
602    if let Some(r) = try_with_executor(|exec| exec.execute_zwc_program(src)) {
603        return r;
604    }
605    let ptr = SESSION_EXECUTOR_PTR.with(|c| c.get());
606    match ptr {
607        // SAFETY: per with_session_context.
608        Some(ptr) => {
609            let _ctx = ExecutorContext::enter(unsafe { &mut *ptr });
610            unsafe { (*ptr).execute_zwc_program(src) }
611        }
612        None => Ok(0),
613    }
614}
615
616/// RAII guard that sets/clears the thread-local executor pointer.
617///
618/// Idempotent: calling `enter` when a context is already active is a no-op
619/// for the entry side, and the guard's drop only clears the thread-local if
620/// *this* call was the one that set it. Nested `execute_command` invocations
621/// (e.g. from inside a builtin handler) reuse the outer pointer instead of
622/// stomping it.
623pub(crate) struct ExecutorContext {
624    we_set_it: bool,
625}
626
627impl ExecutorContext {
628    pub(crate) fn enter(executor: &mut ShellExecutor) -> Self {
629        let we_set_it = CURRENT_EXECUTOR.with(|cell| {
630            let mut slot = cell.borrow_mut();
631            if slot.is_some() {
632                false
633            } else {
634                *slot = Some(executor as *mut ShellExecutor);
635                true
636            }
637        });
638        ExecutorContext { we_set_it }
639    }
640}
641
642impl Drop for ExecutorContext {
643    fn drop(&mut self) {
644        if self.we_set_it {
645            CURRENT_EXECUTOR.with(|cell| {
646                *cell.borrow_mut() = None;
647            });
648        }
649    }
650}
651
652/// Access the current executor from a builtin handler.
653/// # Safety
654/// Only call this from within a VM execution context (after ExecutorContext::enter).
655#[inline]
656pub(crate) fn with_executor<F, R>(f: F) -> R
657where
658    F: FnOnce(&mut ShellExecutor) -> R,
659{
660    CURRENT_EXECUTOR.with(|cell| {
661        let ptr = cell
662            .borrow()
663            .expect("with_executor called outside VM context");
664        // SAFETY: The pointer is valid for the duration of VM execution,
665        // and we're single-threaded within the executor.
666        let executor = unsafe { &mut *ptr };
667        f(executor)
668    })
669}
670
671/// Non-panicking variant of [`with_executor`]: runs `f` against the
672/// current executor and returns `Some(result)`, or `None` when no
673/// executor is in scope (`CURRENT_EXECUTOR` unset — e.g. unit tests /
674/// compsys contexts with no fusevm bridge running).
675///
676/// This is the primitive the `crate::ported::exec` accessor wrappers
677/// (array/assoc/dispatch_function_call/execute_script/...) use to
678/// reach the live executor while preserving the exact "no executor →
679/// fall back to the direct param table / default value" semantics that
680/// the deleted `exec_hooks` OnceLock layer encoded via its
681/// "is-the-hook-installed?" check. `CURRENT_EXECUTOR` being set is the
682/// faithful equivalent of "the bridge installed the hooks".
683#[inline]
684pub(crate) fn try_with_executor<F, R>(f: F) -> Option<R>
685where
686    F: FnOnce(&mut ShellExecutor) -> R,
687{
688    CURRENT_EXECUTOR.with(|cell| {
689        let ptr = (*cell.borrow())?;
690        // SAFETY: same contract as with_executor — the pointer is valid
691        // for the duration of VM execution and access is single-threaded.
692        let executor = unsafe { &mut *ptr };
693        Some(f(executor))
694    })
695}
696
697/// Look up a canonical builtin by name in `BUILTINS` and dispatch
698/// via `execbuiltin` (Src/builtin.c:250). NO shadow check — calls the
699/// builtin even if a user function with the same name exists. Used by
700/// the `builtin foo` prefix opcode (which explicitly bypasses function
701/// lookup per zsh semantics) and by internal call sites where shadowing
702/// is unwanted. For zsh's normal name-resolution order (function shadows
703/// builtin), use `dispatch_builtin` instead.
704/// Shell-identifier prefix for diagnostic lines. Reads the canonical
705/// scriptname (`zsh` in `--zsh` parity mode, `zshrs` otherwise) so a
706/// single helper replaces hardcoded `"zshrs:"` literals across the
707/// file's eprintln paths.
708fn shname() -> String {
709    crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string())
710}
711
712/// c:Src/subst.c:505-507 + Src/exec.c:3378-3380 — per-command
713/// CSH_NULL_GLOB outcome check. During this command's word expansion
714/// `expand_glob` accumulated `badcshglob |= 1` per failed glob and
715/// `|= 2` per successful one (Src/glob.c:1871-1875). Exactly 1 —
716/// failures and no successes — is the csh-style error: `no match`,
717/// command skipped, status 1. Any other value (0 = no globs, 2/3 =
718/// at least one matched) is silent. Always resets the counter for
719/// the next command (C resets at prefork entry, subst.rs:1307).
720/// Returns true when the error fired; callers mirror their
721/// glob_failed handling (builtins leave ERRFLAG_ERROR set so the
722/// script aborts, externals clear it so the next sublist runs —
723/// verified against zsh 5.9.1).
724/// Restore the user's GLOB_SUBST after a `${~spec}` carrier flip
725/// (see subst::TILDE_GLOBSUBST_CARRIER). Runs at the same
726/// command-dispatch boundaries that consume glob_failed /
727/// badcshglob — by then every glob op of the current word pipeline
728/// has read the carrier.
729pub(crate) fn consume_tilde_globsubst_carrier() {
730    crate::ported::subst::TILDE_GLOBSUBST_CARRIER.with(|c| {
731        if let Some(saved) = c.take() {
732            crate::ported::options::opt_state_set("globsubst", saved);
733        }
734    });
735}
736
737/// Pop `argc` stack slots for a whole-array assignment: the LAST popped
738/// (deepest pushed) is the param name, the rest are the values in stack
739/// order, with any `Value::Array` flattened to its elements. Mirrors the
740/// Flatten an array-assignment RHS value into scalar strings, descending
741/// through nested `Value::Array`s. zsh arrays are always flat, so recursion
742/// only collapses the wrapper layers the compiler introduces — in particular
743/// the single `Value::Array` built by `Op::MakeArray` for `arr=(...)` literals
744/// (used to dodge `CallBuiltin`'s u8 argc cap), whose own elements may
745/// themselves be arrays from an unquoted `$other_array` expansion. A
746/// one-level flatten would stringify those inner arrays into a single element.
747fn flatten_array_value(v: Value, out: &mut Vec<String>) {
748    match v {
749        Value::Array(items) => {
750            for it in items.iter() {
751                flatten_array_value(it.clone(), out);
752            }
753        }
754        other => out.push(other.to_str()),
755    }
756}
757
758/// pop/flatten prologue of BUILTIN_SET_ARRAY / BUILTIN_APPEND_ARRAY.
759fn pop_array_args_with_name(vm: &mut fusevm::VM, argc: u8) -> (String, Vec<String>) {
760    let n = argc as usize;
761    let mut popped: Vec<Value> = Vec::with_capacity(n);
762    for _ in 0..n {
763        popped.push(vm.pop());
764    }
765    popped.reverse();
766    let name = popped.pop().map(|v| v.to_str()).unwrap_or_default();
767    let mut values: Vec<String> = Vec::new();
768    for v in popped {
769        flatten_array_value(v, &mut values);
770    }
771    (name, values)
772}
773
774fn consume_badcshglob() -> bool {
775    let v = crate::ported::glob::BADCSHGLOB.swap(0, std::sync::atomic::Ordering::Relaxed);
776    if v == 1 {
777        crate::ported::utils::zerr("no match"); // c:Src/subst.c:507
778        true
779    } else {
780        false
781    }
782}
783
784/// Map a builtin name to the zsh module that owns it, IFF zsh does
785/// not auto-load that builtin on first use. Used by
786/// `dispatch_builtin_raw` to gate `--zsh` mode dispatch behind
787/// `zmodload`, mirroring `zsh -fc <name>` returning 127 for these
788/// names without an explicit module load.
789///
790/// Returns `Some(module_name)` if `name` belongs to a non-auto-load
791/// module per the per-module `Src/Modules/<x>.c` `bintab[]` plus
792/// the auto-load flag set at module-build time. `None` for core
793/// builtins and for auto-loaded module builtins (sched, log, echotc,
794/// echoti, zformat, zparseopts, zregexparse, zstyle, strftime,
795/// private, vared, zle, bindkey, comp*) which work without zmodload.
796fn module_bound_builtin_module(name: &str) -> Option<&'static str> {
797    match name {
798        "zftp" => Some("zsh/zftp"),
799        "zsocket" => Some("zsh/net/socket"),
800        "ztcp" => Some("zsh/net/tcp"),
801        "zstat" => Some("zsh/stat"),
802        "zselect" => Some("zsh/zselect"),
803        "zpty" => Some("zsh/zpty"),
804        "zprof" => Some("zsh/zprof"),
805        "zsystem" | "syserror" => Some("zsh/system"),
806        "clone" => Some("zsh/clone"),
807        "zcurses" => Some("zsh/curses"),
808        "ztie" | "zuntie" | "zgdbmpath" => Some("zsh/db/gdbm"),
809        "pcre_compile" | "pcre_match" | "pcre_study" => Some("zsh/pcre"),
810        "example" => Some("zsh/example"),
811        "cap" | "getcap" | "setcap" => Some("zsh/cap"),
812        "zgetattr" | "zsetattr" | "zdelattr" | "zlistattr" => Some("zsh/attr"),
813        // c:Src/Modules/datetime.c — `strftime` is registered via
814        // partab[] when zsh/datetime loads. Verified by
815        // `zsh -fc 'strftime -s s %Y 0'` → 127 "command not found".
816        "strftime" => Some("zsh/datetime"),
817        _ => None,
818    }
819}
820
821/// Dispatch a zshrs-ORIGINAL builtin by NAME, argv-style. These are
822/// registered as fusevm opcodes in [`register_builtins`] (async, doctor,
823/// peach, …), so a *literal* name compiles to `CallBuiltin` and runs. But
824/// they are absent from the static `BUILTINS` port table and the merged
825/// `builtintab`, so when the command name is resolved only at run time —
826/// `$var` indirection, `builtin NAME` — the ported command-resolution path
827/// never finds them and reports "command not found" / "no such builtin",
828/// even though `whence` (correctly) calls them builtins. The
829/// `register_builtins` closures use the VM only to pop args and then call an
830/// executor method, so the identical dispatch works here from any parent-side
831/// resolver that has an executor — no VM re-entry (which would alias the
832/// running `&mut VM`). Returns `None` for a name that is not one of them, so
833/// the caller falls through to external lookup.
834///
835/// !!! Keep in sync with the matching `vm.register_builtin(...)` closures in
836/// `register_builtins`: both must route a name to the same executor method.
837pub(crate) fn try_run_registered_builtin(name: &str, argv: &[String]) -> Option<i32> {
838    let s = match name {
839        "async" => with_executor(|e| e.builtin_async(argv)),
840        "await" => with_executor(|e| e.builtin_await(argv)),
841        "barrier" => with_executor(|e| e.builtin_barrier(argv)),
842        "peach" => with_executor(|e| e.builtin_peach(argv)),
843        "pmap" => with_executor(|e| e.builtin_pmap(argv)),
844        "pgrep" => with_executor(|e| e.builtin_pgrep(argv)),
845        "intercept" => with_executor(|e| e.builtin_intercept(argv)),
846        "intercept_proceed" => with_executor(|e| e.builtin_intercept_proceed(argv)),
847        "doctor" => with_executor(|e| e.builtin_doctor(argv)),
848        "dbview" => with_executor(|e| e.builtin_dbview(argv)),
849        "profile" => with_executor(|e| e.builtin_profile(argv)),
850        "provenance" => with_executor(|e| e.builtin_provenance(argv)),
851        "caller" => with_executor(|e| e.builtin_caller(argv)),
852        "help" => with_executor(|e| e.builtin_help(argv)),
853        "cdreplay" => with_executor(|e| e.builtin_cdreplay(argv)),
854        "zsleep" => crate::extensions::ext_builtins::zsleep(argv),
855        // Host-registered native commands (`extensions/native_cmds.rs`): the
856        // fat binary's sibling runtimes — `git` (zvcs), `arb` (arblang),
857        // `stryke` (strykelang) in the zshrs-native build. Unknown here in the
858        // thin shell, where the table is empty and this arm falls through to
859        // `None` exactly as before.
860        //
861        // Reached from the two places that ask "is this a builtin?": the
862        // pre-PATH arm of the ZshrsHost dispatch (after functions and after
863        // builtintab, so a user `git()` still shadows it) and the forced
864        // `builtin NAME` precommand. `command git` consults neither, so the
865        // escape hatch to the `git` on PATH is untouched.
866        //
867        // The registry's contract is full argv — argv[0] is the command name
868        // as invoked, which zvcs needs for its `git-<verb>` dashed form and
869        // for its `zvcs: <command>: <reason>` diagnostics — while every arm
870        // above takes the operands alone, so the name is spliced back on here.
871        n => {
872            if !crate::native_cmds::is_enabled(n) {
873                return None;
874            }
875            let full: Vec<String> = std::iter::once(n.to_string())
876                .chain(argv.iter().cloned())
877                .collect();
878            return crate::native_cmds::dispatch(n, &full);
879        }
880    };
881    Some(s)
882}
883
884pub(crate) fn dispatch_builtin_raw(name: &str, args: Vec<String>) -> i32 {
885    // !!! WARNING: RUST-ONLY — NO C COUNTERPART !!!
886    // Native p10k engine intercept (src/extensions/p10k): sourcing
887    // powerlevel10k.zsh-theme activates the Rust segment engine
888    // instead of executing the ~13k-line zsh theme. The user's
889    // `.p10k.zsh` CONFIG is NOT intercepted — it sources normally so
890    // its POWERLEVEL9K_* typesets land in the paramtab, which the
891    // engine reads live at every render. Placed here (the chokepoint
892    // every builtin route funnels through) so `source`, `.`, and
893    // `builtin source` all hit it.
894    if matches!(name, "source" | ".") {
895        if let Some(status) = crate::p10k::maybe_intercept_theme_source(&args) {
896            // Register a `p10k` stub function so `${+functions[p10k]}`
897            // guards in .zshrc templates stay truthy. The body forwards
898            // to the bridge-intercepted `zshrs-p10k-api` name so
899            // `p10k segment` (custom-segment protocol) and the other
900            // API subcommands reach the native engine (p10k_api).
901            try_with_executor(|exec| {
902                let _ = exec.execute_script("function p10k() { zshrs-p10k-api \"$@\" }");
903            });
904            return status;
905        }
906    }
907    // Native p10k API dispatch — the `p10k` stub function forwards
908    // here (see the theme intercept above). Must run before the
909    // generic builtintab lookup: the name is not a real builtin.
910    if let Some(status) = crate::p10k::maybe_intercept_command(name, &args) {
911        return status;
912    }
913    // c:Src/exec.c:2700-2717 — `private` is an autoloaded builtin in
914    // zsh (autofeature b:private of zsh/param/private): first use
915    // runs ensurefeature → require_module → load_module → boot_,
916    // marking the module MOD_INIT_B (what `zmodload -e` reads) and
917    // installing the wrap_private FuncWrap (param_private.c:712).
918    // doshfunc gates the wrapper dispatch on that load state, so
919    // this require_module is what activates private scoping. The
920    // raw dispatcher is the chokepoint every builtin route funnels
921    // through; require_module is idempotent after the first call
922    // (needs_load checks MOD_INIT_B).
923    if name == "private" {
924        if let Ok(mut tab) = crate::ported::module::MODULESTAB.lock() {
925            let _ = crate::ported::module::require_module(
926                &mut tab,
927                "zsh/param/private",
928                None,
929                0,
930                false,
931            );
932            // c:2710 ensurefeature
933        }
934    }
935    // c:Src/Modules/param_private.c:682-685 setup_ — loading
936    // zsh/param/private REPLACES the `local` builtintab node's
937    // handlerfunc + optstr with bin_private's ("Even more horrible
938    // hack"), so once the module is loaded `local` IS bin_private: it
939    // accepts the -P/-Pa private-scope flags, and without -P delegates
940    // to bin_typeset, which already treats `local` and `private`
941    // identically (is_locallike, builtin.rs:3666). Replicate the swap by
942    // routing `local` through the `private` node only after the module
943    // is loaded — before then, `local -P` still errors "bad option: -P"
944    // exactly like stock zsh. The `private` node carries the augmented
945    // optstr (with P) that the `local` node lacks.
946    if name == "local"
947        && crate::ported::module::MODULESTAB
948            .lock()
949            .map(|t| t.is_bound("zsh/param/private"))
950            .unwrap_or(false)
951    {
952        // c:Src/Modules/param_private.c:683-685 — the swap copies EXACTLY
953        // two fields:
954        //     ((Builtin)hn)->handlerfunc = bintab[0].handlerfunc;
955        //     ((Builtin)hn)->optstr = bintab[0].optstr;
956        // `defopts` is NOT copied, so the `local` node keeps its own
957        // (NULL) defaults while `private`'s node keeps `"P"`. That is
958        // what makes `local x` delegate straight to bin_typeset
959        // (c:225-229 `if (!OPT_ISSET(ops, 'P'))`) while `private x`
960        // opens a private scope. Dispatching `local` through the
961        // `private` NODE inherited defopts="P", so every `local NAME`
962        // ran the private-promotion path: `() { local h=scalar;
963        // private -A h }` reported "can't change type of private param"
964        // where zsh reports "can't change scope of existing param"
965        // (V10private.ztst:13), and `local` silently made privates.
966        // zshrs's builtintab maps to `&'static builtin` rows in an
967        // immutable static, so mirror C's field swap on a private
968        // static copy of the `local` node instead of mutating the table.
969        static LOCAL_AS_PRIVATE: std::sync::LazyLock<crate::ported::zsh_h::builtin> =
970            std::sync::LazyLock::new(|| crate::ported::zsh_h::builtin {
971                // c:683 `save_local = *(Builtin)hn;` — start from the
972                // real `local` row so name/flags/minargs/maxargs/funcid/
973                // defopts all stay `local`'s.
974                node: crate::ported::zsh_h::hashnode {
975                    next: None,
976                    nam: "local".to_string(),
977                    flags: (crate::ported::zsh_h::BINF_PLUSOPTS
978                        | crate::ported::zsh_h::BINF_MAGICEQUALS
979                        | crate::ported::zsh_h::BINF_PSPECIAL
980                        | crate::ported::zsh_h::BINF_ASSIGN) as i32,
981                },
982                // c:684 — handlerfunc from bintab[0] (bin_private).
983                handlerfunc: Some(
984                    crate::ported::modules::param_private::bin_private
985                        as crate::ported::zsh_h::HandlerFunc,
986                ),
987                minargs: 0,
988                maxargs: -1,
989                funcid: 0,
990                // c:685 — optstr from bintab[0] (private's, which adds `P`).
991                optstr: Some("AE:%F:%HL:%PR:%TUZ:%ahi:%lnmrtux".to_string()),
992                // c:683-685 — NOT copied: `local` keeps its own defaults.
993                defopts: None,
994            });
995        let bn_ptr = &*LOCAL_AS_PRIVATE as *const _ as *mut _;
996        return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
997    }
998    // c:Bugs #475/#504/#555 — bash-only builtins (`mapfile`,
999    // `readarray`, `compopt`) should emit "command not found" in
1000    // `--zsh` mode matching zsh's external-command-lookup miss.
1001    // The per-opcode closures for caller/help/complete/compgen
1002    // already gate via IS_ZSH_MODE at their registration sites;
1003    // names without dedicated opcodes (compopt/mapfile/readarray)
1004    // route through this generic builtintab lookup and need the
1005    // gate here.
1006    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1007        && matches!(name, "compopt" | "mapfile" | "readarray")
1008    {
1009        eprintln!("zsh:1: command not found: {}", name);
1010        let _ = args;
1011        return 127;
1012    }
1013    // c:Src/exec.c:2700-2724 resolvebuiltin — autoloaded-builtin stub
1014    // (registered by `zmodload -ab MOD NAME`, Src/module.c:426
1015    // add_autobin) fires on first use: ensurefeature loads the owning
1016    // module, then dispatch proceeds against the real builtin. Must
1017    // run BEFORE the module-bound 127 gate below — `zmodload -ab
1018    // zsh/zselect zselect; zselect` previously died there with
1019    // `command not found` because the gate only checked is_loaded,
1020    // never the autoload ledger.
1021    if let Some(rc) = crate::ported::module::resolvebuiltin(name) {
1022        if rc != 0 {
1023            // Load failed or feature undefined — diagnostics already
1024            // printed (load_module zwarn / resolvebuiltin zerr).
1025            // C's execbuiltin head returns 1 (Src/builtin.c:264-267).
1026            return 1;
1027        }
1028        // Module loaded — fall through; the is_loaded gates below now
1029        // pass and the normal dispatch chain runs the real builtin.
1030    }
1031    // c:Src/Modules/<mod>.c boot_/setup_ chain — module-bound builtins
1032    // (zftp, zsocket, ztcp, zstat, etc.) are only registered into
1033    // `builtintab` when their module is loaded via `zmodload`. In
1034    // zsh `-fc` (the parity test harness's invocation), the modules
1035    // are NOT pre-loaded, so each name reports "command not found"
1036    // with exit 127. zshrs intentionally pre-loads all module bintabs
1037    // in `createbuiltintable` (builtin.rs:131-152) for the default
1038    // mode so users can call these without `zmodload`; that auto-load
1039    // diverges from zsh's gate behavior. Match zsh's stance only when
1040    // the user explicitly asked for parity via `--zsh`.
1041    //
1042    // The list is the union of builtins from modules that zsh does
1043    // NOT auto-load (verified via `zsh -fc <name>` returning 127):
1044    //   zsh/zftp          → zftp
1045    //   zsh/net/socket    → zsocket
1046    //   zsh/net/tcp       → ztcp
1047    //   zsh/stat          → zstat (NOT `stat`; that name resolves to
1048    //                              /bin/stat on PATH per zsh's setup)
1049    //   zsh/zselect       → zselect
1050    //   zsh/zpty          → zpty
1051    //   zsh/zprof         → zprof
1052    //   zsh/system        → zsystem, syserror
1053    //   zsh/clone         → clone
1054    //   zsh/curses        → zcurses
1055    //   zsh/db/gdbm       → ztie, zuntie, zgdbmpath
1056    //   zsh/pcre          → pcre_compile, pcre_match, pcre_study
1057    //   zsh/example       → example
1058    //   zsh/cap           → cap, getcap, setcap
1059    //   zsh/attr          → zgetattr, zsetattr, zdelattr, zlistattr
1060    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1061        && module_bound_builtin_module(name)
1062            .map(|m| {
1063                !crate::ported::module::MODULESTAB
1064                    .lock()
1065                    .map(|t| t.is_loaded(m))
1066                    .unwrap_or(false)
1067            })
1068            .unwrap_or(false)
1069    {
1070        eprintln!("zsh:1: command not found: {}", name);
1071        let _ = args;
1072        return 127;
1073    }
1074    // c:Src/Modules/files.c:806-824 — zsh/files registers `chmod`,
1075    // `chown`, `chgrp`, `ln`, `mkdir`, `mv`, `rm`, `rmdir`, `sync`
1076    // (plus their `zf_*` aliases) into builtintab on module load.
1077    // Without an explicit `zmodload zsh/files`, zsh resolves the
1078    // names through PATH lookup — `zsh -fc 'chmod +x f'` runs
1079    // `/bin/chmod`, whose argv-parser accepts symbolic modes like
1080    // `+x` that bin_chmod's octal-only parser rejects with
1081    // "invalid mode `+x'". The shadow-aware wrapper at
1082    // `dispatch_builtin` (line 438) already has this gate, but the
1083    // direct `dispatch_builtin_raw` path used by fusevm's
1084    // CallBuiltin opcode bypasses it. Mirror the gate here so the
1085    // low-level dispatch matches C's PATH-fall-through behavior.
1086    // The gate is NOT emulation-mode dependent. C has no `zsh/files`
1087    // builtins in `builtintab` until the module is loaded, in ANY mode, so a
1088    // bare `rm`/`mv`/`chmod` falls through to PATH — `chmod +x FILE` runs
1089    // /bin/chmod and succeeds. Conditioning this on `IS_ZSH_MODE` meant the
1090    // native binary answered `chmod +x` from `bin_chmod`'s octal-only parser
1091    // ("invalid mode `+x'"), and `rm -s` / `mv -s` from the module's argument
1092    // parser instead of the system tool's. `dispatch_builtin` at :1087 already
1093    // gates unconditionally; this low-level `CallBuiltin` path did not.
1094    if module_gated_files_builtin(name)
1095        && !crate::ported::module::MODULESTAB
1096            .lock()
1097            .map(|t| t.is_loaded("zsh/files"))
1098            .unwrap_or(false)
1099    {
1100        // PATH lookup uses the LITERAL name: bare `mkdir` finds
1101        // /bin/mkdir; a `zf_*` alias finds nothing and exits 127 —
1102        // matching zsh -fc `zf_mkdir d` → "command not found:
1103        // zf_mkdir" (the aliases exist ONLY in the loaded module's
1104        // builtintab, Src/Modules/files.c:816-824; PATH has no
1105        // /bin/zf_rm). The previous zf_-strip silently ran the
1106        // system binary instead.
1107        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1108        return status;
1109    }
1110    // c:Src/Modules/stat.c:637-638 — zsh/stat registers BOTH `stat`
1111    // and `zstat`. `zstat` is in the module_bound 127-gate above (no
1112    // /usr/bin/zstat exists), but the bare `stat` name must FALL
1113    // THROUGH to PATH when zsh/stat isn't loaded — zsh -fc
1114    // 'stat -f %Lp f' runs /usr/bin/stat, while bin_stat's parser
1115    // rejects stat(1) flags ("bad option: -c"). Same fall-through
1116    // shape as the zsh/files gate above.
1117    if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
1118        && name == "stat"
1119        && !crate::ported::module::MODULESTAB
1120            .lock()
1121            .map(|t| t.is_loaded("zsh/stat"))
1122            .unwrap_or(false)
1123    {
1124        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1125        return status;
1126    }
1127    // c:Src/exec.c:3050-3068 — builtin lookup hits `builtintab` (the
1128    // merged table containing module-provided builtins). The previous
1129    // port walked only the core `BUILTINS` slice, so per-module
1130    // entries like `log` (Src/Modules/watch.c:693 `BUILTIN("log", …,
1131    // bin_log, …)`) were registered into builtintab via
1132    // createbuiltintable but never reached at dispatch — `log` fell
1133    // through to PATH and ran `/usr/bin/log`. Bug #72 in docs/BUGS.md.
1134    let tab = crate::ported::builtin::createbuiltintable();
1135    if let Some(bn_static) = tab.get(name) {
1136        let bn_ptr = *bn_static as *const _ as *mut _;
1137        return crate::ported::builtin::execbuiltin(args, Vec::new(), bn_ptr);
1138    }
1139    1
1140}
1141
1142/// Shadow-aware dispatch matching zsh's name-resolution order:
1143/// alias → reserved word → **function (shadows builtin)** → builtin →
1144/// external. All `BUILTIN_X` opcode handlers route through here so a
1145/// user-defined `cd () { … }` (or `r`, `fc`, `which`, … anything in
1146/// fusevm's name→opcode map) takes precedence over the C builtin —
1147/// matching `Src/exec.c:execcmd_exec`'s dispatch at c:3050-3068.
1148/// Without this, compile-time builtin resolution silently ignored
1149/// user wrappers (e.g. ZPWR's `cd () { builtin cd "$@"; … }`).
1150/// True for builtins that are bound by zsh/files's boot_/setup_
1151/// chain (Src/Modules/files.c:806-824). These are the bare-name
1152/// `mkdir`/`rm`/`mv`/`ln`/`chmod`/`chown`/`chgrp`/`sync`/`rmdir`
1153/// AND their `zf_*` aliases at c:816-824. Without explicit
1154/// `zmodload zsh/files`, the names fall through to PATH lookup
1155/// (zsh's `type rm` reports `/bin/rm`). Bug #28.
1156fn module_gated_files_builtin(name: &str) -> bool {
1157    matches!(
1158        name,
1159        "mkdir"
1160            | "rmdir"
1161            | "rm"
1162            | "mv"
1163            | "ln"
1164            | "chmod"
1165            | "chown"
1166            | "chgrp"
1167            | "sync"
1168            | "zf_mkdir"
1169            | "zf_rmdir"
1170            | "zf_rm"
1171            | "zf_mv"
1172            | "zf_ln"
1173            | "zf_chmod"
1174            | "zf_chown"
1175            | "zf_chgrp"
1176            | "zf_sync"
1177    )
1178}
1179
1180pub(crate) fn dispatch_builtin(name: &str, args: Vec<String>) -> i32 {
1181    // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close any
1182    // `>(cmd)` write ends owned by this command once it finishes
1183    // (drops on every return path below).
1184    let _psub_fds = PsubFdGuard;
1185    // c:Src/exec.c — when any redirect in the current scope failed
1186    // (e.g. noclobber blocked a `>` overwrite), zsh refuses to
1187    // execute the command and exits with status 1. The Rust port
1188    // still applied the command (writing to the /dev/null sink
1189    // installed by host_apply_redirect's noclobber arm) so the
1190    // success status overwrote the intended 1. Short-circuit here
1191    // for builtins (the external-exec equivalent lives in
1192    // ZshrsHost::exec).
1193    let redir_failed = with_executor(|exec| {
1194        let f = exec.redirect_failed;
1195        exec.redirect_failed = false;
1196        f
1197    });
1198    if redir_failed {
1199        // c:Src/exec.c:4367-4386 — POSIX special-builtin escalation:
1200        // a failed redirect on a PSPECIAL builtin (set, readonly,
1201        // typeset, ...) under POSIX_BUILTINS is FATAL in a
1202        // non-interactive shell (`exit(1)` at c:4383). The `command`
1203        // prefix resets this (BINF_COMMAND, c:4369) — that path
1204        // dispatches through bin_command, not here.
1205        if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
1206            && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
1207            && builtin_is_pspecial(name)
1208        {
1209            use std::sync::atomic::Ordering;
1210            crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
1211            crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
1212        }
1213        return 1;
1214    }
1215    // c:Src/glob.c:1876-1880 NOMATCH path — when expand_glob() failed
1216    // on a no-match glob, zsh aborts the simple command after zerr()
1217    // printed "no matches found". In C, this works because zerr()
1218    // sets ERRFLAG_ERROR (Src/utils.c) and execcmd_exec()
1219    // (Src/exec.c:3050+) checks errflag before invoking the builtin
1220    // table. Rust's builtin dispatch doesn't sit on the same errflag
1221    // gate, so we explicitly consume the per-command glob-fail cell
1222    // and short-circuit with status 1. Mirrors the external-path
1223    // guard at host_exec_external (line 5167). Without this:
1224    // `echo /never/*` would print empty (silently rolled back to ""
1225    // by the empty glob expansion). Parity bug #13.
1226    consume_tilde_globsubst_carrier();
1227    let glob_failed = with_executor(|exec| {
1228        let f = exec.current_command_glob_failed.get();
1229        exec.current_command_glob_failed.set(false); // c:1879 cleanup
1230        f
1231    });
1232    if glob_failed {
1233        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH zerr sets
1234        // ERRFLAG_ERROR (via utils.c:184). For a BUILTIN command the
1235        // expansion runs IN the shell process, so errflag stays set
1236        // and the rest of the input aborts (zsh -fc 'echo /nope_*;
1237        // echo after' prints nothing after the error — verified
1238        // against zsh 5.9). The continue-after-nomatch behaviour
1239        // belongs ONLY to externals: C forks BEFORE expansion there,
1240        // so the child's zerr can't touch the parent's errflag (zsh
1241        // -fc 'ls /nope_*; echo after' prints `after`) — that path's
1242        // clear lives in fn exec / execute_external. Leave
1243        // ERRFLAG_ERROR set here; BUILTIN_ERREXIT_CHECK trigger 4
1244        // aborts the remaining script at the next command boundary.
1245        return 1; // c:1880 — command aborted, status 1
1246    }
1247    // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the NOMATCH
1248    // gate above: all of this command's globs failed silently (words
1249    // dropped, badcshglob accumulated 1s and no 2s) → `no match`,
1250    // skip the builtin, status 1. Like the NOMATCH path, ERRFLAG
1251    // from zerr stays set for builtins so the rest of the script
1252    // aborts (zsh -fc 'setopt cshnullglob; print *nope* x; print
1253    // after' prints only the error — verified zsh 5.9.1).
1254    if consume_badcshglob() {
1255        // c:Src/exec.c:3380 — `lastval = 1;` so the shell's final
1256        // exit status reflects the aborted command.
1257        with_executor(|exec| exec.set_last_status(1));
1258        return 1;
1259    }
1260    // c:Src/exec.c:4162-4295 — assignment-builtin (BINF_ASSIGN family:
1261    // typeset / declare / local / export / readonly / integer / float /
1262    // private) whose `name=value` postassign arg raised errflag while
1263    // its RHS was preforked (PREFORK_ASSIGN, c:4239-4245) — the classic
1264    // case is a math error in `typeset -F fv=$((1/0))`. The postassign
1265    // loop `break`s on errflag (c:4243) and then `if (!errflag)
1266    // execbuiltin(...)` (c:4287) SKIPS the builtin entirely, so `lastval`
1267    // is left UNCHANGED from before the command (0 fresh, 1 after
1268    // `false`). This differs from a PLAIN assignment `x=$((1/0))`, which
1269    // goes through execsimple c:1375 `lv = errflag ? errflag : cmdoutval`
1270    // → 1, and from a NON-assign builtin `print $((1/0))`, whose main
1271    // args-prefork errflag lands on c:3760 `lastval = 1`. Only the
1272    // assignment-BUILTIN postassign path preserves the prior status.
1273    // Mirror it here: the fusevm reg_passthru dispatch still calls us
1274    // with errflag set (unlike C's pre-invoke gate), so consume that
1275    // state and return the prior LASTVAL instead of running the builtin.
1276    {
1277        use std::sync::atomic::Ordering;
1278        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
1279        let ef = live & crate::ported::zsh_h::ERRFLAG_ERROR;
1280        let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
1281        // Only the SOFT recoverable error (math failure like `$((1/0))`,
1282        // ERRFLAG_ERROR without ERRFLAG_HARD) preserves the prior status
1283        // per c:4287. A HARD error (`${var?msg}`, which c:Src/subst.c
1284        // OR's ERRFLAG_HARD onto errflag) is a script-abort that yields
1285        // status 1 regardless of the prior status — leave that to the
1286        // normal dispatch/abort path below (which returns 1 and keeps
1287        // ERRFLAG_HARD set for the downstream errexit gate).
1288        if ef != 0 && hard == 0 && builtin_is_assign_family(name) {
1289            // c:4287 — execbuiltin skipped; lastval unchanged.
1290            return crate::ported::builtin::LASTVAL.load(Ordering::Relaxed);
1291        }
1292    }
1293    if let Some(status) = try_user_fn_override(name, &args) {
1294        // c:Src/jobs.c:1748 waitonejob — canonical single-command
1295        // pipestats update via the no-procs else-branch.
1296        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1297        let mut synth = crate::ported::zsh_h::job::default();
1298        crate::ported::jobs::waitonejob(&mut synth);
1299        return status;
1300    }
1301    // c:Src/builtin.c:587 + Src/exec.c:3056 — a builtin disabled via
1302    // `disable <name>` has its `DISABLED` flag set in `builtintab`;
1303    // `builtintab->getnode` (the DISABLED-filtering accessor) returns
1304    // NULL for it at lookup time, so execcmd_exec falls through to
1305    // PATH lookup and runs the external. The Rust port stores the
1306    // disabled set in `BUILTINS_DISABLED`; the previous dispatcher
1307    // only checked the immutable `createbuiltintable` HashMap which
1308    // never reflects disablement — so `disable echo; echo hi` kept
1309    // running the bin_echo builtin. Bug #106 in docs/BUGS.md.
1310    //
1311    // dispatch_builtin (the high-level wrapper used by the BUILTIN_*
1312    // opcode handlers and reg_passthru! callsites) is the correct
1313    // gate: `dispatch_builtin_raw` is the low-level entry point
1314    // used by `bin_builtin` itself which MUST bypass the disabled
1315    // set (man zshbuiltins: `builtin name` runs the builtin
1316    // regardless of disable state). Place the check here so the
1317    // bypass path stays clean.
1318    let disabled = crate::ported::builtin::BUILTINS_DISABLED
1319        .lock()
1320        .map(|s| s.contains(name))
1321        .unwrap_or(false);
1322    if disabled {
1323        let status = with_executor(|exec| exec.execute_external(name, &args, &[])).unwrap_or(127);
1324        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1325        let mut synth = crate::ported::zsh_h::job::default();
1326        crate::ported::jobs::waitonejob(&mut synth);
1327        return status;
1328    }
1329    // c:Src/Modules/files.c:806-814 — `mkdir`, `rm`, `mv`, `ln`, `chmod`,
1330    // `chown`, `chgrp`, `sync`, `rmdir` are bound by the `zsh/files`
1331    // module's boot_/setup_ chain. Without explicit `zmodload zsh/files`,
1332    // these bare names fall through to PATH (`/bin/rm`, `/usr/bin/chmod`,
1333    // etc.) in zsh; `type rm` reports `rm is /bin/rm`. The `zf_*`
1334    // aliases (`zf_rm`, `zf_chmod`, …) are bound by the same module
1335    // and gated the same way. Bug #28 in docs/BUGS.md.
1336    if module_gated_files_builtin(name) {
1337        if !crate::ported::module::MODULESTAB
1338            .lock()
1339            .unwrap()
1340            .is_loaded("zsh/files")
1341        {
1342            // PATH lookup uses the literal name. In --zsh parity mode
1343            // `zf_rm` must 127 like zsh -fc (no /bin/zf_rm); default
1344            // zshrs mode keeps the convenience zf_-strip so the alias
1345            // still reaches the system binary.
1346            let path_name = if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1347                name
1348            } else {
1349                name.strip_prefix("zf_").unwrap_or(name)
1350            };
1351            let status =
1352                with_executor(|exec| exec.execute_external(path_name, &args, &[])).unwrap_or(127);
1353            crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1354            let mut synth = crate::ported::zsh_h::job::default();
1355            crate::ported::jobs::waitonejob(&mut synth);
1356            return status;
1357        }
1358    }
1359    // c:Src/exec.c:3997 `int q = queue_signal_level();`
1360    // c:Src/exec.c:4231 `dont_queue_signals();`
1361    // c:Src/exec.c:4243 `restore_queue_signals(q);`
1362    //
1363    // C runs EVERY builtin with signal queueing switched OFF. Two
1364    // consequences the zshrs port was missing:
1365    //
1366    //   1. `dont_queue_signals()` DRAINS the pending queue (it calls
1367    //      run_queued_signals()), so a signal that arrived while an
1368    //      enclosing scope held queue_signals() — doshfunc holds one
1369    //      for the whole call, c:Src/exec.c:5835 — fires its trap at
1370    //      the NEXT command boundary rather than at function exit.
1371    //   2. While the builtin runs, queueing stays off, so a signal the
1372    //      builtin sends to itself (`kill -USR1 $$`) dispatches the
1373    //      trap synchronously inside the builtin — which is why zsh
1374    //      prints pre/trap/post for
1375    //      `f() { print pre; kill -USR1 $$; print post }`.
1376    //
1377    // Without this bracket every trap raised inside a function was
1378    // deferred to the enclosing unqueue_signals() (i.e. script end).
1379    // c:Src/exec.c:3546 — `setunderscore((args && nonempty(args)) ?
1380    // ((char *) getdata(lastnode(args))) : "");`. execcmd_exec sets `$_`
1381    // to the last word of the command it is ABOUT to run — after the
1382    // words were expanded, before the builtin/external executes — so a
1383    // builtin that READS `_` at run time (`typeset -p _`, `${(P)…}`,
1384    // `$parameters[_]`) sees its own last argument, not the previous
1385    // command's. zshrs only did this for a handful of builtins (echo,
1386    // print, true, false, `:`) and for the external/function paths;
1387    // every reg_passthru! builtin was left reading the stale value.
1388    // C's `args` list carries argv[0], so a bare `cat` sets `_=cat` —
1389    // hence the fallback to `name` when there are no arguments.
1390    let underscore = args.last().cloned().unwrap_or_else(|| name.to_string()); // c:3546
1391    crate::ported::params::set_zunderscore(std::slice::from_ref(&underscore)); // c:3546
1392    let q = crate::ported::signals_h::queue_signal_level(); // c:3997
1393    crate::ported::signals_h::dont_queue_signals(); // c:4231
1394    let status = dispatch_builtin_raw(name, args);
1395    crate::ported::signals_h::restore_queue_signals(q); // c:4243
1396                                                        // c:Src/jobs.c:1748 waitonejob — canonical single-command pipestats update.
1397    crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
1398    let mut synth = crate::ported::zsh_h::job::default();
1399    crate::ported::jobs::waitonejob(&mut synth);
1400    // c:Src/exec.c:4367-4386 — done: tail. A PSPECIAL builtin that
1401    // raised errflag under POSIX_BUILTINS exits the non-interactive
1402    // shell with status 1 ("hard error in POSIX" — e.g. bin_dot's
1403    // zerrnam at Src/builtin.c:6133). Arm the deferred-exit pair so
1404    // the next ERREXIT_CHECK unwinds; EXIT_VAL=1 matches C's
1405    // hardcoded exit(1), NOT the builtin's own status (dot returns
1406    // 127 but POSIX exits 1).
1407    if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
1408        && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
1409        && builtin_is_pspecial(name)
1410        && (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
1411            & crate::ported::zsh_h::ERRFLAG_ERROR)
1412            != 0
1413    {
1414        use std::sync::atomic::Ordering;
1415        crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
1416        crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
1417    }
1418    status
1419}
1420
1421/// c:Src/zsh.h:1467 BINF_PSPECIAL — true when `name` is a POSIX
1422/// special builtin per the canonical builtin table flags
1423/// (Src/builtin.c:48-129: `.`, `:`, break, continue, declare, eval,
1424/// exit, export, float, integer, local, readonly, return, set,
1425/// shift, source, times, trap, typeset, unset).
1426fn builtin_is_pspecial(name: &str) -> bool {
1427    crate::ported::builtin::createbuiltintable()
1428        .get(name)
1429        .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_PSPECIAL) != 0)
1430        .unwrap_or(false)
1431}
1432
1433/// c:Src/zsh.h:1486 BINF_ASSIGN — the assignment-builtin family
1434/// (typeset / declare / local / export / readonly / integer / float /
1435/// private). Their `name=value` args are handled as postassigns
1436/// (c:Src/exec.c:4162-4295), whose errflag-abort skips execbuiltin and
1437/// preserves the prior `lastval`. Read the flag straight from the
1438/// builtin table (same pattern as `builtin_is_pspecial`).
1439fn builtin_is_assign_family(name: &str) -> bool {
1440    crate::ported::builtin::createbuiltintable()
1441        .get(name)
1442        .map(|b| (b.node.flags as u32 & crate::ported::zsh_h::BINF_ASSIGN) != 0)
1443        .unwrap_or(false)
1444}
1445
1446// The former `install_exec_hooks()` fn-pointer registry is gone. Code
1447// under `src/ported/` now reaches `ShellExecutor` operations
1448// (array/assoc storage, script eval, function dispatch, command
1449// substitution) through the `crate::ported::exec::*` accessor wrappers,
1450// which resolve the live executor via `try_with_executor`
1451// (`CURRENT_EXECUTOR`). The bridge lives in exec.rs — the sanctioned
1452// fusevm-access exception — per `feedback_no_exec_script_from_ported` /
1453// `feedback_no_shellexecutor_in_ported`.
1454
1455/// Register all zsh builtins with the VM.
1456pub(crate) fn register_builtins(vm: &mut fusevm::VM) {
1457    // src/ported/ reaches the live executor (param store, function
1458    // dispatch, nested script/cmdsubst exec) through the
1459    // `crate::ported::exec::*` accessor wrappers, which read
1460    // `CURRENT_EXECUTOR` via `try_with_executor`. No install step is
1461    // needed: the executor is in scope for the duration of any VM run
1462    // (set by `ExecutorContext::enter`), so the wrappers resolve it
1463    // directly. (Replaces the former `exec_hooks` OnceLock fn-ptr
1464    // registry, now deleted.)
1465    // Engage fusevm's tiered JIT (block + tracing) so hot, fully-eligible
1466    // numeric chunks run in native code and — with the `jit-disk-cache`
1467    // feature (on by default) — persist that native code to
1468    // `~/.cache/fusevm-jit`, letting repeated zsh invocations skip Cranelift
1469    // codegen. fusevm gates the JIT on per-chunk eligibility and warms up by
1470    // an invocation threshold, falling back to the interpreter for any chunk
1471    // it cannot compile (e.g. host-builtin/`Extended` command dispatch), so
1472    // enabling it here never changes observable behaviour — it only caches
1473    // the numeric hot path. Idempotent: re-enabling on each VM is a no-op.
1474    vm.enable_tracing_jit();
1475    // Macro for builtins that user functions are allowed to shadow.
1476    // zsh dispatch order is alias → function → builtin; without the
1477    // try_user_fn_override probe a `cat() { ... }; cat` would silently
1478    // run the C builtin and ignore the user function.
1479    macro_rules! reg_overridable {
1480        ($vm:expr, $id:expr, $name:literal, $method:ident) => {
1481            $vm.register_builtin($id, |vm, argc| {
1482                let args = pop_args(vm, argc);
1483                // c:Src/exec.c getproc + Src/jobs.c deletefilelist —
1484                // close `>(cmd)` write ends owned by this command
1485                // once it finishes (shadows bypass dispatch_builtin
1486                // and ZshrsHost::exec, so they need their own guard:
1487                // `tee >(wc -c) </dev/null` left wc blocked).
1488                let _psub_fds = PsubFdGuard;
1489                if let Some(s) = try_user_fn_override($name, &args) {
1490                    return Value::Status(s);
1491                }
1492                // c:Src/exec.c — redirect failure in the current
1493                // scope means the command must NOT run. coreutils
1494                // shadows (cat / head / tail / etc.) take a separate
1495                // dispatch path from dispatch_builtin, so they need
1496                // their own gate. Without this `cat <&3` after a
1497                // closed-fd diagnostic still ran the shadow and
1498                // overwrote $? from the forced 1.
1499                let redir_failed = with_executor(|exec| {
1500                    let f = exec.redirect_failed;
1501                    exec.redirect_failed = false;
1502                    f
1503                });
1504                if redir_failed {
1505                    return Value::Status(1);
1506                }
1507                // `[builtins].coreutils_shadows = off` in
1508                // ~/.zshrs/zshrs.toml (or `ZSHRS_NO_COREUTILS_SHADOWS=1`
1509                // env override) bypasses the in-process shadow and
1510                // fork-execs the real /bin/X. Safety valve for any
1511                // script that hits an edge-case divergence between
1512                // the zshrs shadow and system coreutils. Cached
1513                // after first call, so the hot path is one atomic
1514                // load per shadowed-builtin invocation.
1515                // c:Src/exec.c:3545-3547 — these shadows stand in for
1516                // EXTERNAL commands (`cat`, `head`, …), which in zsh reach
1517                // execcmd_exec and set `$_` to the command's last word
1518                // before running. Both arms below bypass dispatch_builtin
1519                // AND execute_external_bg (the shadow runs in-process; the
1520                // opt-out arm spawns through exec_system_command), so
1521                // without this `cat f; print $_` reported the PREVIOUS
1522                // command's last argument.
1523                {
1524                    let last = args.last().cloned().unwrap_or_else(|| $name.to_string());
1525                    crate::ported::params::set_zunderscore(std::slice::from_ref(&last));
1526                    // c:3546
1527                }
1528                if !crate::daemon_presence::coreutils_shadows_enabled() {
1529                    return Value::Status(exec_system_command($name, &args));
1530                }
1531                let status = with_executor(|exec| exec.$method(&args));
1532                Value::Status(status)
1533            });
1534        };
1535    }
1536
1537    // Pure-passthru builtin: pops args, routes to canonical
1538    // `dispatch_builtin(name, args)` (which goes via execbuiltin →
1539    // BUILTINS[name] → bin_X). No pre/post bridge work. Used by
1540    // ~25 handlers that were 4-line copy-paste boilerplate.
1541    macro_rules! reg_passthru {
1542        ($vm:expr, $id:expr, $name:literal) => {
1543            $vm.register_builtin($id, |vm, argc| {
1544                let args = pop_args(vm, argc);
1545                // function > builtin: a same-named user function wins over
1546                // the builtin on the normal (CallBuiltin) invocation path.
1547                // The compiler's `user_function_shadow` already routes the
1548                // same-compile-unit case through CallFunction; this probe
1549                // extends that to the cross-unit / interactive case (define
1550                // `zstyle() { … }` on one line, call it on the next). The
1551                // forced `builtin NAME` / `command NAME` paths dispatch
1552                // through their own handlers, not this one, so they still
1553                // reach the builtin as required.
1554                if let Some(s) = try_user_fn_override($name, &args) {
1555                    return Value::Status(s);
1556                }
1557                Value::Status(dispatch_builtin($name, args))
1558            });
1559        };
1560    }
1561
1562    // zshrs-original extension builtins (async / peach / doctor / …) that
1563    // route to an ExecutorContext method. Like `reg_overridable!`, they
1564    // probe `try_user_fn_override` FIRST so a user function of the same
1565    // name wins — zsh's alias → function → builtin dispatch order. Without
1566    // the probe, `doctor() { … }; doctor` silently ran the builtin and
1567    // ignored the function (function > builtin violated for these).
1568    macro_rules! reg_ext_overridable {
1569        ($vm:expr, $id:expr, $name:literal, $method:ident) => {
1570            $vm.register_builtin($id, |vm, argc| {
1571                let args = pop_args(vm, argc);
1572                if let Some(s) = try_user_fn_override($name, &args) {
1573                    return Value::Status(s);
1574                }
1575                Value::Status(with_executor(|exec| exec.$method(&args)))
1576            });
1577        };
1578    }
1579
1580    // Core builtins
1581    vm.register_builtin(BUILTIN_CD, |vm, argc| {
1582        let args = pop_args(vm, argc);
1583        if let Some(s) = try_user_fn_override("cd", &args) {
1584            return Value::Status(s);
1585        }
1586        let status = dispatch_builtin("cd", args);
1587        // c:Src/builtin.c:1258 — `callhookfunc("chpwd", NULL, 1, NULL)`
1588        // after cd succeeds. The canonical port at
1589        // src/ported/utils.rs:1532 handles both the `chpwd` shfunc
1590        // dispatch AND the `chpwd_functions` array walk.
1591        if status == 0 {
1592            crate::ported::utils::callhookfunc("chpwd", None, 1, std::ptr::null_mut());
1593        }
1594        Value::Status(status)
1595    });
1596
1597    vm.register_builtin(BUILTIN_PWD, |vm, argc| {
1598        let args = pop_args(vm, argc);
1599        if let Some(s) = try_user_fn_override("pwd", &args) {
1600            return Value::Status(s);
1601        }
1602        // Route through the canonical execbuiltin path so the `rLP`
1603        // optstr at BUILTINS["pwd"] is parsed into `ops`.
1604        let status = dispatch_builtin("pwd", args);
1605        Value::Status(status)
1606    });
1607
1608    vm.register_builtin(BUILTIN_ECHO, |vm, argc| {
1609        let args = pop_args(vm, argc);
1610        if let Some(s) = try_user_fn_override("echo", &args) {
1611            return Value::Status(s);
1612        }
1613        // Update `$_` to the last arg before running. C zsh sets
1614        // zunderscore in execcmd_exec for every simple command,
1615        // including builtins.
1616        crate::ported::params::set_zunderscore(&args);
1617        let status = dispatch_builtin("echo", args);
1618        Value::Status(status)
1619    });
1620
1621    vm.register_builtin(BUILTIN_PRINT, |vm, argc| {
1622        let args = pop_args(vm, argc);
1623        if let Some(s) = try_user_fn_override("print", &args) {
1624            return Value::Status(s);
1625        }
1626        crate::ported::params::set_zunderscore(&args);
1627        let status = dispatch_builtin("print", args);
1628        Value::Status(status)
1629    });
1630
1631    reg_passthru!(vm, BUILTIN_PRINTF, "printf");
1632    reg_passthru!(vm, BUILTIN_EXPORT, "export");
1633    reg_passthru!(vm, BUILTIN_UNSET, "unset");
1634    // `source` (Src/builtin.c c:116) wired to bin_dot via BUILTINS.
1635    reg_passthru!(vm, BUILTIN_SOURCE, "source");
1636    reg_passthru!(vm, BUILTIN_DOT, ".");
1637    reg_passthru!(vm, BUILTIN_LOGOUT, "logout");
1638
1639    vm.register_builtin(BUILTIN_EXIT, |vm, argc| {
1640        let args = pop_args(vm, argc);
1641        let status = dispatch_builtin("exit", args);
1642        Value::Status(status)
1643    });
1644
1645    vm.register_builtin(BUILTIN_RETURN, |vm, argc| {
1646        let args = pop_args(vm, argc);
1647        // zsh: bare `return` (no arg) returns with the status of
1648        // the most recently executed command — `false; return`
1649        // returns 1, not 0. Direct port of zsh's bin_break/RETURN.
1650        // The executor's `last_status` is stale here (synced at
1651        // statement boundaries, not after each VM op), so read
1652        // the live `vm.last_status` instead.
1653        let live_status = vm.last_status;
1654        let status = {
1655            // Sync canonical LASTVAL to the VM's view BEFORE
1656            // bin_break("return") reads it for the no-arg fallback.
1657            with_executor(|exec| exec.set_last_status(live_status));
1658            dispatch_builtin("return", args)
1659        };
1660        Value::Status(status)
1661    });
1662
1663    vm.register_builtin(BUILTIN_TRUE, |vm, argc| {
1664        let args = pop_args(vm, argc);
1665        if let Some(s) = try_user_fn_override("true", &args) {
1666            return Value::Status(s);
1667        }
1668        // c:Src/exec.c:1257 — zsh sets `zunderscore` AT THE END of
1669        // each command (the `if (!noerrs)` block runs `zsfree(prev_argv0); …;
1670        // zunderscore = …`). For no-arg `true`, $_ becomes the
1671        // command name itself. Set DIRECTLY (not via pending_underscore)
1672        // so the NEXT command's argv-expansion of `$_` reads "true",
1673        // not the stale prior value — pending_underscore is consumed
1674        // by pop_args which runs AFTER argv expansion, too late.
1675        // c:Src/exec.c:1257 — `zunderscore = …` at end-of-command.
1676        // With args, $_ = args.last(). Without args, $_ = command name.
1677        // Write DIRECTLY to the canonical zunderscore static (the
1678        // underscoregetfn at params.rs:7003 reads from there); the
1679        // paramtab "_" slot is shadowed by lookup_special_var so
1680        // set_scalar on it has no effect on `$_` reads.
1681        if args.is_empty() {
1682            crate::ported::params::set_zunderscore(&["true".to_string()]);
1683        } else {
1684            crate::ported::params::set_zunderscore(&args);
1685        }
1686        // Route through canonical execbuiltin so PS4 xtrace fires
1687        // via the c:442 printprompt4 path.
1688        Value::Status(dispatch_builtin("true", args))
1689    });
1690    vm.register_builtin(BUILTIN_FALSE, |vm, argc| {
1691        let args = pop_args(vm, argc);
1692        if let Some(s) = try_user_fn_override("false", &args) {
1693            return Value::Status(s);
1694        }
1695        // Direct set; see BUILTIN_TRUE above for rationale.
1696        if args.is_empty() {
1697            crate::ported::params::set_zunderscore(&["false".to_string()]);
1698        } else {
1699            crate::ported::params::set_zunderscore(&args);
1700        }
1701        // Route through canonical execbuiltin — see BUILTIN_TRUE
1702        // above for the same rationale (xtrace + fast-path removal).
1703        let status = dispatch_builtin("false", args);
1704        Value::Status(status)
1705    });
1706    vm.register_builtin(BUILTIN_COLON, |vm, argc| {
1707        let args = pop_args(vm, argc);
1708        // Direct set; see BUILTIN_TRUE above for rationale.
1709        if args.is_empty() {
1710            crate::ported::params::set_zunderscore(&[":".to_string()]);
1711        } else {
1712            crate::ported::params::set_zunderscore(&args);
1713        }
1714        let status = dispatch_builtin(":", args);
1715        Value::Status(status)
1716    });
1717
1718    vm.register_builtin(BUILTIN_TEST, |vm, argc| {
1719        let args = pop_args(vm, argc);
1720        // Distinguish `[ … ]` from `test …` by sniffing the trailing
1721        // `]` — `[` requires it (c:Src/builtin.c:7241), `test` rejects
1722        // it. The compile path emits BUILTIN_TEST for both, so the
1723        // dispatch name carries the `[` vs `test` semantic for
1724        // execbuiltin's funcid (BIN_BRACKET=21 vs BIN_TEST=20). Without
1725        // this, bin_test's `if func == BIN_BRACKET` arm (which pops
1726        // the trailing `]`) never fired for `[` calls, so the `]`
1727        // leaked into evalcond as a positional and silently changed
1728        // the result. Bug surfaced via test_test_dashdash_unknown_condition.
1729        let name = if args.last().map(|s| s.as_str()) == Some("]") {
1730            "["
1731        } else {
1732            "test"
1733        };
1734        let status = dispatch_builtin(name, args);
1735        Value::Status(status)
1736    });
1737
1738    // Variable declaration. `local` (Src/builtin.c bin_local) handles
1739    // the scope chain (`pm->old = oldpm` at Src/params.c:1137 inside
1740    // createparam, `pm->level = locallevel` at Src/builtin.c:2576).
1741    // `typeset` / `declare` are aliases — fusevm maps both to
1742    // BUILTIN_TYPESET; compile_zsh special-cases `declare` to keep
1743    // the `declare:` error prefix.
1744    reg_passthru!(vm, BUILTIN_LOCAL, "local");
1745    reg_passthru!(vm, BUILTIN_TYPESET, "typeset");
1746
1747    reg_passthru!(vm, BUILTIN_DECLARE, "declare");
1748    reg_passthru!(vm, BUILTIN_READONLY, "readonly");
1749    reg_passthru!(vm, BUILTIN_INTEGER, "integer");
1750    reg_passthru!(vm, BUILTIN_FLOAT, "float");
1751    reg_passthru!(vm, BUILTIN_READ, "read");
1752    // c:Bug #504 — fusevm reserves BUILTIN_MAPFILE for the bash
1753    // mapfile/readarray builtins. Neither exists in zsh; in --zsh
1754    // parity mode the dispatch must emit "command not found" + rc=127
1755    // matching zsh's external-command-lookup miss. The previous wiring
1756    // left BUILTIN_MAPFILE unregistered, so fusevm's VM treated the op
1757    // as a no-op rc=0 — `mapfile` (and `readarray`) silently succeeded
1758    // in --zsh mode. The host gate in `dispatch_builtin_raw` never
1759    // fired because the compile path emitted `Op::CallBuiltin(31, ..)`
1760    // directly. Register the slot so the gate runs (or a future
1761    // non-zsh mode can wire in a real impl).
1762    vm.register_builtin(fusevm::shell_builtins::BUILTIN_MAPFILE, |vm, argc| {
1763        let args = pop_args(vm, argc);
1764        // The fusevm name→id map collapses both `mapfile` and
1765        // `readarray` to the same opcode; pick the right diagnostic
1766        // by sniffing the user's actual invocation. The xtrace ARGS
1767        // push earlier records the cmd-prefix as the bottom of the
1768        // popped argv, but `args` here excludes the prefix — so we
1769        // can't recover the user-typed name from the stack. Default
1770        // to `mapfile` (the more-common spelling); both produce
1771        // identical diagnostics in any case.
1772        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
1773            eprintln!("zsh:1: command not found: mapfile");
1774            let _ = args;
1775            return Value::Status(127);
1776        }
1777        // Non-zsh modes (bash drop-in): mapfile / readarray reads lines
1778        // from stdin (or `-u fd`) into an array. Handled by the ported
1779        // bash builtin in ext_builtins.
1780        Value::Status(crate::extensions::ext_builtins::readarray(&args))
1781    });
1782    reg_passthru!(vm, BUILTIN_BREAK, "break");
1783    reg_passthru!(vm, BUILTIN_CONTINUE, "continue");
1784    reg_passthru!(vm, BUILTIN_SHIFT, "shift");
1785
1786    vm.register_builtin(BUILTIN_EVAL, |vm, argc| {
1787        // Direct port of `bin_eval(UNUSED(char *nam), char **argv, UNUSED(Options ops), UNUSED(int func))` body from Src/builtin.c:6151:
1788        //   `if (!*argv) return 0;`
1789        //   `prog = parse_string(zjoin(argv, ' ', 1), 1);`
1790        //   `execode(prog, 1, 0, "eval");`
1791        // The execode invocation lives here (not in the canonical
1792        // free-fn) because it must run through the bytecode VM's
1793        // current executor — the same VM that's mid-dispatch.
1794        let mut args = pop_args(vm, argc);
1795        // c:Src/builtin.c:407-411 — generic `--` end-of-options
1796        // strip applied by `execbuiltin` for builtins that have
1797        // NULL optstr AND no BINF_HANDLES_OPTS. `eval` qualifies
1798        // (Src/builtin.c:65 `BUILTIN("eval", BINF_PSPECIAL, ...,
1799        // NULL, NULL)`). The BUILTIN_EVAL fast-path bypasses
1800        // execbuiltin, so we mirror the strip inline. Bug #319.
1801        if args.first().is_some_and(|s| s == "--") {
1802            args.remove(0);
1803        }
1804        if args.is_empty() {
1805            return Value::Status(0); // c:6160
1806        }
1807        let src = args.join(" "); // c:6166
1808                                  // c:Src/builtin.c:6164-6165 — `if (!ineval) scriptname =
1809                                  // "(eval)";`. Diagnostics emitted while the eval body runs
1810                                  // (command-not-found, parse errors, etc.) use scriptname as
1811                                  // the source-context prefix. Without setting it here the
1812                                  // BUILTIN_EVAL fast-path leaked the outer "zsh" prefix
1813                                  // through, breaking the `(eval):N:` convention zsh uses
1814                                  // for in-eval errors. Bug #420.
1815                                  // c:Src/builtin.c:6209 — `execode(prog, 1, 0, "eval");`. execode
1816                                  // (c:Src/exec.c:1245-1266) APPENDS its context argument to
1817                                  // `zsh_eval_context` for the duration of the body, so code inside
1818                                  // `eval` sees `cmdarg:eval` (and `cmdarg:shfunc:eval` when the eval is
1819                                  // in a function). zshrs pushed "shfunc" and, since #1065, "cmdsubst",
1820                                  // but never "eval". Popped on every return path by the guard, matching
1821                                  // execode's stack discipline. Bug #1065 (eval leg).
1822        let _eval_ctx_guard = crate::ported::exec::EvalContextFrame::push("eval");
1823        // c:Src/builtin.c:6163-6178 — `eval` pushes a funcstack frame named
1824        // "(eval)" (tp = FS_EVAL), gated on `ineval = !isset(EVALLINENO)` /
1825        // `if (!ineval)` — i.e. pushed when EVAL_LINENO is SET, which is the
1826        // zsh default. zshrs already set `scriptname = "(eval)"` (below) but
1827        // never pushed the frame, so `eval '…${#funcstack}'` reported 0 where
1828        // zsh reports 1, and inside a function `${(j:,:)funcstack}` was `f`
1829        // rather than `(eval),f`. Both shells already agreed under
1830        // `unsetopt evallineno` (no frame), so the option gate is load-bearing
1831        // and is mirrored here. Popped on every return path by the guard.
1832        // Bug #1066.
1833        // The push itself is the canonical port (`EvalFuncstackFrame::push`,
1834        // exec.rs, c:6155-6193) — shared with `eval_string`, which the
1835        // compsys `_dispatch` port uses for its `eval "$comp"` sites so both
1836        // eval entry points produce a byte-identical `(eval)` frame. It was
1837        // inline here and set `lineno`/`flineno` to 0 with no `filename`,
1838        // which C computes at c:6161 / c:6174-6188 — so `$functrace` read
1839        // `<caller>:0` and `$funcfiletrace` lost the defining file.
1840        let _eval_fs_guard = crate::ported::exec::EvalFuncstackFrame::push();
1841        let oscriptname = crate::ported::utils::scriptname_get();
1842        crate::ported::utils::set_scriptname(Some("(eval)".to_string()));
1843        // Recursion backstop — c:Src/jobs.c:1878-1884. zsh caps eval recursion
1844        // via its job table (every eval'd pipeline grabs a job slot; the table
1845        // caps at MAX_MAXJOBS → "job table full or recursion limit exceeded").
1846        // The fusevm runtime allocates no job per pipeline, and nested evals
1847        // push no funcstack frame (INEVAL suppression, c:6164), so eval nesting
1848        // is invisible to both the job table AND FUNCNEST/FUNCSTACK — runaway
1849        // `eval`-string recursion overflowed the 256 MB main-thread stack →
1850        // uncatchable SIGBUS. Track eval re-entry depth (the Rust proxy for
1851        // held job slots) and refuse at the same MAX_MAXJOBS ceiling.
1852        let eval_depth = crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| {
1853            let v = d.get() + 1;
1854            d.set(v);
1855            v
1856        });
1857        let mut status = if eval_depth >= crate::ported::jobs::MAX_MAXJOBS {
1858            crate::ported::utils::zerr("job table full or recursion limit exceeded");
1859            1
1860        } else {
1861            with_executor(|exec| {
1862                // c:6175 execode
1863                exec.execute_script(&src).unwrap_or(1)
1864            })
1865        };
1866        crate::vm_helper::EVAL_RECURSION_DEPTH.with(|d| d.set(d.get().saturating_sub(1)));
1867        // c:Src/builtin.c:6211-6212 — `if (errflag && !lastval)
1868        //   lastval = errflag;`
1869        // c:Src/builtin.c:6221 — `errflag &= ~ERRFLAG_ERROR;`
1870        // eval is a CONTAINMENT boundary: an error inside the eval
1871        // body (readonly reassign, bad assoc set, ${unset?msg}, …)
1872        // breaks the eval body's lists via errflag, then eval clears
1873        // the flag and returns lastval, and the CALLER's next list
1874        // runs. zsh 5.9: `eval 'assoc=(odd)'; echo "after $?"`
1875        // prints `after 1` in -c, script, and stdin contexts.
1876        {
1877            use std::sync::atomic::Ordering;
1878            let ef = crate::ported::utils::errflag.load(Ordering::Relaxed)
1879                & crate::ported::zsh_h::ERRFLAG_ERROR;
1880            if ef != 0 && status == 0 {
1881                status = ef; // c:6212 lastval = errflag
1882            }
1883            crate::ported::utils::errflag
1884                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
1885        }
1886        crate::ported::utils::set_scriptname(oscriptname);
1887        Value::Status(status)
1888    });
1889
1890    // `builtin foo args…`: precmd-modifier that forces builtin dispatch,
1891    // bypassing alias AND function lookup. Without this, `builtin cd /`
1892    // inside a user `cd () { … }` wrapper recurses (real-world ZPWR pattern).
1893    // Handler pops argc args from the stack, treats args[0] as the builtin
1894    // name, and dispatches the rest via `dispatch_builtin` → `execbuiltin`
1895    // → `bin_*` directly. No function/alias lookup happens.
1896    vm.register_builtin(BUILTIN_BUILTIN, |vm, argc| {
1897        let args = pop_args(vm, argc);
1898        // c:Src/exec.c:3483-3487 — the precommand-modifier walk checks
1899        // `shfunctab` for the command word BEFORE `builtintab`, and only
1900        // skips that check once a prefix has already been consumed
1901        // (`cflags & (BINF_BUILTIN|BINF_COMMAND)`). So on the FIRST word a
1902        // shell function literally named `builtin` shadows the builtin —
1903        // `builtin() { ... }; builtin whence -va x` runs the function.
1904        // zshrs resolves `builtin` at bytecode-compile time (no function
1905        // table yet), so the shadow test has to happen here, at dispatch.
1906        if let Some(status) = try_user_fn_override("builtin", &args) {
1907            return Value::Status(status);
1908        }
1909        let Some((name, rest)) = args.split_first() else {
1910            // `builtin` with no args → list builtins (zsh emits nothing,
1911            // exit 0). Match that behavior; the BIN_BUILTIN bin_* in C
1912            // does the same default-list-nothing.
1913            return Value::Status(0);
1914        };
1915        // zshrs extension builtins (daemon z* family: zd, zcache, zjob,
1916        // …) are dispatched by name via try_dispatch instead of living
1917        // in builtintab — but they ARE builtins, so the `builtin`
1918        // precommand must reach them (`builtin zd ping` errored
1919        // "no such builtin: zd" while bare `zd ping` worked).
1920        if crate::daemon::builtins::is_zshrs_builtin(name) {
1921            let argv: Vec<String> = std::iter::once(name.to_string())
1922                .chain(rest.iter().cloned())
1923                .collect();
1924            return Value::Status(crate::daemon::builtins::try_dispatch(name, &argv).unwrap_or(1));
1925        }
1926        // c:Src/exec.c:3435-3436 — `builtin NAME` with NAME not in
1927        // builtintab emits `zwarn("no such builtin: %s", cmdarg)`
1928        // and returns 1. zshrs's dispatch_builtin_raw bare-returned 1
1929        // silently. Probe the table here so the diagnostic fires
1930        // before dispatch.
1931        let tab = crate::ported::builtin::createbuiltintable();
1932        if !tab.contains_key(name.as_str()) {
1933            // zshrs-original opcode builtins (async, doctor, peach, …) aren't
1934            // in builtintab; `builtin NAME` must still reach them.
1935            if let Some(status) = try_run_registered_builtin(name, rest) {
1936                return Value::Status(status);
1937            }
1938            // c:Src/exec.c:3436 — `zwarn("no such builtin: %s", cmdarg);`.
1939            // Route through the ported `zwarn` rather than formatting the
1940            // prefix by hand: zwarn emits zsh's `zsh:LINE:` prefix, and the
1941            // hand-rolled `eprintln!` here printed `zshrs:1:` instead. Of the
1942            // twelve error shapes probed this was the ONLY one carrying the
1943            // wrong prefix — the ported twin at exec.rs:9878 already used
1944            // zwarn correctly, so this was a reimplementation shadowing a
1945            // faithful port (same shape as #1027 / #1031 / #1044 / #1050).
1946            // Bug #1063.
1947            crate::ported::utils::zwarn(&format!("no such builtin: {}", name));
1948            return Value::Status(1);
1949        }
1950        // `builtin foo` MUST bypass function shadow — that's the whole
1951        // point of the prefix. Use the _raw helper, not the shadow-aware
1952        // one. Without this, `cd () { builtin cd "$@"; }` recurses.
1953        Value::Status(dispatch_builtin_raw(name, rest.to_vec()))
1954    });
1955
1956    // `command foo args…` — BINF_COMMAND prefix (Src/builtin.c:44). Zsh
1957    // semantic: bypass alias+function lookup, search builtin then $PATH.
1958    // Without this, `cd () { command cd "$@" }` would re-invoke the user
1959    // wrapper (same root cause as the `builtin` bug). Flags `-p`/`-v`/`-V`
1960    // route to bin_whence with BIN_COMMAND funcid; bare `command foo`
1961    // dispatches builtin if present, else external (no fork — direct
1962    // spawn via execute_external since zshrs is non-forking).
1963    // BUILTIN_COMMAND — `command [-p] [-v|-V] cmd args…` BIN_PREFIX
1964    // (Src/builtin.c:45). PURE PASSTHRU: prepend "command" and hand
1965    // to `exec::execcmd_compile_head` (the fusevm-bytecode-time head
1966    // resolver mirroring `Src/exec.c::execcmd_exec` precommand-modifier
1967    // walk at c:3104-3187). That helper already does the -p / -v / -V
1968    // option parsing, surfaces `has_command_vv` for the whence
1969    // redirect, and reports the dispatch shape (is_builtin vs external).
1970    vm.register_builtin(BUILTIN_COMMAND, |vm, argc| {
1971        let args = pop_args(vm, argc);
1972        // c:Src/exec.c:3483-3487 — same shfunctab-before-builtintab rule
1973        // as BUILTIN_BUILTIN above: a shell function named `command`
1974        // shadows the `command` precommand modifier on the first word.
1975        if let Some(status) = try_user_fn_override("command", &args) {
1976            return Value::Status(status);
1977        }
1978        let mut full = Vec::with_capacity(args.len() + 1);
1979        full.push("command".to_string());
1980        full.extend(args.clone());
1981        let dispatch =
1982            crate::ported::exec::execcmd_compile_head(&full, crate::ported::zsh_h::WC_SIMPLE);
1983        let post = &full[dispatch.precmd_skip..];
1984        // c:Src/builtin.c:4500 — `command -p` resets PATH for the
1985        // exec to the POSIX-defined default (`getconf PATH`), so
1986        // standard utilities resolve even when the caller has
1987        // emptied $PATH. zsh restores the original PATH after the
1988        // command returns. Mirror via a scoped env::set_var.
1989        //
1990        // command's OWN options end at the first non-flag arg —
1991        // everything after the command name belongs to IT. The
1992        // previous `.any()` scan over ALL args stole `-p` from
1993        // `command mkdir -p DIR` (zconvey.plugin.zsh:44), stripping
1994        // the flag before /bin/mkdir ran → "File exists" errors on
1995        // every re-source.
1996        let mut lead = 0usize;
1997        let mut dash_p = false;
1998        let mut kept_flags: Vec<String> = Vec::new();
1999        for a in post.iter() {
2000            let s = a.as_str();
2001            if s == "--" {
2002                lead += 1;
2003                break;
2004            }
2005            if s.starts_with('-')
2006                && s.len() >= 2
2007                && s[1..].chars().all(|c| c == 'p' || c == 'v' || c == 'V')
2008            {
2009                if s.contains('p') {
2010                    dash_p = true;
2011                }
2012                // -v / -V drive the whence-style lookup downstream —
2013                // keep them in post (only the PATH-reset `p` is
2014                // consumed here).
2015                let rest: String = s[1..].chars().filter(|c| *c != 'p').collect();
2016                if !rest.is_empty() {
2017                    kept_flags.push(format!("-{}", rest));
2018                }
2019                lead += 1;
2020                continue;
2021            }
2022            break;
2023        }
2024        let mut post: Vec<String> = {
2025            let mut v = kept_flags;
2026            v.extend(post[lead..].iter().cloned());
2027            v
2028        };
2029        // c:Src/exec.c:3176-3177 — `BINF_COMMAND` arm strips a single
2030        // leading `--` end-of-options marker.
2031        // `execcmd_compile_head` (src/ported/exec.rs:1042) performs
2032        // this removal on its LOCAL `preargs` Vec but doesn't surface
2033        // the modified args; the caller still sees `--` in `full` and
2034        // tried to dispatch it as the command name. Bug #251. Mirror
2035        // the C strip here so `command -- echo hi` and
2036        // `command -p -- echo hi` route correctly.
2037        if let Some(first) = post.first() {
2038            if first == "--" {
2039                post.remove(0);
2040            }
2041        }
2042        let post = post.as_slice();
2043        let _path_guard = if dash_p {
2044            let saved = env::var("PATH").ok();
2045            let default_path = std::process::Command::new("getconf")
2046                .arg("PATH")
2047                .output()
2048                .ok()
2049                .and_then(|o| String::from_utf8(o.stdout).ok())
2050                .map(|s| s.trim().to_string())
2051                .filter(|s| !s.is_empty())
2052                .unwrap_or_else(|| "/usr/bin:/bin:/usr/sbin:/sbin".to_string());
2053            env::set_var("PATH", &default_path);
2054            crate::ported::params::setsparam("PATH", &default_path);
2055            Some(saved)
2056        } else {
2057            None
2058        };
2059        struct PathGuard {
2060            saved: Option<String>,
2061            active: bool,
2062        }
2063        impl Drop for PathGuard {
2064            fn drop(&mut self) {
2065                if !self.active {
2066                    return;
2067                }
2068                match self.saved.take() {
2069                    Some(p) => {
2070                        env::set_var("PATH", &p);
2071                        crate::ported::params::setsparam("PATH", &p);
2072                    }
2073                    None => {
2074                        env::remove_var("PATH");
2075                        crate::ported::params::setsparam("PATH", "");
2076                    }
2077                }
2078            }
2079        }
2080        let _restore = PathGuard {
2081            saved: _path_guard.unwrap_or(None),
2082            active: dash_p,
2083        };
2084        if dispatch.has_command_vv {
2085            // `-v` / `-V` → bin_whence with BIN_COMMAND funcid.
2086            let mut ops = options {
2087                ind: [0u8; MAX_OPS],
2088                args: Vec::new(),
2089                argscount: 0,
2090                argsalloc: 0,
2091            };
2092            let mut name_pos = 0usize;
2093            let mut flag_byte = b'v';
2094            for (i, a) in post.iter().enumerate() {
2095                if a.starts_with('-') && a.len() >= 2 {
2096                    let body = &a.as_bytes()[1..];
2097                    if body.contains(&b'V') {
2098                        flag_byte = b'V';
2099                    }
2100                    name_pos = i + 1;
2101                } else {
2102                    name_pos = i;
2103                    break;
2104                }
2105            }
2106            ops.ind[flag_byte as usize] = 1;
2107            let whence_args: Vec<String> = post[name_pos..].to_vec();
2108            return Value::Status(crate::ported::builtin::bin_whence(
2109                "command",
2110                &whence_args,
2111                &ops,
2112                crate::ported::hashtable_h::BIN_COMMAND,
2113            ));
2114        }
2115        if dispatch.is_empty_command {
2116            return Value::Status(0);
2117        }
2118        let Some((name, rest)) = post.split_first() else {
2119            return Value::Status(0);
2120        };
2121        // c:Src/exec.c:3275-3278 — `execcmd_compile_head` cleared
2122        // hn for the BINF_COMMAND + !POSIXBUILTINS case, surfacing
2123        // is_builtin=false. Run as external. Under POSIXBUILTINS
2124        // dispatch.is_builtin would be true; honour it.
2125        let n = name.clone();
2126        let r = rest.to_vec();
2127        if dispatch.is_builtin
2128            && crate::ported::builtin::BUILTINS
2129                .iter()
2130                .any(|b| b.node.nam == n.as_str())
2131        {
2132            return Value::Status(dispatch_builtin_raw(&n, r));
2133        }
2134        // c:Src/exec.c:3275-3278 — `command NAME` asks for the thing on
2135        // `PATH`, not the in-process one. The host-registered native commands
2136        // (`extensions/native_cmds.rs`) are caught inside `execute_external`,
2137        // which is this very call, so they are marked as explicitly forced
2138        // past for its duration — matching what `command cat` already does to
2139        // the coreutils shadow.
2140        let _forced = crate::native_cmds::force_external();
2141        Value::Status(with_executor(|exec| exec.execute_external(&n, &r, &[])).unwrap_or(127))
2142    });
2143
2144    // `exec cmd args…` — BINF_EXEC prefix (Src/builtin.c:45). Zsh
2145    // semantic: replace the current shell process with `cmd`. On Unix
2146    // this is `execvp(2)`; the call only returns on error. zshrs is
2147    // non-forking, so the shell process IS the calling process —
2148    // execvp here directly replaces it. Options `-a name` (override
2149    // argv[0]), `-c` (clean env), `-l` (login shell — prepend `-`)
2150    // ported minimally; advanced redirect-only `exec >file` is handled
2151    // upstream by compile_zsh and never reaches this handler.
2152    vm.register_builtin(BUILTIN_EXEC, |vm, argc| {
2153        let mut args = pop_args(vm, argc);
2154        let mut argv0_override: Option<String> = None;
2155        let mut clean_env = false;
2156        let mut login = false;
2157        let mut i = 0;
2158        // c:Src/builtin.c:1075-1080 — track if any flag was consumed.
2159        // `exec -c`, `exec -l`, `exec -a NAME` without a following
2160        // command emit "exec requires a command to execute" rc=1.
2161        // Bare `exec` (no args at all) is the silent-redirect-apply
2162        // form per POSIX.
2163        let mut saw_flag = false;
2164        while i < args.len() {
2165            let a = &args[i];
2166            if a == "--" {
2167                args.remove(i);
2168                break;
2169            }
2170            // c:Src/builtin.c:42 `BIN_PREFIX("-", BINF_DASH)`. A bare
2171            // `-` is its own BINF_PREFIX builtin (BINF_DASH flag —
2172            // "login shell, prepend `-` to argv[0]"). In the canonical
2173            // precmd-walk at Src/exec.c:3056-3091 a bare `-` after
2174            // `exec` is recognized AS a builtin and stripped from
2175            // preargs (precmd_skip++), accumulating BINF_DASH into
2176            // cflags. The fast-path here bypasses execcmd_compile_head,
2177            // so we mirror the strip locally: bare `-` → set login,
2178            // remove, continue. Without this `exec -` (with no command
2179            // following) tried to exec `-` as a literal command and
2180            // exited the shell. Bug #252.
2181            if a == "-" {
2182                saw_flag = true;
2183                login = true;
2184                args.remove(i);
2185                continue;
2186            }
2187            if !a.starts_with('-') || a.len() < 2 {
2188                break;
2189            }
2190            match a.as_str() {
2191                // c:Src/exec.c:3268-3273 — the exec flag word is scanned
2192                // CHARACTER by character (`for (cmdopt = &argdata[1];
2193                // *cmdopt; ++cmdopt)`), and `case 'a'` takes the REST OF
2194                // THE SAME WORD when there is one:
2195                //     if (cmdopt[1]) { exec_argv0 = cmdopt+1;
2196                //                      cmdopt += strlen(cmdopt+1); }
2197                // Matching whole words only left `exec -a/bin/SPLOOSH
2198                // /bin/sh -c '…'` (A01grammar.ztst:135) treating the flag
2199                // word itself as the command name.
2200                inline_a if inline_a.starts_with("-a") && inline_a.len() > 2 => {
2201                    saw_flag = true;
2202                    argv0_override = Some(inline_a[2..].to_string()); // c:3269
2203                    args.remove(i);
2204                }
2205                "-a" => {
2206                    saw_flag = true;
2207                    args.remove(i);
2208                    if i < args.len() {
2209                        argv0_override = Some(args.remove(i));
2210                    }
2211                }
2212                "-c" => {
2213                    saw_flag = true;
2214                    clean_env = true;
2215                    args.remove(i);
2216                }
2217                "-l" => {
2218                    saw_flag = true;
2219                    login = true;
2220                    args.remove(i);
2221                }
2222                _ => {
2223                    // c:Src/exec.c:3196-3208 — when an unrecognized
2224                    // `-X`-style arg has NO following arg, the lexer's
2225                    // IS_DASH walk hits the "no next node" branch at
2226                    // c:3199 before the unknown-flag-letter switch at
2227                    // c:3249, so the canonical message is "exec
2228                    // requires a command to execute" rc=1 (verified vs
2229                    // `/opt/homebrew/bin/zsh -fc 'exec --bad'`).
2230                    // Consume the lone flag so the post-loop check
2231                    // fires. When a following arg exists, leave the
2232                    // unknown-flag arg in place — that arg becomes
2233                    // the command name and execution proceeds.
2234                    if args.len() == 1 {
2235                        saw_flag = true;
2236                        args.remove(i);
2237                        continue;
2238                    }
2239                    break;
2240                }
2241            }
2242        }
2243        let Some(cmd) = args.first().cloned() else {
2244            if saw_flag {
2245                // c:Src/builtin.c:1078-1080 — flags consumed but no
2246                // command follows → "exec requires a command to
2247                // execute" rc=1.
2248                eprintln!("zshrs:1: exec requires a command to execute");
2249                return Value::Status(1);
2250            }
2251            // `exec` with no command + no redirects = no-op success.
2252            return Value::Status(0);
2253        };
2254        let rest: Vec<String> = args[1..].to_vec();
2255        let display_argv0 = match argv0_override {
2256            Some(a) => a,
2257            None => {
2258                if login {
2259                    format!("-{}", cmd)
2260                } else {
2261                    cmd.clone()
2262                }
2263            }
2264        };
2265        // c:Src/exec.c:3468/3582 — execcmd bails out before running anything
2266        // once a redirection has failed: the failure calls zerr, which sets
2267        // errflag, and both bail-outs test it. `exec` is not exempt, so
2268        //     exec ls 3>&98; print after
2269        // in zsh reports the bad fd, does NOT run ls, and the SHELL SURVIVES
2270        // to run `print after`. zshrs consumed the flag in
2271        // BUILTIN_EXEC_PERM_REDIRS (returning status 1) but then dispatched
2272        // the command regardless — replacing the shell with it, so anything
2273        // after the exec never ran, and `exec 99>&98` reported a spurious
2274        // `command not found: 99` for the leftover fd word.
2275        if with_executor(|exec| {
2276            let f = exec.redirect_failed;
2277            exec.redirect_failed = false;
2278            f
2279        }) {
2280            vm.last_status = 1;
2281            return Value::Status(1);
2282        }
2283
2284        // c:Src/exec.c::execcmd — `exec funcname` runs the function
2285        // in-process as the shell's last act, then exits with the
2286        // function's status. zsh's dispatcher falls through from the
2287        // BINF_EXEC prefix into the normal Builtin/External/Function
2288        // resolution and only execvp's if the target ISN'T a
2289        // function. Bug #101 in docs/BUGS.md: zshrs's exec went
2290        // straight to execvp and errored `not found` for shell
2291        // functions.
2292        //
2293        // For both subshell and top-level contexts: dispatch through
2294        // the function/builtin lookup first; only fall through to
2295        // execvp/spawn if the name isn't shell-resolvable.
2296        let has_user_fn = with_executor(|exec| exec.functions_compiled.contains_key(&cmd));
2297        if has_user_fn {
2298            let status =
2299                with_executor(|exec| exec.dispatch_function_call(&cmd, &rest).unwrap_or(127));
2300            // Top-level `exec funcname` — exit the shell with the
2301            // function's status (mirrors C's "exec replaces shell as
2302            // last act"). Subshell `(exec funcname)` — return through
2303            // the EXIT_PENDING path so the subshell body aborts and
2304            // the parent resumes via subshell_end.
2305            let in_subshell_now = with_executor(|exec| !exec.subshell_snapshots.is_empty());
2306            if in_subshell_now {
2307                crate::ported::builtin::EXIT_VAL
2308                    .store(status, std::sync::atomic::Ordering::Relaxed);
2309                crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
2310                return Value::Status(status);
2311            }
2312            std::process::exit(status);
2313        }
2314        // c:Src/exec.c — builtin path: `exec builtin` runs the
2315        // builtin in-process and exits.
2316        let bn_in_tab = crate::ported::builtin::createbuiltintable().contains_key(&cmd);
2317        if bn_in_tab {
2318            let status = dispatch_builtin_raw(&cmd, rest.clone());
2319            let in_subshell_now = with_executor(|exec| !exec.subshell_snapshots.is_empty());
2320            if in_subshell_now {
2321                crate::ported::builtin::EXIT_VAL
2322                    .store(status, std::sync::atomic::Ordering::Relaxed);
2323                crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
2324                return Value::Status(status);
2325            }
2326            std::process::exit(status);
2327        }
2328        // c:Src/exec.c — `exec` inside a subshell (`(exec cmd)`)
2329        // replaces ONLY the subshell child process; the parent shell
2330        // continues. C zsh always forks for `(...)`, so the actual
2331        // execvp lands in the forked child. zshrs runs subshells via
2332        // a snapshot/restore pattern in the SAME process — calling
2333        // execvp here would replace the parent too. Bug #94 in
2334        // docs/BUGS.md.
2335        //
2336        // Detect subshell context via the non-empty
2337        // `subshell_snapshots` stack. When in a subshell: spawn the
2338        // command as a child, wait for it, then signal the subshell
2339        // body to abort (return Status(N) and the caller's
2340        // subshell_end will pop the snapshot and resume the parent).
2341        let in_subshell = with_executor(|exec| !exec.subshell_snapshots.is_empty());
2342        if in_subshell {
2343            let mut command = std::process::Command::new(&cmd);
2344            command.arg0(&display_argv0);
2345            command.args(&rest);
2346            if clean_env {
2347                command.env_clear();
2348            }
2349            // Queue signals across spawn+wait so the SIGCHLD reaper
2350            // can't reap this child before child.wait() does — see
2351            // ForegroundWaitGuard.
2352            let _wait_guard = ForegroundWaitGuard::enter();
2353            let status = match command.spawn() {
2354                Ok(mut child) => match child.wait() {
2355                    Ok(s) => s.code().unwrap_or(127),
2356                    Err(_) => 127,
2357                },
2358                Err(e) => {
2359                    // c:Src/exec.c:797 — `zerr("%e: %s", lerrno, arg0)`
2360                    //                     when arg0 contains `/`.
2361                    // c:872-876 — when arg0 has no `/` (PATH search
2362                    //              path), C tracks the "good" errno
2363                    //              via `isgooderr`; if all PATH entries
2364                    //              were ENOENT-not-good, eno stays 0
2365                    //              and C emits `command not found: %s`
2366                    //              instead of strerror.
2367                    // %e expands to strerror(errno) with the first
2368                    // letter lowercased (unless errno == EIO; see
2369                    // Src/utils.c:362-368). `zerr` prepends the
2370                    // scriptname:lineno: prefix — matching zsh's
2371                    // canonical `zsh:N: <errmsg>: <cmd>` pattern.
2372                    // Previously emitted `zshrs: exec: {}: not found`
2373                    // (wrong prefix, hardcoded message, missing
2374                    // lineno). Bug #140 in docs/BUGS.md.
2375                    let errno = e.raw_os_error().unwrap_or(libc::ENOENT);
2376                    let has_slash = cmd.contains('/');
2377                    if !has_slash && errno == libc::ENOENT {
2378                        // c:876 — PATH search exhausted with no good
2379                        // errno → `command not found: arg0`.
2380                        crate::ported::utils::zerr(&format!("command not found: {}", cmd));
2381                    } else {
2382                        let mut errmsg = crate::ported::compat::strerror(errno);
2383                        if errno != libc::EIO {
2384                            if let Some(c) = errmsg.chars().next() {
2385                                errmsg = format!(
2386                                    "{}{}",
2387                                    c.to_ascii_lowercase(),
2388                                    &errmsg[c.len_utf8()..]
2389                                );
2390                            }
2391                        }
2392                        crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
2393                    }
2394                    // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
2395                    if errno == libc::EACCES || errno == libc::ENOEXEC {
2396                        126
2397                    } else {
2398                        127
2399                    }
2400                }
2401            };
2402            // Mark the subshell as exec-replaced so subsequent body
2403            // commands skip — mirrors the post-execvp "child process
2404            // is gone" reality in C. EXIT_PENDING + EXIT_VAL drive
2405            // the next ERREXIT_CHECK to unwind to the subshell-end
2406            // patch.
2407            crate::ported::builtin::EXIT_VAL.store(status, std::sync::atomic::Ordering::Relaxed);
2408            crate::ported::builtin::EXIT_PENDING.store(1, std::sync::atomic::Ordering::Relaxed);
2409            return Value::Status(status);
2410        }
2411        let mut command = std::process::Command::new(&cmd);
2412        command.arg0(&display_argv0);
2413        command.args(&rest);
2414        if clean_env {
2415            command.env_clear();
2416        }
2417        use std::os::unix::process::CommandExt;
2418        // `exec` returns the OS error iff exec(2) failed; on success
2419        // it never returns. Match zsh: print the error to stderr with
2420        // the `exec` prefix and exit 127 (cmd not found) or 126 (not
2421        // executable).
2422        let err = command.exec();
2423        // c:Src/exec.c:797 / c:872-876 — same format as in-subshell
2424        // branch. arg0-has-/ → `<strerror>: <cmd>`; arg0-no-/ +
2425        // ENOENT → `command not found: <cmd>`. Lowercase strerror
2426        // first letter unless EIO. Bug #140 in docs/BUGS.md.
2427        let errno = err.raw_os_error().unwrap_or(libc::ENOENT);
2428        let has_slash = cmd.contains('/');
2429        if !has_slash && errno == libc::ENOENT {
2430            crate::ported::utils::zerr(&format!("command not found: {}", cmd));
2431        } else {
2432            let mut errmsg = crate::ported::compat::strerror(errno);
2433            if errno != libc::EIO {
2434                if let Some(c) = errmsg.chars().next() {
2435                    errmsg = format!("{}{}", c.to_ascii_lowercase(), &errmsg[c.len_utf8()..]);
2436                }
2437            }
2438            crate::ported::utils::zerr(&format!("{}: {}", errmsg, cmd));
2439        }
2440        // c:881 — `_exit((eno == EACCES || eno == ENOEXEC) ? 126 : 127);`
2441        let code = if errno == libc::EACCES || errno == libc::ENOEXEC {
2442            126
2443        } else {
2444            127
2445        };
2446        std::process::exit(code);
2447    });
2448
2449    reg_passthru!(vm, BUILTIN_LET, "let");
2450
2451    // Job control
2452    reg_passthru!(vm, BUILTIN_JOBS, "jobs");
2453    reg_passthru!(vm, BUILTIN_FG, "fg");
2454    reg_passthru!(vm, BUILTIN_BG, "bg");
2455    reg_passthru!(vm, BUILTIN_KILL, "kill");
2456    reg_passthru!(vm, BUILTIN_DISOWN, "disown");
2457    reg_passthru!(vm, BUILTIN_WAIT, "wait");
2458    reg_passthru!(vm, BUILTIN_SUSPEND, "suspend");
2459
2460    // History — `fc` / `history` / `r` all route to `bin_fc` (zsh
2461    // registers them as aliases of the same builtin per Src/builtin.c).
2462    reg_passthru!(vm, BUILTIN_FC, "fc");
2463    reg_passthru!(vm, BUILTIN_HISTORY, "history");
2464    reg_passthru!(vm, BUILTIN_R, "r");
2465
2466    // Aliases — alias is `BINF_MAGICEQUALS` per Src/builtin.c:50.
2467    // c:Src/exec.c:3298-3304 — when a builtin has BINF_MAGICEQUALS,
2468    // execcmd_exec sets esprefork = PREFORK_TYPESET and calls
2469    // `prefork(args, esprefork, NULL)` on the argv. prefork (subst.c:
2470    // 100) drives `filesub` on each word (c:133), which (c:677-686)
2471    // looks for the assignment Equals and runs `filesubstr` on the
2472    // VALUE side. That's how `alias bad===` triggers equalsubstr's
2473    // "= not found" via the inner Equals after the first `=`.
2474    //
2475    // The fusevm dispatch path doesn't go through execcmd_exec, so
2476    // BUILTIN_ALIAS previously passed args straight to bin_alias with
2477    // no expansion — `alias x=~/foo` stored literal `~/foo` (no tilde
2478    // expand), `alias bad===` stored a broken entry without firing
2479    // the "= not found" diagnostic. The prefork(PREFORK_TYPESET) runs
2480    // per arg word via BUILTIN_MAGIC_EQUALS_PREFORK ops that
2481    // compile_simple emits BEFORE the redirect scope opens (matching
2482    // c:3304 prefork-before-addfd order), so the dispatch here is a
2483    // plain passthrough — re-running prefork would double-fire the
2484    // "= not found" diagnostic.
2485    reg_passthru!(vm, BUILTIN_ALIAS, "alias");
2486    // c:Src/exec.c:3298-3304 — per-word magic-equals prefork; see the
2487    // const doc at BUILTIN_MAGIC_EQUALS_PREFORK. prefork's filesub
2488    // trigger (subst.c:678 `strchr(*namptr+1, Equals)`) looks for the
2489    // EQUALS TOKEN, not literal `=`. The fusevm path delivers args
2490    // already-untokenized, so re-tokenize each element via
2491    // `shtokenize` (the same call C's lexer makes implicitly when
2492    // assembling the word) so prefork sees Equals tokens at `=`
2493    // boundaries and Tilde tokens at `~` starts. After prefork
2494    // expands, untokenize for storage.
2495    vm.register_builtin(BUILTIN_MAGIC_EQUALS_PREFORK, |vm, _argc| {
2496        let raw = vm.pop();
2497        let inputs: Vec<String> = match raw {
2498            Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
2499            other => vec![other.to_str()],
2500        };
2501        let mut as_linklist: crate::ported::linklist::LinkList<String> = Default::default();
2502        for s in &inputs {
2503            let mut tokd = s.clone();
2504            crate::ported::glob::shtokenize(&mut tokd);
2505            as_linklist.push_back(tokd);
2506        }
2507        let mut rf = 0i32;
2508        crate::ported::subst::prefork(
2509            &mut as_linklist,
2510            crate::ported::zsh_h::PREFORK_TYPESET,
2511            &mut rf,
2512        );
2513        let mut expanded: Vec<String> = Vec::with_capacity(inputs.len());
2514        while let Some(s) = as_linklist.pop_front() {
2515            expanded.push(crate::ported::lex::untokenize(&s).to_string());
2516        }
2517        if expanded.len() == 1 {
2518            Value::str(expanded.into_iter().next().unwrap())
2519        } else {
2520            Value::array(expanded.into_iter().map(Value::str).collect())
2521        }
2522    });
2523
2524    // Options. `setopt` (BIN_SETOPT=0) / `unsetopt` (BIN_UNSETOPT=1)
2525    // share bin_setopt (options.c:580) — funcid bit discriminates
2526    // the polarity via BUILTINS table entries.
2527    reg_passthru!(vm, BUILTIN_SET, "set");
2528    reg_passthru!(vm, BUILTIN_SETOPT, "setopt");
2529    reg_passthru!(vm, BUILTIN_UNSETOPT, "unsetopt");
2530
2531    vm.register_builtin(BUILTIN_SHOPT, |vm, argc| {
2532        let args = pop_args(vm, argc);
2533        // `shopt` is a BASH builtin; no shell in the zsh family has it
2534        // (c:Src/builtin.c:40-137 `builtins[]` has no `shopt` row), so
2535        // outside bash drop-in mode the name must resolve through PATH
2536        // like any other unknown command — `zsh -fc shopt` →
2537        // `zsh:1: command not found: shopt`, rc 127.
2538        //
2539        // compile_zsh:3109 already forces `builtin_id = None` for the
2540        // literal name so the compile-time `CallBuiltin` fast path is
2541        // skipped, but that guard alone stopped working: the emitted
2542        // `Op::CallFunction` reaches fusevm's run-time
2543        // `VM::run_builtin_by_name` (fusevm-0.23.0/src/vm.rs:3348 →
2544        // :979) once the host's `call_function` answers None, and THAT
2545        // resolves the name straight back to this registered handler.
2546        // mapfile / readarray / compopt survive only because their
2547        // opcodes gate on the mode inside the handler; do the same
2548        // here. Mirrors the BUILTIN_COMPGEN / BUILTIN_COMPLETE gates
2549        // below, including their user-function precedence: a shell
2550        // function named `shopt` (bashcompinit-style shims define one)
2551        // still wins over the fall-through.
2552        if !crate::extensions::dash_mode::bash_mode() {
2553            if crate::ported::utils::getshfunc("shopt").is_some() {
2554                let status = with_executor(|exec| exec.dispatch_function_call("shopt", &args))
2555                    .unwrap_or(127);
2556                return Value::Status(status);
2557            }
2558            // PATH lookup with the literal name, so the diagnostic and
2559            // the status are the shell's own external-miss ones
2560            // (`zsh:1:` under --zsh, `zshrs:1:` natively) rather than a
2561            // second hand-rolled spelling of them.
2562            let status =
2563                with_executor(|exec| exec.execute_external("shopt", &args, &[])).unwrap_or(127);
2564            return Value::Status(status);
2565        }
2566        let status = crate::extensions::ext_builtins::shopt(&args);
2567        Value::Status(status)
2568    });
2569
2570    reg_passthru!(vm, BUILTIN_EMULATE, "emulate");
2571    reg_passthru!(vm, BUILTIN_GETOPTS, "getopts");
2572    reg_passthru!(vm, BUILTIN_AUTOLOAD, "autoload");
2573    reg_passthru!(vm, BUILTIN_FUNCTIONS, "functions");
2574    reg_passthru!(vm, BUILTIN_TRAP, "trap");
2575    reg_passthru!(vm, BUILTIN_DIRS, "dirs");
2576    // pushd / popd dispatch through canonical bin_cd via execbuiltin
2577    // — the BUILTINS table at src/ported/builtin.rs:9298 wires
2578    // `pushd` to bin_cd with funcid=BIN_PUSHD, and `popd` similarly
2579    // with BIN_POPD. Without these reg_passthru lines the fusevm
2580    // BUILTIN_PUSHD/POPD opcodes had no handler installed, so the
2581    // emitted CallBuiltin(110, …) silently returned a no-op and the
2582    // dirstack/$dirstack/pwd all stayed unchanged.
2583    reg_passthru!(vm, BUILTIN_PUSHD, "pushd");
2584    reg_passthru!(vm, BUILTIN_POPD, "popd");
2585    // type / whence / where / which all route through `bin_whence`
2586    // (canonical port at `src/ported/builtin.rs:3734` of
2587    // `Src/builtin.c:3975`). Each gets its own opcode so funcid +
2588    // defopts come from the BUILTINS table entry — execbuiltin
2589    // applies them correctly via the module-level dispatch_builtin.
2590    reg_passthru!(vm, BUILTIN_WHENCE, "whence");
2591    reg_passthru!(vm, BUILTIN_TYPE, "type");
2592    reg_passthru!(vm, BUILTIN_WHICH, "which");
2593    reg_passthru!(vm, BUILTIN_WHERE, "where");
2594    reg_passthru!(vm, BUILTIN_HASH, "hash");
2595    reg_passthru!(vm, BUILTIN_REHASH, "rehash");
2596
2597    // `unhash`/`unalias`/`unfunction` share `bin_unhash` (Src/builtin.c
2598    // c:4350) but each carries its own funcid (BIN_UNHASH /
2599    // BIN_UNALIAS / BIN_UNFUNCTION) in the BUILTINS table.
2600    reg_passthru!(vm, BUILTIN_UNHASH, "unhash");
2601    vm.register_builtin(BUILTIN_UNALIAS, |vm, argc| {
2602        let args = pop_args(vm, argc);
2603        Value::Status(dispatch_builtin("unalias", args))
2604    });
2605    vm.register_builtin(BUILTIN_UNFUNCTION, |vm, argc| {
2606        let args = pop_args(vm, argc);
2607        Value::Status(dispatch_builtin("unfunction", args))
2608    });
2609
2610    // Completion
2611    vm.register_builtin(BUILTIN_COMPGEN, |vm, argc| {
2612        let args = pop_args(vm, argc);
2613        // c:Bug #475/#555 — `compgen` is a bash-only builtin. In
2614        // `--zsh` mode emit "command not found" matching zsh's
2615        // external-command lookup miss — UNLESS a user FUNCTION of
2616        // that name exists: zsh has no such builtin, so bashcompinit's
2617        // `compgen() {...}` definition wins the dispatch there. The
2618        // unconditional 127 shadowed it and broke every
2619        // bashcompinit-style completion file (zsh-more-completions
2620        // _msync/_gocomplete/_qshell/_cw), spraying "command not
2621        // found: complete" at every deferred compinit load.
2622        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2623            if crate::ported::utils::getshfunc("compgen").is_some() {
2624                let status = with_executor(|exec| exec.dispatch_function_call("compgen", &args))
2625                    .unwrap_or(127);
2626                return Value::Status(status);
2627            }
2628            eprintln!("zsh:1: command not found: compgen");
2629            let _ = args;
2630            return Value::Status(127);
2631        }
2632        let status = with_executor(|exec| exec.builtin_compgen(&args));
2633        Value::Status(status)
2634    });
2635
2636    vm.register_builtin(BUILTIN_COMPLETE, |vm, argc| {
2637        let args = pop_args(vm, argc);
2638        // c:Bug #475 — `complete` is a bash-only builtin. Same gate +
2639        // user-function precedence as BUILTIN_COMPGEN above.
2640        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2641            if crate::ported::utils::getshfunc("complete").is_some() {
2642                let status = with_executor(|exec| exec.dispatch_function_call("complete", &args))
2643                    .unwrap_or(127);
2644                return Value::Status(status);
2645            }
2646            eprintln!("zsh:1: command not found: complete");
2647            let _ = args;
2648            return Value::Status(127);
2649        }
2650        let status = with_executor(|exec| exec.builtin_complete(&args));
2651        Value::Status(status)
2652    });
2653
2654    reg_passthru!(vm, BUILTIN_COMPADD, "compadd");
2655    reg_passthru!(vm, BUILTIN_COMPSET, "compset");
2656
2657    // See the const's doc comment for the contract. Stack (bottom→top):
2658    // base, e1, …, eN — argc = N + 1.
2659    vm.register_builtin(BUILTIN_TYPESET_PAREN_PACK, |vm, argc| {
2660        let mut vals: Vec<Value> = Vec::with_capacity(argc as usize);
2661        for _ in 0..argc {
2662            vals.push(vm.pop());
2663        }
2664        vals.reverse();
2665        let mut it = vals.into_iter();
2666        let mut out = it.next().map(|v| v.to_str()).unwrap_or_default();
2667        for v in it {
2668            match v {
2669                // Array → splice items as separate elements (splat);
2670                // empty array contributes nothing (empty elision).
2671                Value::Array(items) => {
2672                    for item in items.iter() {
2673                        out.push('\u{1f}');
2674                        out.push_str(&item.to_str());
2675                    }
2676                }
2677                other => {
2678                    out.push('\u{1f}');
2679                    out.push_str(&other.to_str());
2680                }
2681            }
2682        }
2683        Value::str(out)
2684    });
2685
2686    vm.register_builtin(BUILTIN_TYPESET_PAREN_CLOSE, |vm, _argc| {
2687        let base = vm.pop().to_str();
2688        Value::str(format!("{}\u{1f})", base))
2689    });
2690
2691    vm.register_builtin(BUILTIN_COMPDEF, |vm, argc| {
2692        let args = pop_args(vm, argc);
2693        // ACTUALLY A ZSH FUNCTION: compdef is defined by `compinit`, it is
2694        // never a builtin. Without the completion system set up it is
2695        // command-not-found (127) in every mode — `zsh -f; compdef` prints
2696        // "command not found: compdef". A user/compsys `compdef` FUNCTION
2697        // (autoload compinit → compinit defines compdef) wins and runs the
2698        // fast native impl; otherwise it's command-not-found. Previously the
2699        // extension builtin ran in native mode (bare `compdef` → "I need
2700        // arguments"), diverging from zsh.
2701        // compinit installs a `compdef` function stub (see
2702        // NATIVE_COMPDEF_MARKER) purely so `${+functions[compdef]}` is
2703        // true; route that exact body to the fast native impl instead of
2704        // dispatching the stub. A genuine user/compsys compdef function
2705        // (any other body) still wins via try_user_fn_override below.
2706        let is_native_stub = crate::ported::hashtable::shfunctab_lock()
2707            .read()
2708            .ok()
2709            .and_then(|t| t.get("compdef").and_then(|shf| shf.body.clone()))
2710            .map(|b| b.trim() == crate::extensions::ext_builtins::NATIVE_COMPDEF_MARKER)
2711            .unwrap_or(false);
2712        if is_native_stub {
2713            return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
2714        }
2715        if let Some(s) = try_user_fn_override("compdef", &args) {
2716            return Value::Status(s);
2717        }
2718        if with_executor(|exec| exec.function_exists("compdef")) {
2719            return Value::Status(with_executor(|exec| exec.builtin_compdef(&args)));
2720        }
2721        eprintln!("zsh:1: command not found: compdef");
2722        Value::Status(127)
2723    });
2724
2725    vm.register_builtin(BUILTIN_COMPINIT, |vm, argc| {
2726        let args = pop_args(vm, argc);
2727        // ACTUALLY A ZSH FUNCTION: compinit is a contrib FUNCTION (autoloaded
2728        // from $fpath), never a builtin. Without `autoload -Uz compinit` it is
2729        // command-not-found
2730        // (127) in every mode — `zsh -f; compinit` prints
2731        // "command not found: compinit". zshrs previously ran its builtin
2732        // unconditionally, so bare `compinit` succeeded. Gate on a compinit
2733        // function entry existing (which `autoload -Uz compinit` creates);
2734        // once the user has autoloaded/defined it, run zshrs's implementation.
2735        if !with_executor(|exec| exec.function_exists("compinit")) {
2736            eprintln!("zsh:1: command not found: compinit");
2737            let _ = args;
2738            return Value::Status(127);
2739        }
2740        Value::Status(with_executor(|exec| exec.builtin_compinit(&args)))
2741    });
2742
2743    reg_ext_overridable!(vm, BUILTIN_CDREPLAY, "cdreplay", builtin_cdreplay);
2744
2745    // Zsh-specific
2746    reg_passthru!(vm, BUILTIN_ZSTYLE, "zstyle");
2747    reg_passthru!(vm, BUILTIN_ZMODLOAD, "zmodload");
2748    reg_passthru!(vm, BUILTIN_BINDKEY, "bindkey");
2749    reg_passthru!(vm, BUILTIN_ZLE, "zle");
2750    reg_passthru!(vm, BUILTIN_VARED, "vared");
2751    reg_passthru!(vm, BUILTIN_ZCOMPILE, "zcompile");
2752    reg_passthru!(vm, BUILTIN_ZFORMAT, "zformat");
2753    reg_passthru!(vm, BUILTIN_ZPARSEOPTS, "zparseopts");
2754    reg_passthru!(vm, BUILTIN_ZREGEXPARSE, "zregexparse");
2755
2756    // Resource limits
2757    reg_passthru!(vm, BUILTIN_ULIMIT, "ulimit");
2758    reg_passthru!(vm, BUILTIN_LIMIT, "limit");
2759    reg_passthru!(vm, BUILTIN_UNLIMIT, "unlimit");
2760    reg_passthru!(vm, BUILTIN_UMASK, "umask");
2761
2762    // Misc
2763    reg_passthru!(vm, BUILTIN_TIMES, "times");
2764
2765    vm.register_builtin(BUILTIN_CALLER, |vm, argc| {
2766        let args = pop_args(vm, argc);
2767        // c:Bug #475 — `caller` is a bash-only builtin. In `--zsh`
2768        // mode emit the canonical "command not found" diagnostic
2769        // and rc=127 matching zsh's external-command-lookup miss.
2770        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2771            eprintln!("zsh:1: command not found: caller");
2772            let _ = args;
2773            return Value::Status(127);
2774        }
2775        Value::Status(with_executor(|exec| exec.builtin_caller(&args)))
2776    });
2777
2778    vm.register_builtin(BUILTIN_HELP, |vm, argc| {
2779        let args = pop_args(vm, argc);
2780        // c:Bug #475 — `help` is a bash-only builtin. Same gate as
2781        // BUILTIN_CALLER above.
2782        if crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) {
2783            eprintln!("zsh:1: command not found: help");
2784            let _ = args;
2785            return Value::Status(127);
2786        }
2787        Value::Status(with_executor(|exec| exec.builtin_help(&args)))
2788    });
2789
2790    reg_passthru!(vm, BUILTIN_ENABLE, "enable");
2791    reg_passthru!(vm, BUILTIN_DISABLE, "disable");
2792    reg_passthru!(vm, BUILTIN_TTYCTL, "ttyctl");
2793    reg_passthru!(vm, BUILTIN_SYNC, "sync");
2794    reg_passthru!(vm, BUILTIN_MKDIR, "mkdir");
2795    reg_passthru!(vm, BUILTIN_STRFTIME, "strftime");
2796
2797    vm.register_builtin(BUILTIN_ZSLEEP, |vm, argc| {
2798        let args = pop_args(vm, argc);
2799        // function > builtin: a user `zsleep() { … }` wins.
2800        if let Some(s) = try_user_fn_override("zsleep", &args) {
2801            return Value::Status(s);
2802        }
2803        Value::Status(crate::extensions::ext_builtins::zsleep(&args))
2804    });
2805
2806    reg_passthru!(vm, BUILTIN_ZSYSTEM, "zsystem");
2807
2808    // PCRE
2809    reg_passthru!(vm, BUILTIN_PCRE_COMPILE, "pcre_compile");
2810    reg_passthru!(vm, BUILTIN_PCRE_MATCH, "pcre_match");
2811    reg_passthru!(vm, BUILTIN_PCRE_STUDY, "pcre_study");
2812
2813    // Database (GDBM)
2814    reg_passthru!(vm, BUILTIN_ZTIE, "ztie");
2815    reg_passthru!(vm, BUILTIN_ZUNTIE, "zuntie");
2816    reg_passthru!(vm, BUILTIN_ZGDBMPATH, "zgdbmpath");
2817
2818    // Prompt
2819    vm.register_builtin(BUILTIN_PROMPTINIT, |vm, argc| {
2820        let args = pop_args(vm, argc);
2821        // ACTUALLY A ZSH FUNCTION: promptinit is a contrib FUNCTION
2822        // (autoloaded from $fpath), never a builtin. Command-not-found until
2823        // `autoload -Uz promptinit`; once autoloaded, run the native impl.
2824        if !with_executor(|exec| exec.function_exists("promptinit")) {
2825            eprintln!("zsh:1: command not found: promptinit");
2826            let _ = args;
2827            return Value::Status(127);
2828        }
2829        Value::Status(crate::extensions::ext_builtins::promptinit(&args))
2830    });
2831
2832    vm.register_builtin(BUILTIN_PROMPT, |vm, argc| {
2833        let args = pop_args(vm, argc);
2834        Value::Status(crate::extensions::ext_builtins::prompt(&args))
2835    });
2836
2837    // Async / Parallel (zshrs extensions) — all overridable by a
2838    // same-named user function (function > builtin).
2839    reg_ext_overridable!(vm, BUILTIN_ASYNC, "async", builtin_async);
2840    reg_ext_overridable!(vm, BUILTIN_AWAIT, "await", builtin_await);
2841    reg_ext_overridable!(vm, BUILTIN_PMAP, "pmap", builtin_pmap);
2842    reg_ext_overridable!(vm, BUILTIN_PGREP, "pgrep", builtin_pgrep);
2843    reg_ext_overridable!(vm, BUILTIN_PEACH, "peach", builtin_peach);
2844    reg_ext_overridable!(vm, BUILTIN_BARRIER, "barrier", builtin_barrier);
2845
2846    // Intercept (AOP)
2847    reg_ext_overridable!(vm, BUILTIN_INTERCEPT, "intercept", builtin_intercept);
2848    reg_ext_overridable!(
2849        vm,
2850        BUILTIN_INTERCEPT_PROCEED,
2851        "intercept_proceed",
2852        builtin_intercept_proceed
2853    );
2854
2855    // Debug / Profile
2856    reg_ext_overridable!(vm, BUILTIN_DOCTOR, "doctor", builtin_doctor);
2857    reg_ext_overridable!(vm, BUILTIN_DBVIEW, "dbview", builtin_dbview);
2858    reg_ext_overridable!(vm, BUILTIN_PROFILE, "profile", builtin_profile);
2859    reg_ext_overridable!(vm, BUILTIN_PROVENANCE, "provenance", builtin_provenance);
2860
2861    reg_passthru!(vm, BUILTIN_ZPROF, "zprof");
2862
2863    // ═══════════════════════════════════════════════════════════════════════
2864    // Coreutils builtins (anti-fork, gated by !posix_mode)
2865    //
2866    // All of these are routinely wrapped by user functions in real
2867    // dotfiles (zpwr, oh-my-zsh, etc.) — `cat() { ... }`, `ls() { ... }`,
2868    // `find() { ... }`. Each handler MUST consult try_user_fn_override
2869    // first (via reg_overridable!) so the user definition wins, matching
2870    // zsh's alias → function → builtin dispatch order.
2871    // ═══════════════════════════════════════════════════════════════════════
2872
2873    reg_overridable!(vm, BUILTIN_CAT, "cat", builtin_cat);
2874    reg_overridable!(vm, BUILTIN_HEAD, "head", builtin_head);
2875    reg_overridable!(vm, BUILTIN_TAIL, "tail", builtin_tail);
2876    reg_overridable!(vm, BUILTIN_WC, "wc", builtin_wc);
2877    reg_overridable!(vm, BUILTIN_BASENAME, "basename", builtin_basename);
2878    reg_overridable!(vm, BUILTIN_DIRNAME, "dirname", builtin_dirname);
2879    reg_overridable!(vm, BUILTIN_TOUCH, "touch", builtin_touch);
2880    reg_overridable!(vm, BUILTIN_REALPATH, "realpath", builtin_realpath);
2881    reg_overridable!(vm, BUILTIN_SORT, "sort", builtin_sort);
2882    reg_overridable!(vm, BUILTIN_FIND, "find", builtin_find);
2883    reg_overridable!(vm, BUILTIN_UNIQ, "uniq", builtin_uniq);
2884    reg_overridable!(vm, BUILTIN_CUT, "cut", builtin_cut);
2885    reg_overridable!(vm, BUILTIN_TR, "tr", builtin_tr);
2886    reg_overridable!(vm, BUILTIN_SEQ, "seq", builtin_seq);
2887    reg_overridable!(vm, BUILTIN_REV, "rev", builtin_rev);
2888    reg_overridable!(vm, BUILTIN_TEE, "tee", builtin_tee);
2889    reg_overridable!(vm, BUILTIN_SLEEP, "sleep", builtin_sleep);
2890    reg_overridable!(vm, BUILTIN_WHOAMI, "whoami", builtin_whoami);
2891    reg_overridable!(vm, BUILTIN_ID, "id", builtin_id);
2892
2893    reg_overridable!(vm, BUILTIN_HOSTNAME, "hostname", builtin_hostname);
2894    reg_overridable!(vm, BUILTIN_UNAME, "uname", builtin_uname);
2895    reg_overridable!(vm, BUILTIN_DATE, "date", builtin_date);
2896    reg_overridable!(vm, BUILTIN_MKTEMP, "mktemp", builtin_mktemp);
2897    // `cp` — zshrs extension (NOT in upstream zsh; upstream's
2898    // zsh/files module ships `ln`/`mv`/`rm`/`chmod`/`chown` but no
2899    // `cp`). In-process implementation in
2900    // `ext_builtins::cp_impl` — recursive copy with -r/-R, -f, -i,
2901    // -n, -p (chown + utimensat), -v. ID 263 is the first slot
2902    // past fusevm's built-in range (260-262) and before BUILTIN_MAX
2903    // (280).
2904    /// `BUILTIN_CP` constant.
2905    pub const BUILTIN_CP: u16 = 263;
2906    reg_overridable!(vm, BUILTIN_CP, "cp", builtin_cp);
2907
2908    // Pipeline execution — bytecode-native fork-per-stage. Pops N sub-chunk
2909    // indices, forks N children with stdin/stdout wired through N-1 pipes,
2910    // each child runs its stage's compiled bytecode and exits. Parent waits
2911    // and returns the last stage's status.
2912    //
2913    // Caveats: post-fork in a multi-threaded program, only async-signal-safe
2914    // ops are POSIX-safe. We violate this (running the bytecode VM after fork
2915    // touches mutexes like REGEX_CACHE). In practice, most pipeline stages
2916    // don't touch shared mutex state — externals fork/exec away, builtins do
2917    // pure I/O. Risks are bounded; if a stage does touch a held mutex, the
2918    // child deadlocks.
2919    vm.register_builtin(BUILTIN_RUN_PIPELINE, |vm, argc| {
2920        let n = argc as usize;
2921        if n == 0 {
2922            return Value::Status(0);
2923        }
2924
2925        // c:Src/exec.c — every pipeline stage forks from the current
2926        // shell state, so each stage observes the pre-pipeline $? until
2927        // it runs its own command. Stage sub-VMs start fresh with
2928        // last_status=0, so seed them with the parent's lastval; without
2929        // this `false; echo $? | cat` prints 0 instead of zsh's 1.
2930        let parent_status = vm.last_status;
2931
2932        // Pop N sub-chunk indices (LIFO → reverse to stage order)
2933        let mut indices: Vec<u16> = Vec::with_capacity(n);
2934        for _ in 0..n {
2935            indices.push(vm.pop().to_int() as u16);
2936        }
2937        indices.reverse();
2938
2939        // Clone each stage's sub-chunk
2940        let stages: Vec<fusevm::Chunk> = indices
2941            .iter()
2942            .filter_map(|&i| vm.chunk.sub_chunks.get(i as usize).cloned())
2943            .collect();
2944        if stages.len() != n {
2945            return Value::Status(1);
2946        }
2947
2948        // Single stage — no pipe, just run inline
2949        if n == 1 {
2950            let stage = stages.into_iter().next().unwrap();
2951            crate::fusevm_disasm::maybe_print_stdout("pipeline:single", &stage);
2952            let mut stage_vm = fusevm::VM::new(stage);
2953            stage_vm.last_status = parent_status;
2954            register_builtins(&mut stage_vm);
2955            let _ = stage_vm.run();
2956            return Value::Status(stage_vm.last_status);
2957        }
2958
2959        // Build N-1 pipes
2960        let mut pipes: Vec<(i32, i32)> = Vec::with_capacity(n - 1);
2961        for _ in 0..n - 1 {
2962            let mut fds = [0i32; 2];
2963            if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
2964                // Cleanup any pipes we already created
2965                for (r, w) in &pipes {
2966                    unsafe {
2967                        libc::close(*r);
2968                        libc::close(*w);
2969                    }
2970                }
2971                return Value::Status(1);
2972            }
2973            pipes.push((fds[0], fds[1]));
2974        }
2975
2976        // zsh runs the LAST stage of a pipeline in the CURRENT shell
2977        // (not a forked child) so a trailing `read x` keeps its
2978        // assignment in the parent. Other shells (bash) fork every
2979        // stage. Honor zsh by leaving stage N-1 inline. Forks the
2980        // first N-1 stages with fork(); runs the last in this process
2981        // with stdin dup2'd to the last pipe's read end and stdout
2982        // restored after.
2983        let last_idx = n - 1;
2984        let stages_vec: Vec<fusevm::Chunk> = stages.into_iter().collect();
2985
2986        let mut child_pids: Vec<libc::pid_t> = Vec::with_capacity(n - 1);
2987        for (i, chunk) in stages_vec.iter().take(last_idx).enumerate() {
2988            match unsafe { libc::fork() } {
2989                -1 => {
2990                    // fork failed — kill any children we already started
2991                    for pid in &child_pids {
2992                        unsafe { libc::kill(*pid, libc::SIGTERM) };
2993                    }
2994                    for (r, w) in &pipes {
2995                        unsafe {
2996                            libc::close(*r);
2997                            libc::close(*w);
2998                        }
2999                    }
3000                    return Value::Status(1);
3001                }
3002                0 => {
3003                    // Reset SIGPIPE to default so a broken-pipe write
3004                    // kills the child cleanly instead of triggering a
3005                    // Rust println! panic. The parent shell ignores
3006                    // SIGPIPE so it can handle EPIPE itself, but child
3007                    // pipeline stages should die quietly when their
3008                    // downstream stage closes early (e.g. `seq | head -3`).
3009                    unsafe {
3010                        libc::signal(libc::SIGPIPE, libc::SIG_DFL);
3011                    }
3012                    // c:Src/exec.c — pipeline children are forked
3013                    // subshells; their EXIT trap context is reset so
3014                    // the parent's `trap '...' EXIT` doesn't fire when
3015                    // the child exits. Mirror by dropping EXIT from
3016                    // the inherited traps_table inside the child.
3017                    // c:Src/exec.c:2917-2918 — a forked PIPELINE stage enters
3018                    // the subshell with ESUB_KEEPTRAP:
3019                    //     if ((type != WC_SUBSH) && !(how & Z_ASYNC))
3020                    //         flags |= ESUB_KEEPTRAP;
3021                    // so entersubsh's c:1127 reset loop is SKIPPED and the
3022                    // stage keeps the parent's traps. Only the EXIT trap goes,
3023                    // so the parent's `trap '…' EXIT` does not fire when the
3024                    // stage exits. Applying the full reset here instead was
3025                    // wrong: it wiped the inherited-SIGQUIT record and the
3026                    // parent's other trap flags inside every pipeline stage.
3027                    //
3028                    // Drop it from BOTH stores, since a body-less entry lives
3029                    // only in sigtrapped and the `trap` listing now reads it
3030                    // (c:Src/builtin.c:7358-7361); clearing just the body left
3031                    // `trap | grep -c EXIT` reporting the stale flag.
3032                    if let Ok(mut tt) = crate::ported::builtin::traps_table().lock() {
3033                        tt.remove("EXIT");
3034                    }
3035                    if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
3036                        if let Some(slot) = st.get_mut(crate::ported::signals_h::SIGEXIT as usize) {
3037                            *slot = 0;
3038                        }
3039                    }
3040                    // c:Src/exec.c:2862 → 1219 — pipeline children run
3041                    // entersubsh with ESUB_PGRP, which clears the job
3042                    // table (clearjobtab, Src/jobs.c:1780). Without
3043                    // this, `sleep 5 & jobs -p | wc -l` reports 1 in
3044                    // the forked stage where zsh reports 0. The fork
3045                    // already copy-isolates the statics, so mutating
3046                    // them here can't leak to the parent.
3047                    with_executor(|exec| {
3048                        let monitor =
3049                            crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
3050                        crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
3051                    });
3052                    // c:Src/exec.c:1153-1154 — the same entersubsh call sets
3053                    // `subsh = 1` in the forked stage. PRINT_EXIT_VALUE reads
3054                    // it (c:4309 `&& !subsh`), which is why zsh prints nothing
3055                    // for the failing stage of `false | true`.
3056                    crate::ported::exec::subsh.store(1, std::sync::atomic::Ordering::Relaxed);
3057                    *crate::ported::jobs::THISJOB
3058                        .get_or_init(|| std::sync::Mutex::new(-1))
3059                        .lock()
3060                        .unwrap() = -1;
3061                    // c:Src/exec.c:3720-3724 — the stage's own fds go
3062                    // onto 0/1 only AFTER its argument words have been
3063                    // expanded (prefork c:3304 / globlist c:3702), so
3064                    // park them and let the stage chunk's
3065                    // BUILTIN_PIPE_FDS_INSTALL do the dup2 at the
3066                    // C-faithful point. `print -rl -- c a b |
3067                    // print -r -- "[$(cat)]" | cat` therefore prints
3068                    // `[]` — the middle stage's `$(cat)` reads the
3069                    // shell's stdin, not the pipe.
3070                    let in_fd = if i > 0 { pipes[i - 1].0 } else { -1 };
3071                    let out_fd = pipes[i].1;
3072                    // (Pipe-output MULTIOS marking — c:Src/exec.c:3724 —
3073                    // is emitted INTO the stage chunk by compile_pipe
3074                    // via BUILTIN_PIPE_OUTPUT_MARK, gated on the stage's
3075                    // top-level command actually carrying redirects, so
3076                    // a nested `{ echo a > f; } | cat` body redirect
3077                    // does not wrongly join the pipe.)
3078                    // Close every pipe fd this stage doesn't need. The
3079                    // two it does keep are closed by the install op
3080                    // right after their dup2.
3081                    for (r, w) in &pipes {
3082                        unsafe {
3083                            if *r != in_fd && *r != out_fd {
3084                                libc::close(*r);
3085                            }
3086                            if *w != in_fd && *w != out_fd {
3087                                libc::close(*w);
3088                            }
3089                        }
3090                    }
3091                    stage_fds_park(in_fd, out_fd);
3092
3093                    // Run this stage's bytecode on a fresh VM
3094                    crate::fusevm_disasm::maybe_print_stdout(
3095                        &format!("pipeline:child:stage:{i}"),
3096                        chunk,
3097                    );
3098                    let mut stage_vm = fusevm::VM::new(chunk.clone());
3099                    stage_vm.last_status = parent_status;
3100                    register_builtins(&mut stage_vm);
3101                    let _ = stage_vm.run();
3102                    // Flush any buffered output before exiting
3103                    let _ = std::io::stdout().flush();
3104                    let _ = std::io::stderr().flush();
3105                    std::process::exit(stage_vm.last_status);
3106                }
3107                pid => {
3108                    child_pids.push(pid);
3109                }
3110            }
3111        }
3112
3113        // Parent runs the LAST stage inline. Save stdin, park the last
3114        // pipe's read end for the chunk's BUILTIN_PIPE_FDS_INSTALL
3115        // (c:Src/exec.c:3722 `addfd(..., 0, input, 0, NULL)` — after
3116        // the stage's args are expanded, so `… | print -r -- "[$(cat)]"`
3117        // has its `$(cat)` read the shell's stdin, not the pipe), run
3118        // the chunk, restore stdin. Close every other pipe fd so the
3119        // producer side gets EOF when the last upstream stage exits.
3120        // Shell-internal save — keep it out of the script's fd range (movefd,
3121        // c:Src/exec.c:2425).
3122        let saved_stdin = unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD, 10) };
3123        let last_in_fd = if last_idx > 0 {
3124            pipes[last_idx - 1].0
3125        } else {
3126            -1
3127        };
3128        // Close all pipe fds in the parent except the one the last
3129        // stage still has to install. (Children already have their own
3130        // copies; the install op closes the read end after its dup2.)
3131        for (r, w) in &pipes {
3132            unsafe {
3133                if *r != last_in_fd {
3134                    libc::close(*r);
3135                }
3136                libc::close(*w);
3137            }
3138        }
3139        let outer_stage_fds = stage_fds_park(last_in_fd, -1);
3140
3141        // Run the last stage's bytecode on a sub-VM with the host wired up.
3142        // By default (zsh semantics) the sub-VM runs IN THIS PROCESS so the
3143        // last stage's reads/assignments update the parent's state directly
3144        // (`echo x | read v` sets $v; `cmd | mapfile arr` sets arr).
3145        //
3146        // !!! BASH-MODE GATE !!! bash forks EVERY pipeline stage (unless
3147        // `shopt -s lastpipe`), so the last stage runs in a SUBSHELL and its
3148        // variable/array assignments do NOT persist — `echo x | read v; echo
3149        // $v` prints an empty line, `cmd | mapfile arr` leaves arr unset.
3150        // Fork the last stage under `--bash` to match. The parent's existing
3151        // `stage_fds_take()` below closes its `last_in_fd` copy; the forked
3152        // child inherits the parked pipe fd and installs it onto stdin, and
3153        // the writer stages (already forked) supply its input.
3154        let last_stage_status = if crate::dash_mode::bash_mode() {
3155            let last_chunk = stages_vec.into_iter().last().unwrap();
3156            crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
3157            match unsafe { libc::fork() } {
3158                -1 => 1,
3159                0 => {
3160                    // Subshell child: run the last stage, then _exit with its
3161                    // status. Reset SIGPIPE + drop the EXIT trap like the
3162                    // other pipeline children above.
3163                    unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) };
3164                    if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
3165                        t.remove("EXIT");
3166                    }
3167                    let mut stage_vm = fusevm::VM::new(last_chunk);
3168                    stage_vm.last_status = parent_status;
3169                    register_builtins(&mut stage_vm);
3170                    stage_vm.set_shell_host(Box::new(ZshrsHost));
3171                    let _ = stage_vm.run();
3172                    let st = stage_vm.last_status;
3173                    let _ = std::io::stdout().flush();
3174                    let _ = std::io::stderr().flush();
3175                    unsafe { libc::_exit(st) };
3176                }
3177                pid => {
3178                    // Same EINTR retry as the stage reap loop below.
3179                    match waitpid_eintr(pid) {
3180                        Some(status) if libc::WIFEXITED(status) => libc::WEXITSTATUS(status),
3181                        Some(status) if libc::WIFSIGNALED(status) => 128 + libc::WTERMSIG(status),
3182                        Some(_) => 1,
3183                        None => 0,
3184                    }
3185                }
3186            }
3187        } else {
3188            let last_chunk = stages_vec.into_iter().last().unwrap();
3189            crate::fusevm_disasm::maybe_print_stdout("pipeline:last", &last_chunk);
3190            let mut stage_vm = fusevm::VM::new(last_chunk);
3191            stage_vm.last_status = parent_status;
3192            register_builtins(&mut stage_vm);
3193            stage_vm.set_shell_host(Box::new(ZshrsHost));
3194            let _ = stage_vm.run();
3195            let _ = std::io::stdout().flush();
3196            let _ = std::io::stderr().flush();
3197            stage_vm.last_status
3198        };
3199
3200        // Reclaim the read end if the stage chunk never reached its
3201        // install op (an expansion error aborted it, or the stage was
3202        // a shape that dispatches without one), then restore the outer
3203        // stage's still-pending fds for a nested pipeline.
3204        let (leftover_in, _) = stage_fds_take();
3205        if leftover_in >= 0 {
3206            unsafe { libc::close(leftover_in) };
3207        }
3208        stage_fds_park(outer_stage_fds.0, outer_stage_fds.1);
3209
3210        // Restore stdin
3211        if saved_stdin >= 0 {
3212            unsafe {
3213                libc::dup2(saved_stdin, libc::STDIN_FILENO);
3214                libc::close(saved_stdin);
3215            }
3216        }
3217
3218        // Wait for all forked stages, capture per-stage statuses for PIPESTATUS.
3219        let mut pipestatus: Vec<i32> = Vec::with_capacity(n);
3220        for pid in child_pids {
3221            // EINTR retry: the SIGCHLD handler interrupts this wait and
3222            // leaves `status` untouched, which used to read back as a
3223            // clean exit 0 for every forked stage. See waitpid_eintr.
3224            let s = match waitpid_eintr(pid) {
3225                Some(status) if libc::WIFEXITED(status) => libc::WEXITSTATUS(status),
3226                Some(status) if libc::WIFSIGNALED(status) => 128 + libc::WTERMSIG(status),
3227                Some(_) => 1,
3228                // Unreapable (ECHILD — the handler got there first).
3229                // Nothing better to report than success; the stage's
3230                // real status is gone.
3231                None => 0,
3232            };
3233            pipestatus.push(s);
3234        }
3235        // Append the in-parent last-stage status so `pipestatus` ends
3236        // with N entries (one per stage).
3237        pipestatus.push(last_stage_status);
3238        // Pipeline exit status: by default, the LAST stage's status.
3239        // With `setopt pipefail` (or `set -o pipefail`), use the
3240        // first non-zero stage status (so failures earlier in the
3241        // pipeline propagate even if the last stage succeeded).
3242        let pipefail_on = with_executor(|exec| opt_state_get("pipefail").unwrap_or(false));
3243        let last_status = if pipefail_on {
3244            pipestatus
3245                .iter()
3246                .copied()
3247                .rfind(|&s| s != 0)
3248                .or_else(|| pipestatus.last().copied())
3249                .unwrap_or(0)
3250        } else {
3251            *pipestatus.last().unwrap_or(&0)
3252        };
3253
3254        // c:Src/params.c:265,438 — only `pipestatus` (lowercase) is the
3255        // zsh special parameter; bash's `PIPESTATUS` doesn't exist in
3256        // zsh's special-params table. Prior port also populated
3257        // `PIPESTATUS` "for portability" — but that's a real divergence
3258        // from zsh: a script doing `[[ -z $PIPESTATUS ]]` to detect
3259        // zsh-vs-bash would mis-classify. Bug #64 in docs/BUGS.md.
3260        with_executor(|exec| {
3261            // c:Src/jobs.c:83 `int pipestats[MAX_PIPESTATS]` — the values
3262            // live in that C GLOBAL, reached through `pipestatus`'s GSU
3263            // (c:Src/params.c pipestatgetfn). A `typeset -h +g pipestatus`
3264            // local shadow carries no PM_SPECIAL and no GSU, so the C
3265            // writer cannot reach it; skip the paramtab mirror likewise or
3266            // the shadow loses its PM_UNSET (B02typeset.ztst:37,38).
3267            let shadowed = crate::ported::params::paramtab()
3268                .read()
3269                .ok()
3270                .and_then(|t| {
3271                    t.get("pipestatus")
3272                        .map(|pm| (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) == 0)
3273                })
3274                .unwrap_or(false);
3275            if !shadowed {
3276                let strs: Vec<String> = pipestatus.iter().map(|s| s.to_string()).collect();
3277                exec.set_array("pipestatus".to_string(), strs);
3278            }
3279        });
3280
3281        Value::Status(last_status)
3282    });
3283
3284    // Array→String join. Pops one value; if it's an Array (e.g. from Op::Glob),
3285    // joins string-coerced elements with a single space. Pass-through for
3286    // non-arrays so the op is safe to chain after any String-or-Array producer.
3287    // Scalar coercion of an assembled word: pop a Value; if it's an
3288    // Array (produced by a splice segment like `"$@"` / `"${arr[@]}"`),
3289    // IFS[0]-join it to a single scalar; a scalar passes through. This
3290    // is the assignment-context coercion C zsh applies in multsub when
3291    // the expansion is the RHS of a SCALAR assignment (Src/subst.c
3292    // c:3032 sepjoin under ssub) — `v="$@"` joins the positionals with
3293    // ${IFS[1]} rather than leaving an array whose splat would lose all
3294    // but the first element. Joins via sepjoin so a custom / empty IFS
3295    // is honored (not a hardcoded space).
3296    vm.register_builtin(BUILTIN_ARRAY_JOIN, |vm, _argc| {
3297        let val = vm.pop();
3298        match val {
3299            Value::Array(items) => {
3300                let strs: Vec<String> = items.iter().map(|v| v.to_str()).collect();
3301                Value::str(crate::ported::utils::sepjoin(&strs, None))
3302            }
3303            other => other,
3304        }
3305    });
3306
3307    // `cmd &` background execution. Compile_list emits this for any item
3308    // followed by ListOp::Amp: the job text + the cmd's sub-chunk index are
3309    // pushed, then this builtin pops both, looks up the chunk, forks. The
3310    // child detaches via setsid (so SIGINT to the foreground job doesn't kill
3311    // it), runs the bytecode on a fresh VM with builtins re-registered, exits
3312    // with the last status. The parent registers the job in the canonical
3313    // JOBTAB (c:Src/exec.c::execpline Z_ASYNC arm) and returns Status(0).
3314    vm.register_builtin(BUILTIN_RUN_BG, |vm, _argc| {
3315        // `&|` / `&!` set disown → the job is dropped from the table (no
3316        // `[N] pid` announcement, no `[N] done`), matching C exec.c:1752-1758.
3317        let disown = vm.pop().to_int() != 0;
3318        let sub_idx = vm.pop().to_int() as usize;
3319        let job_text = vm.pop().to_str();
3320        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
3321            Some(c) => c,
3322            None => return Value::Status(1),
3323        };
3324
3325        match unsafe { libc::fork() } {
3326            -1 => Value::Status(1),
3327            0 => {
3328                // Child: detach and run.
3329                unsafe { libc::setsid() };
3330                crate::fusevm_disasm::maybe_print_stdout("background_job", &chunk);
3331                let mut bg_vm = fusevm::VM::new(chunk);
3332                register_builtins(&mut bg_vm);
3333                let _ = bg_vm.run();
3334                let _ = std::io::stdout().flush();
3335                let _ = std::io::stderr().flush();
3336                std::process::exit(bg_vm.last_status);
3337            }
3338            pid => {
3339                // Parent: record the PID into `$!` (most recent
3340                // backgrounded job's pid). zsh exposes this for any
3341                // script that needs `wait $!`. Also register the
3342                // bare-pid job so a no-args `wait` can synchronize.
3343                // c:Src/jobs.c:73 — `lastpid = pid;` after a
3344                // background fork. zshrs's `$!` getter
3345                // (params.rs::lookup_special_var "!") reads from
3346                // the same atomic, so a single store here is the
3347                // canonical writer.
3348                crate::ported::modules::clone::lastpid
3349                    .store(pid, std::sync::atomic::Ordering::Relaxed);
3350                // c:Src/exec.c:1700 — `thisjob = newjob = initjob()`:
3351                // allocate the canonical jobtab slot. c:Src/exec.c:2950
3352                // zfork path → addproc(pid, text, 0, &bgtime, ...) hangs
3353                // the proc entry (with its display text) off the job.
3354                // c:Src/exec.c:1744-1746 — `clearoldjobtab();
3355                // jobtab[thisjob].stat |= STAT_NOSTTY;` then c:1758
3356                // `spawnjob()` promotes it to curjob (top-level shell
3357                // only), marks STAT_LOCKED and resets thisjob.
3358                {
3359                    use crate::ported::jobs;
3360                    use std::sync::Mutex;
3361                    let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
3362                    let idx = {
3363                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
3364                        let idx = jobs::initjob(&mut tab); // c:exec.c:1700
3365                        jobs::addproc(
3366                            &mut tab[idx],
3367                            pid,
3368                            &job_text,
3369                            false,
3370                            Some(std::time::Instant::now()),
3371                            -1,
3372                            -1,
3373                        ); // c:exec.c:2950 addproc
3374                        tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
3375                        idx
3376                    };
3377                    jobs::clearoldjobtab(); // c:exec.c:1744
3378                    if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
3379                        *tj = idx as i32;
3380                    }
3381                    if disown {
3382                        // c:exec.c:1752-1755 — `pipecleanfilelist(...);
3383                        // deletejob(jobtab + thisjob, 1); thisjob = -1;` — a
3384                        // disowned job leaves the table entirely, so neither
3385                        // spawnjob's `[N] pid` nor the later `[N] done` prints.
3386                        // This is what keeps zinit-turbo's `… &|` completion
3387                        // jobs silent (they load inside a `zle -F` handler).
3388                        {
3389                            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
3390                            jobs::pipecleanfilelist(&mut tab[idx], false); // c:1753
3391                            jobs::deletejob(&mut tab[idx], true); // c:1754
3392                        }
3393                        if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
3394                            *tj = -1; // c:1755
3395                        }
3396                    } else {
3397                        jobs::spawnjob(); // c:exec.c:1758
3398                    }
3399                }
3400                with_executor(|exec| {
3401                    exec.jobs
3402                        .add_pid_job(pid, job_text.clone(), JobState::Running);
3403                });
3404                Value::Status(0)
3405            }
3406        }
3407    });
3408
3409    // ── Indexed-array storage ─────────────────────────────────────────────
3410    //
3411    // Stack: pushed values then name (LAST). `arr=(a b c)` → 4 args
3412    // (a, b, c, arr). `arr=($(cmd))` → 2 args (FlatArray, arr).
3413    //
3414    // PURE PASSTHRU: pop name + values, dispatch to canonical
3415    // `setaparam` / `sethparam` (C port of `Src/params.c:3595/3602`).
3416    // assignaparam already handles PM_UNIQUE dedupe, type-flag flip,
3417    // PM_NAMEREF rejection, ASSPM_AUGMENT prepend, and createparam
3418    // for fresh names.
3419    vm.register_builtin(BUILTIN_SET_ARRAY, |vm, argc| {
3420        // `${~spec}` carrier: an assignment statement is a word-
3421        // pipeline boundary too — restore the user's GLOB_SUBST
3422        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
3423        // ${options[globsubst]}` must read the user value).
3424        consume_tilde_globsubst_carrier();
3425        let n = argc as usize;
3426        let mut popped: Vec<Value> = Vec::with_capacity(n);
3427        for _ in 0..n {
3428            popped.push(vm.pop());
3429        }
3430        popped.reverse();
3431        if popped.is_empty() {
3432            return Value::Status(1);
3433        }
3434        let name = popped.pop().unwrap().to_str();
3435        let mut values: Vec<String> = Vec::new();
3436        for v in popped {
3437            flatten_array_value(v, &mut values);
3438        }
3439        // Bash sparse: a full `a=(...)` reassign resets the array to dense
3440        // (drops any prior holes from subscript-assign / unset).
3441        if crate::dash_mode::sparse_arrays() {
3442            crate::bash_arrays::clear(&name);
3443        }
3444        let blocked = with_executor(|exec| {
3445            // Assoc init `typeset -A m; m=(k v k v ...)` — route to
3446            // canonical sethparam (Src/params.c:3602) which parses the
3447            // flat (k,v) pair list internally.
3448            if exec.assoc(&name).is_some() {
3449                // `[k]=v` / `[k]+=v` elements arrive from the compiler
3450                // as Marker / key / value triples (compile_zsh's port
3451                // of keyvalpairelement, c:Src/subst.c:49-79).
3452                let marker = crate::ported::zsh_h::Marker;
3453                let values = if values.iter().any(|e| e.starts_with(marker)) {
3454                    // c:Src/params.c:3544-3560 — under ASSPM_KEY_VALUE
3455                    // assocs strictly enforce `[key]=value`: every
3456                    // stride-of-3 element must be a Marker. Mixing
3457                    // plain pairs with kv triads is an error.
3458                    let mut i = 0usize;
3459                    while i < values.len() {
3460                        if !values[i].starts_with(marker) {
3461                            crate::ported::utils::zerr(
3462                                "bad [key]=value syntax for associative array",
3463                            );
3464                            crate::ported::utils::errflag.fetch_or(
3465                                crate::ported::zsh_h::ERRFLAG_ERROR,
3466                                std::sync::atomic::Ordering::Relaxed,
3467                            );
3468                            exec.set_last_status(1);
3469                            return true;
3470                        }
3471                        i += 3;
3472                    }
3473                    if values.len() % 3 != 0 {
3474                        // c:Src/params.c:4124-4131 arrhashsetfn — a
3475                        // truncated triad leaves an odd non-Marker
3476                        // count → "bad set of key/value pairs".
3477                        crate::ported::utils::zerr(
3478                            "bad set of key/value pairs for associative array",
3479                        );
3480                        crate::ported::utils::errflag.fetch_or(
3481                            crate::ported::zsh_h::ERRFLAG_ERROR,
3482                            std::sync::atomic::Ordering::Relaxed,
3483                        );
3484                        exec.set_last_status(1);
3485                        return true;
3486                    }
3487                    // c:Src/params.c:4136-4168 arrhashsetfn — whole
3488                    // assignment builds a FRESH table; a `Marker +`
3489                    // triad (`[k]+=v`) appends to the value inserted
3490                    // EARLIER IN THIS SAME LITERAL (assignstrvalue
3491                    // with eltflags=ASSPM_AUGMENT against the new ht),
3492                    // so `h=([k]=a [k]+=b)` yields "ab". Resolve the
3493                    // appends here, then hand flat pairs to sethparam.
3494                    let mut order: Vec<String> = Vec::new();
3495                    let mut map: std::collections::HashMap<String, String> =
3496                        std::collections::HashMap::new();
3497                    for ch in values.chunks(3) {
3498                        let elt_append = ch[0].chars().nth(1) == Some('+');
3499                        let k = ch[1].clone();
3500                        let v = ch[2].clone();
3501                        let nv = if elt_append {
3502                            format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
3503                        } else {
3504                            v
3505                        };
3506                        if !map.contains_key(&k) {
3507                            order.push(k.clone());
3508                        }
3509                        map.insert(k, nv);
3510                    }
3511                    order
3512                        .into_iter()
3513                        .flat_map(|k| {
3514                            let v = map.get(&k).cloned().unwrap_or_default();
3515                            [k, v]
3516                        })
3517                        .collect()
3518                } else {
3519                    values
3520                };
3521                // Odd-count rejection lives in the canonical chain:
3522                // sethparam → setarrvalue (c:3651/c:2920) →
3523                // arrhashsetfn's zerr "bad set of key/value pairs"
3524                // (Src/params.c:4128-4131). zerr sets ERRFLAG_ERROR,
3525                // which aborts the remaining list at the next command
3526                // boundary (BUILTIN_ERREXIT_CHECK trigger 4) —
3527                // matching `zsh -fc 'typeset -A m; m=(odd); print x'`
3528                // printing nothing after the error. Like C's sethparam
3529                // (c:3652-3653 returns v->pm regardless), the Rust
3530                // port returns Some on the odd-count path — the
3531                // failure travels via errflag, so check BOTH.
3532                let pre_err = crate::ported::utils::errflag
3533                    .load(std::sync::atomic::Ordering::Relaxed)
3534                    & crate::ported::zsh_h::ERRFLAG_ERROR;
3535                let res = crate::ported::params::sethparam(&name, values.clone());
3536                let now_err = crate::ported::utils::errflag
3537                    .load(std::sync::atomic::Ordering::Relaxed)
3538                    & crate::ported::zsh_h::ERRFLAG_ERROR;
3539                if res.is_none() || (pre_err == 0 && now_err != 0) {
3540                    // c:Src/exec.c:2632-2633 addvars — `if
3541                    // (!assignaparam(name, arr, myflags)) lastval = 1;`
3542                    // — failed assignment sets lastval so the errflag
3543                    // abort exits 1 (init.c loop() breaks, zsh_main
3544                    // returns lastval).
3545                    exec.set_last_status(1);
3546                    return true;
3547                }
3548                #[cfg(feature = "recorder")]
3549                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3550                    let ctx = exec.recorder_ctx();
3551                    let attrs = exec.recorder_attrs_for(&name);
3552                    let mut pairs: Vec<(String, String)> = Vec::with_capacity(values.len() / 2);
3553                    let mut iter = values.iter().cloned();
3554                    while let Some(k) = iter.next() {
3555                        if let Some(v) = iter.next() {
3556                            pairs.push((k, v));
3557                        }
3558                    }
3559                    crate::recorder::emit_assoc_assign(&name, pairs, attrs, false, ctx);
3560                }
3561                return false;
3562            }
3563            // Indexed-array: setaparam (Src/params.c:3766) wraps
3564            // assignaparam with ASSPM_WARN — handles PM_UNIQUE dedupe,
3565            // type-flag flip, PM_READONLY rejection.
3566            //
3567            // `[k]=v` elements arrive as Marker / key / value triples
3568            // (compile_zsh's keyvalpairelement port). Mirror
3569            // c:Src/exec.c:2552-2553 — `if (prefork_ret &
3570            // PREFORK_KEY_VALUE) myflags |= ASSPM_KEY_VALUE;` — so
3571            // assignaparam runs its kv-resolution block (sparse fill
3572            // for PM_ARRAY, c:3447-3541; strict-triad enforcement for
3573            // special PM_HASHED targets like `options`, c:3544-3560).
3574            let values = values;
3575            let has_kv = values
3576                .iter()
3577                .any(|e| e.starts_with(crate::ported::zsh_h::Marker));
3578            // The tied-array mirror to a PM_TIED scalar
3579            // (`typeset -T PATH path`) lives canonically in
3580            // setarrvalue's dispatch in C zsh; until that wires
3581            // through assignaparam, mirror here so PATH stays in sync
3582            // after `path=(/x)`.
3583            //
3584            // The mirrored value must be the array AS STORED, which for a
3585            // PM_UNIQUE tie means deduped. c:4066-4076 arrsetfn fixes the
3586            // order:
3587            //     if (pm->node.flags & PM_UNIQUE) uniqarray(x);
3588            //     pm->u.arr = x;
3589            //     if (pm->ename && x) arrfixenv(pm->ename, x);
3590            // — the dedupe happens FIRST, so the scalar publishes the same
3591            // list the array holds and the two halves of a tie always agree.
3592            // Mirroring the raw `values` broke exactly that:
3593            //     typeset -U path; path=(/a /b /a)
3594            //       $path → /a /b        (right)
3595            //       $PATH → /a:/b:/a     (wrong; zsh gives /a:/b)
3596            // i.e. `typeset -U path`, the standard PATH-dedup idiom in
3597            // essentially every .zshrc. assignaparam's own arrfixenv does not
3598            // rescue it: that call is gated on the param having a gsu_a wired,
3599            // and `path` has none.
3600            //
3601            // The dedupe is applied here rather than by reading the array back
3602            // after assignaparam, because this mirror must stay BEFORE it.
3603            // `exec.set_scalar` is heavier than C's arrfixenv — arrfixenv only
3604            // rewrites the environment string, while set_scalar re-derives the
3605            // ARRAY from the scalar. Running it afterwards makes `path=()`
3606            // publish PATH="" and then re-split that back into a one-element
3607            // `path=("")`, where zsh leaves 0 elements.
3608            if let Some((scalar_name, sep)) = exec.tied_array_to_scalar.get(&name).cloned() {
3609                let uniq = crate::ported::params::paramtab()
3610                    .read()
3611                    .ok()
3612                    .and_then(|t| t.get(&name).map(|p| p.node.flags))
3613                    .map(|f| (f as u32 & crate::ported::zsh_h::PM_UNIQUE) != 0)
3614                    .unwrap_or(false);
3615                let mirror = if uniq {
3616                    crate::ported::params::simple_arrayuniq(values.clone()) // c:4068
3617                } else {
3618                    values.clone()
3619                };
3620                exec.set_scalar(scalar_name, mirror.join(&sep)); // c:4074-4075
3621            }
3622            // c:Src/exec.c:2632-2633 addvars — `if (!assignaparam(...))
3623            // lastval = 1;` — a failed assignment (bad subscript, bad
3624            // [key]=value syntax, readonly) exits 1 and the errflag
3625            // abort stops the remaining list. Track errflag pre/post
3626            // like the assoc branch above.
3627            let kv_flag = if has_kv {
3628                crate::ported::zsh_h::ASSPM_KEY_VALUE
3629            } else {
3630                0
3631            };
3632            let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3633                & crate::ported::zsh_h::ERRFLAG_ERROR;
3634            let res = crate::ported::params::assignaparam(
3635                &name,
3636                values.clone(),
3637                crate::ported::zsh_h::ASSPM_WARN | kv_flag,
3638            );
3639            let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3640                & crate::ported::zsh_h::ERRFLAG_ERROR;
3641            if res.is_none() && pre_err == 0 && now_err != 0 {
3642                exec.set_last_status(1);
3643                return true;
3644            }
3645            // Bash sparse: an explicit-index array literal `a=([2]=x [5]=y)`
3646            // leaves the un-indexed slots as HOLES (bash: count 2, indices
3647            // {2,5}), not dense empties. assignaparam has already placed each
3648            // value at its 0-based index; mark every OTHER slot a hole. Gated
3649            // to a PURE indexed literal (every element a `[idx]=val` triple) —
3650            // a mixed positional/indexed literal (`a=(x [3]=y z)`) needs the
3651            // positional-counter replay we don't model, so it stays dense.
3652            if crate::dash_mode::sparse_arrays() && has_kv {
3653                let marker = crate::ported::zsh_h::Marker;
3654                let pure_indexed = !values.is_empty()
3655                    && values.len() % 3 == 0
3656                    && values.chunks(3).all(|ch| ch[0].starts_with(marker));
3657                if pure_indexed {
3658                    let mut explicit: std::collections::BTreeSet<usize> =
3659                        std::collections::BTreeSet::new();
3660                    for ch in values.chunks(3) {
3661                        if let Ok(i) = ch[1].trim().parse::<usize>() {
3662                            explicit.insert(i);
3663                        }
3664                    }
3665                    let len = exec.array(&name).map(|a| a.len()).unwrap_or(0);
3666                    for i in 0..len {
3667                        if !explicit.contains(&i) {
3668                            crate::bash_arrays::note_unset(&name, i);
3669                        }
3670                    }
3671                }
3672            }
3673            #[cfg(feature = "recorder")]
3674            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3675                let ctx = exec.recorder_ctx();
3676                let attrs = exec.recorder_attrs_for(&name);
3677                emit_path_or_assign(&name, &values, attrs, false, &ctx);
3678            }
3679            false
3680        });
3681        let status = if blocked { 1 } else { 0 };
3682        // c:Src/jobs.c:1748-1757 waitonejob — in C an array-assignment
3683        // simple command goes through execpline → waitjobs; with no
3684        // procs the else-branch stores `pipestats[0] = lastval;
3685        // numpipestats = 1`. Bare SCALAR assignments never create a
3686        // job (no waitjobs), so this clobber is array/assoc-assignment
3687        // specific: `false|true; x=(1 2); echo $pipestatus` → `0` in
3688        // zsh while `x=1` preserves `1 0`. Bug #373.
3689        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
3690        let mut synth = crate::ported::zsh_h::job::default();
3691        crate::ported::jobs::waitonejob(&mut synth);
3692        Value::Status(status)
3693    });
3694    // `arr+=(d e f)` — array append. Same calling conventions as SET_ARRAY.
3695    //
3696    // PURE PASSTHRU shape: pop name + values, dispatch through the
3697    // canonical assoc / array setter. assignaparam's ASSPM_AUGMENT
3698    // flag handles the C-source-equivalent "preserve prior value"
3699    // semantics; for now we read the current array, extend with
3700    // new values, write through set_array (which routes to
3701    // setaparam → assignaparam where PM_UNIQUE dedupe lands).
3702    vm.register_builtin(BUILTIN_APPEND_ARRAY, |vm, argc| {
3703        let n = argc as usize;
3704        let mut popped: Vec<Value> = Vec::with_capacity(n);
3705        for _ in 0..n {
3706            popped.push(vm.pop());
3707        }
3708        popped.reverse();
3709        if popped.is_empty() {
3710            return Value::Status(1);
3711        }
3712        let name = popped.pop().unwrap().to_str();
3713        let mut values: Vec<String> = Vec::new();
3714        for v in popped {
3715            flatten_array_value(v, &mut values);
3716        }
3717        let blocked = with_executor(|exec| -> bool {
3718            // Assoc append `m+=(k1 v1 ...)`: merge the (k,v) pairs into
3719            // the existing map and write back via canonical sethparam
3720            // (Src/params.c:3602). The canonical C path would go
3721            // assignaparam(ASSPM_AUGMENT) → arrhashsetfn(ASSPM_AUGMENT)
3722            // at Src/params.c:3850, but the zshrs port of
3723            // arrhashsetfn doesn't yet implement value-storage
3724            // (pending Param.u_hash backend wireup) — until that
3725            // lands, do the augment + write here so the storage
3726            // actually mutates.
3727            if exec.assoc(&name).is_some() {
3728                // `[k]=v` / `[k]+=v` elements arrive as Marker / key /
3729                // value triples (compile_zsh's port of
3730                // keyvalpairelement, c:Src/subst.c:49-79).
3731                let marker = crate::ported::zsh_h::Marker;
3732                let mut map = exec.assoc(&name).unwrap_or_default();
3733                if values.iter().any(|e| e.starts_with(marker)) {
3734                    // c:Src/params.c:3544-3560 — strict triad rule.
3735                    let mut i = 0usize;
3736                    while i < values.len() {
3737                        if !values[i].starts_with(marker) {
3738                            crate::ported::utils::zerr(
3739                                "bad [key]=value syntax for associative array",
3740                            );
3741                            crate::ported::utils::errflag.fetch_or(
3742                                crate::ported::zsh_h::ERRFLAG_ERROR,
3743                                std::sync::atomic::Ordering::Relaxed,
3744                            );
3745                            exec.set_last_status(1);
3746                            return true;
3747                        }
3748                        i += 3;
3749                    }
3750                    if values.len() % 3 != 0 {
3751                        // c:Src/params.c:4124-4131 — odd pair count.
3752                        crate::ported::utils::zerr(
3753                            "bad set of key/value pairs for associative array",
3754                        );
3755                        crate::ported::utils::errflag.fetch_or(
3756                            crate::ported::zsh_h::ERRFLAG_ERROR,
3757                            std::sync::atomic::Ordering::Relaxed,
3758                        );
3759                        exec.set_last_status(1);
3760                        return true;
3761                    }
3762                    // c:Src/params.c:4133-4168 arrhashsetfn with
3763                    // ASSPM_AUGMENT — ht = the EXISTING table, so
3764                    // `[k]+=v` appends to the current value
3765                    // (assignstrvalue eltflags=ASSPM_AUGMENT,
3766                    // c:4144-4150) and `[k]=v` overwrites.
3767                    for ch in values.chunks(3) {
3768                        let elt_append = ch[0].chars().nth(1) == Some('+');
3769                        let k = ch[1].clone();
3770                        let v = ch[2].clone();
3771                        let nv = if elt_append {
3772                            format!("{}{}", map.get(&k).cloned().unwrap_or_default(), v)
3773                        } else {
3774                            v
3775                        };
3776                        map.insert(k, nv);
3777                    }
3778                } else {
3779                    // c:Src/params.c:4076-4085 arrhashsetfn — the SAME odd-count
3780                    // gate the non-augment form uses runs before ASSPM_AUGMENT
3781                    // merges anything:
3782                    //     for (aptr = val; *aptr; ++aptr)
3783                    //         if (**aptr != Marker) ++alen;
3784                    //     if (alen % 2) { freearray(val);
3785                    //         zerr("bad set of key/value pairs for associative
3786                    //              array"); return; }
3787                    // c:4086 `if (flags & ASSPM_AUGMENT)` is reached only AFTER
3788                    // it, so `h+=(k2)` with a lone key is refused and the hash is
3789                    // left alone. The walk below just dropped the unpaired key
3790                    // (`if let Some(v) = it.next()`), so the append silently
3791                    // no-opped at status 0 where zsh errors and exits 1.
3792                    if values.len() % 2 != 0 {
3793                        crate::ported::utils::zerr(
3794                            "bad set of key/value pairs for associative array",
3795                        ); // c:4083
3796                        crate::ported::utils::errflag.fetch_or(
3797                            crate::ported::zsh_h::ERRFLAG_ERROR,
3798                            std::sync::atomic::Ordering::Relaxed,
3799                        );
3800                        // c:Src/exec.c:2632-2633 — a failed assignment sets
3801                        // lastval 1, the same tail the triad form above uses.
3802                        exec.set_last_status(1);
3803                        return true;
3804                    }
3805                    let mut it = values.iter().cloned();
3806                    while let Some(k) = it.next() {
3807                        if let Some(v) = it.next() {
3808                            map.insert(k, v);
3809                        }
3810                    }
3811                }
3812                exec.set_assoc(name, map);
3813                return false;
3814            }
3815            // Indexed-array append `arr+=(d e f)` — route directly
3816            // through canonical assignaparam with ASSPM_AUGMENT
3817            // (`Src/params.c:3570-3585` append-on-array branch).
3818            // assignaparam reads the prior array internally and
3819            // appends the new values, so the bridge no longer needs
3820            // to pre-concat manually. Marker triples from `[k]=v`
3821            // elements add ASSPM_KEY_VALUE (c:Src/exec.c:2552-2553)
3822            // so the kv sparse-fill block (c:Src/params.c:3447-3541)
3823            // resolves them against the existing elements.
3824            let kv_flag = if values
3825                .iter()
3826                .any(|e| e.starts_with(crate::ported::zsh_h::Marker))
3827            {
3828                crate::ported::zsh_h::ASSPM_KEY_VALUE
3829            } else {
3830                0
3831            };
3832            let pre_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3833                & crate::ported::zsh_h::ERRFLAG_ERROR;
3834            let res = crate::ported::params::assignaparam(
3835                &name,
3836                values.clone(),
3837                crate::ported::zsh_h::ASSPM_AUGMENT | kv_flag,
3838            );
3839            let now_err = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
3840                & crate::ported::zsh_h::ERRFLAG_ERROR;
3841            if res.is_none() && pre_err == 0 && now_err != 0 {
3842                // c:Src/exec.c:2632-2633 — failed assignment → lastval 1.
3843                exec.set_last_status(1);
3844                return true;
3845            }
3846            #[cfg(feature = "recorder")]
3847            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
3848                let ctx = exec.recorder_ctx();
3849                let attrs = exec.recorder_attrs_for(&name);
3850                emit_path_or_assign(&name, &values, attrs, true, &ctx);
3851            }
3852            // Tied-scalar mirror — TODO faithful: should live in
3853            // setarrvalue's gsu dispatch once boot_ paramtab wiring
3854            // lands (Task #16). Re-read the canonical post-augment
3855            // array so the joined scalar matches.
3856            let tied_scalar = exec.tied_array_to_scalar.get(&name).cloned();
3857            if let Some((scalar_name, sep)) = tied_scalar {
3858                let merged = exec.array(&name).unwrap_or_default();
3859                let joined = merged.join(&sep);
3860                exec.set_scalar(scalar_name.clone(), joined.clone());
3861                let _ = crate::ported::params::zputenv(&format!("{}={}", &scalar_name, &joined));
3862                // c:Src/params.c:5354
3863            }
3864            false
3865        });
3866        // c:Src/jobs.c:1748-1757 waitonejob — `arr+=(...)` is an
3867        // array-assignment simple command and clobbers pipestats to
3868        // `[lastval]` exactly like `arr=(...)` above. Bug #373.
3869        let status = if blocked { 1 } else { 0 };
3870        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
3871        let mut synth = crate::ported::zsh_h::job::default();
3872        crate::ported::jobs::waitonejob(&mut synth);
3873        Value::Status(status)
3874    });
3875    // `name[@]=(...)` / `name[*]=(...)` — whole-array SET with the assoc
3876    // guard (c:Src/params.c:3324-3327). Stack: [v0..vn, name].
3877    vm.register_builtin(BUILTIN_SET_ARRAY_AT, |vm, argc| {
3878        let (name, values) = pop_array_args_with_name(vm, argc);
3879        let status = with_executor(|exec| {
3880            if exec.assoc(&name).is_some() {
3881                // c:Src/params.c:3324-3327 — `[@]` (any slice) on a
3882                // PM_HASHED target is an error.
3883                crate::ported::utils::zerr(&format!(
3884                    "{}: attempt to set slice of associative array",
3885                    name
3886                ));
3887                crate::ported::utils::errflag.fetch_or(
3888                    crate::ported::zsh_h::ERRFLAG_ERROR,
3889                    std::sync::atomic::Ordering::Relaxed,
3890                );
3891                exec.set_last_status(1);
3892                return 1;
3893            }
3894            exec.set_array(name, values); // whole replace (c:3528 setarrvalue)
3895            0
3896        });
3897        Value::Status(status)
3898    });
3899    // `name[@]+=(...)` / `name[*]+=(...)` — whole-array APPEND (push) with
3900    // the same assoc guard.
3901    vm.register_builtin(BUILTIN_APPEND_ARRAY_AT, |vm, argc| {
3902        let (name, values) = pop_array_args_with_name(vm, argc);
3903        let status = with_executor(|exec| {
3904            if exec.assoc(&name).is_some() {
3905                crate::ported::utils::zerr(&format!(
3906                    "{}: attempt to set slice of associative array",
3907                    name
3908                ));
3909                crate::ported::utils::errflag.fetch_or(
3910                    crate::ported::zsh_h::ERRFLAG_ERROR,
3911                    std::sync::atomic::Ordering::Relaxed,
3912                );
3913                exec.set_last_status(1);
3914                return 1;
3915            }
3916            let mut cur = exec.array(&name).unwrap_or_default();
3917            cur.extend(values);
3918            exec.set_array(name, cur); // c:3511-3528 AUGMENT on array → push
3919            0
3920        });
3921        Value::Status(status)
3922    });
3923    vm.register_builtin(BUILTIN_RUN_SELECT, |vm, argc| {
3924        if argc < 2 {
3925            return Value::Status(1);
3926        }
3927        let n = argc as usize;
3928        let mut popped: Vec<Value> = Vec::with_capacity(n);
3929        for _ in 0..n {
3930            popped.push(vm.pop());
3931        }
3932        // popped: [sub_idx, name, word_N, ..., word_1] (popping from top)
3933        let sub_idx_val = popped.remove(0);
3934        let name_val = popped.remove(0);
3935        // c:Src/loop.c — `select` flattens Array values (from `$@`,
3936        // `${arr[@]}`, etc.) into the menu. Without per-element
3937        // splice, `select x do ... done` (bare, iterating $@)
3938        // collapsed all positionals into one joined entry.
3939        let mut words: Vec<String> = Vec::new();
3940        for v in popped.into_iter().rev() {
3941            match v {
3942                Value::Array(items) => {
3943                    for item in items.iter() {
3944                        words.push(item.to_str());
3945                    }
3946                }
3947                other => words.push(other.to_str()),
3948            }
3949        }
3950
3951        let sub_idx = sub_idx_val.to_int() as usize;
3952        let name = name_val.to_str();
3953
3954        // c:Src/loop.c:248-252 — `if (!args || empty(args)) {
3955        // state->pc = end; ... return 0; }`. An empty option list
3956        // skips the body entirely; without this gate the prompt loop
3957        // runs indefinitely (or twice on the EOF stdin case before
3958        // exiting). Bug #401.
3959        if words.is_empty() {
3960            return Value::Status(0);
3961        }
3962
3963        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
3964            Some(c) => c,
3965            None => return Value::Status(1),
3966        };
3967
3968        let prompt =
3969            with_executor(|exec| exec.scalar("PROMPT3").unwrap_or_else(|| "?# ".to_string()));
3970
3971        let stdin = std::io::stdin();
3972        let mut reader = stdin.lock();
3973        let mut last_status: i32 = 0;
3974
3975        // c:Src/loop.c:264 — `more = selectlist(args, 0);` renders the menu
3976        // ONCE, BEFORE the selection loop. C reprints it ONLY when the user
3977        // enters an EMPTY line (c:290, inside the inner read loop) — never per
3978        // body iteration. This was a single conflated loop that re-rendered at
3979        // the top of every pass, so `printf "1\n2\n" | select x in a b; do
3980        // print $x; done` redrew the list before each prompt where zsh prints
3981        // it once.
3982        //
3983        // (`selectlist` is also ported at src/ported/loop.rs:127, but that copy
3984        // derives its row budget from adjustlines()/adjustcolumns() — an ioctl
3985        // on fd 1 — and so renders nothing when stdout is not a tty, which is
3986        // exactly this path. Keeping the working inline render here.)
3987        let render_menu = || {
3988            // Direct port of zsh's selectlist from
3989            // src/zsh/Src/loop.c:347-409. Layout is column-major
3990            // ("down columns, then across") — NOT row-major. With
3991            // 6 items in 3 cols zsh produces:
3992            //   1  3  5
3993            //   2  4  6
3994            // The previous Rust impl walked row-major which
3995            // produced 1 2 3 / 4 5 6 (visually similar but wrong
3996            // for prompts that mention ordering and breaks scripts
3997            // that rely on column count == ceil(N/rows)).
3998            //
3999            // C variable mapping:
4000            //   ct      -> word count (n)
4001            //   longest -> max item width + 1, then plus digits-of-ct
4002            //   fct     -> column count
4003            //   fw      -> per-column width
4004            //   colsz   -> row count = ceil(ct / fct)
4005            //   t1      -> row index, walks 0..colsz
4006            //   ap      -> item pointer; advances by colsz to step
4007            //              DOWN a column.
4008            let term_width: usize = env::var("COLUMNS")
4009                .ok()
4010                .and_then(|v| v.parse().ok())
4011                .unwrap_or(80);
4012            let ct = words.len();
4013            // loop.c:354-363 — find longest item width.
4014            let mut longest = 1usize;
4015            for w in &words {
4016                let aplen = w.chars().count();
4017                if aplen > longest {
4018                    longest = aplen;
4019                }
4020            }
4021            // loop.c:365-367 — `longest++` then add digits of `ct`.
4022            longest += 1;
4023            let mut t0 = ct;
4024            while t0 > 0 {
4025                t0 /= 10;
4026                longest += 1;
4027            }
4028            // loop.c:369-373 — fct = (cols - 1) / (longest + 3); if
4029            // 0, fct = 1; else fw = (cols - 1) / fct.
4030            let raw_fct = (term_width.saturating_sub(1)) / (longest + 3);
4031            let (fct, fw) = if raw_fct == 0 {
4032                (1, longest + 3)
4033            } else {
4034                (raw_fct, (term_width.saturating_sub(1)) / raw_fct)
4035            };
4036            // loop.c:374 — colsz = (ct + fct - 1) / fct.
4037            let colsz = ct.div_ceil(fct);
4038            // loop.c:375-395 — for each row t1, walk down columns.
4039            for t1 in 0..colsz {
4040                let mut ap_idx = t1;
4041                while ap_idx < ct {
4042                    let w = &words[ap_idx];
4043                    let n = ap_idx + 1;
4044                    let _ = write!(std::io::stderr(), "{}) {}", n, w);
4045                    let mut t2 = w.chars().count() + 2;
4046                    let mut t3 = n;
4047                    while t3 > 0 {
4048                        t2 += 1;
4049                        t3 /= 10;
4050                    }
4051                    // Pad to fw (loop.c:389-390).
4052                    while t2 < fw {
4053                        let _ = write!(std::io::stderr(), " ");
4054                        t2 += 1;
4055                    }
4056                    ap_idx += colsz;
4057                }
4058                let _ = writeln!(std::io::stderr());
4059            }
4060        };
4061        render_menu(); // c:264 — once, before the loop
4062
4063        'select: loop {
4064            // c:266-290 — inner read loop: prompt and read until a NON-EMPTY
4065            // line arrives; each empty line reprints the menu and re-reads.
4066            let trimmed = loop {
4067                let _ = write!(std::io::stderr(), "{}", prompt);
4068                let _ = std::io::stderr().flush();
4069
4070                let mut line = String::new();
4071                match reader.read_line(&mut line) {
4072                    Ok(0) => {
4073                        // c:277-285 — EOF (user pressed Ctrl+D): REPLY="",
4074                        // a newline to stderr, then leave the construct.
4075                        with_executor(|exec| {
4076                            exec.set_scalar("REPLY".to_string(), String::new());
4077                        });
4078                        let _ = writeln!(std::io::stderr());
4079                        let _ = std::io::stderr().flush();
4080                        break 'select;
4081                    }
4082                    Ok(_) => {}
4083                    Err(_) => break 'select,
4084                }
4085                let t = line.trim_end_matches(['\n', '\r'][..].as_ref()).to_string();
4086                // c:288-289 — `if (*str) break;`
4087                if !t.is_empty() {
4088                    break t;
4089                }
4090                // c:290 — `more = selectlist(args, more);` on an empty line.
4091                render_menu();
4092            };
4093            // c:291 `setsparam("REPLY", ztrdup(str));` — REPLY is set once the
4094            // inner loop yields a non-empty line. An empty line never reaches
4095            // here: c:290 reprints and re-reads instead.
4096            with_executor(|exec| {
4097                exec.set_scalar("REPLY".to_string(), trimmed.clone());
4098            });
4099
4100            // c:293 `i = atoi(str);` — atoi(3) reads a LEADING integer:
4101            // optional blanks, optional sign, then digits, ignoring whatever
4102            // trails, and yields 0 when there are no digits at all.
4103            // `parse::<usize>()` is strict and rejected `1 2` / `1abc` / `+2`
4104            // / ` 1`, so a reply with anything after the number selected
4105            // NOTHING where zsh selects the leading number's item.
4106            let i: i64 = {
4107                let b = trimmed.as_bytes();
4108                let mut p = 0;
4109                while p < b.len() && (b[p] == b' ' || b[p] == b'\t') {
4110                    p += 1;
4111                }
4112                let neg = p < b.len() && b[p] == b'-';
4113                if p < b.len() && (b[p] == b'-' || b[p] == b'+') {
4114                    p += 1;
4115                }
4116                let mut v: i64 = 0;
4117                while p < b.len() && b[p].is_ascii_digit() {
4118                    v = v.saturating_mul(10).saturating_add((b[p] - b'0') as i64);
4119                    p += 1;
4120                }
4121                if neg {
4122                    -v
4123                } else {
4124                    v
4125                }
4126            };
4127            // c:294-301 — `if (!i) str = "";` else walk i-1 nodes and take
4128            // that word; running off the end leaves "". A NEGATIVE i walks
4129            // until the list is exhausted (`n && i`), which also lands on "".
4130            let chosen = if i <= 0 {
4131                String::new()
4132            } else {
4133                words.get((i - 1) as usize).cloned().unwrap_or_default()
4134            };
4135
4136            with_executor(|exec| {
4137                exec.set_scalar(name.clone(), chosen);
4138            });
4139
4140            // Reset canonical BREAKS/CONTFLAG before running the body
4141            // so a stale value from a sibling construct doesn't leak in.
4142            crate::ported::builtin::BREAKS.store(0, SeqCst);
4143            crate::ported::builtin::CONTFLAG.store(0, SeqCst);
4144
4145            // c:Src/loop.c — `select` increments LOOPS for the body so
4146            // `break` / `continue` inside the body see loops > 0 and
4147            // don't emit `not in while, until, select, or repeat loop`.
4148            // Mirrors execwhile/execrepeat's `LOOPS.fetch_add` pattern.
4149            // The decrement happens after the body call so a body that
4150            // explicitly returns / errors still leaves the counter
4151            // balanced for the next iteration.
4152            crate::ported::builtin::LOOPS.fetch_add(1, SeqCst);
4153
4154            crate::fusevm_disasm::maybe_print_stdout("select:body", &chunk);
4155            let mut body_vm = fusevm::VM::new(chunk.clone());
4156            register_builtins(&mut body_vm);
4157            let _ = body_vm.run();
4158            last_status = body_vm.last_status;
4159
4160            crate::ported::builtin::LOOPS.fetch_sub(1, SeqCst);
4161
4162            // Drain the canonical BREAKS/CONTFLAG counters. Mirrors
4163            // loop.c:529-534's `if (breaks) { breaks--; if (breaks ||
4164            // !contflag) break; contflag = 0; }` drain pattern.
4165            // The legacy `BREAK_SELECT=1` env-var sentinel is still
4166            // honored for backward compat.
4167            let break_legacy = with_executor(|exec| {
4168                let v = exec.scalar("BREAK_SELECT");
4169                exec.unset_scalar("BREAK_SELECT");
4170                v.map(|s| s != "0" && !s.is_empty()).unwrap_or(false)
4171            });
4172            use std::sync::atomic::Ordering::SeqCst;
4173            let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
4174            if breaks > 0 {
4175                let cont = crate::ported::builtin::CONTFLAG.load(SeqCst);
4176                crate::ported::builtin::BREAKS.fetch_sub(1, SeqCst);
4177                if breaks - 1 > 0 || cont == 0 {
4178                    break;
4179                }
4180                crate::ported::builtin::CONTFLAG.store(0, SeqCst);
4181                continue;
4182            }
4183            if break_legacy {
4184                break;
4185            }
4186        }
4187
4188        Value::Status(last_status)
4189    });
4190
4191    // Magic special-parameter assoc lookup. Synthesizes values from
4192    // shell state for zsh's shell-introspection assocs:
4193    //   commands, aliases, galiases, saliases, dis_aliases, dis_galiases,
4194    //   dis_saliases, functions, dis_functions, builtins, dis_builtins,
4195    //   reswords, options, parameters, jobtexts, jobdirs, jobstates,
4196    //   nameddirs, userdirs, modules.
4197    // Returns None if `name` isn't a recognized magic name.
4198
4199    // `${arr[idx]}` — pop name, then idx_str. zsh is 1-based for positive
4200    // indices; we honor that. `@`/`*` return the whole array as Value::Array
4201    // so Op::Exec splice produces N argv slots. For `${foo[key]}` where foo
4202    // is an assoc, the idx is a string key — we check assoc_arrays first
4203    // when the idx isn't `@`/`*` and the name has an assoc binding.
4204    // BUILTIN_ARRAY_INDEX — `${name[idx]}` paramsubst dispatch.
4205    // PURE PASSTHRU: pops the idx + name, hands the canonical
4206    // `${name[idx]}` form to `subst::paramsubst` (C port of
4207    // `Src/subst.c::paramsubst`). All subscript-flag dispatch
4208    // ((I)pat / (R)pat / (i)/(r)/(K)/(k), range slices `[N,M]`,
4209    // negative indices, magic-assoc shape lookup, DQ-join collapse)
4210    // lives inside paramsubst → fetchvalue → getarg in params.rs.
4211    //
4212    // Outer-flag dispatch (`(@)` / `(@k)` / `(v)NAME[(I)pat]` / etc.)
4213    // routes through BUILTIN_BRIDGE_BRACE_ARRAY at the compile path
4214    // (canonical paramsubst flag parser owns dispatch at Src/subst.c:2147+),
4215    // so BUILTIN_ARRAY_INDEX receives clean name+key with no sentinel
4216    // prefixes.
4217    vm.register_builtin(BUILTIN_ARRAY_INDEX, |vm, _argc| {
4218        let idx = vm.pop().to_str();
4219        let name = vm.pop().to_str();
4220        array_index_lookup(&name, &idx)
4221    });
4222    // BUILTIN_ARRAY_INDEX_UNBRACED — bare `$name[idx]` (no braces).
4223    // Same subscript dispatch as BUILTIN_ARRAY_INDEX when KSHARRAYS
4224    // is unset, but under KSHARRAYS the UNBRACED form does NOT
4225    // subscript at all:
4226    //   c:Src/subst.c:2800-2802 — fetchvalue's bracket-parse arg is
4227    //     `(unset(KSHARRAYS) || inbrace) ? 1 : -1`; -1 inhibits
4228    //     subscript parsing for the bare form under KSHARRAYS.
4229    //   c:Src/subst.c:2867 — the bracket-consuming loop only runs
4230    //     `while (v || ((inbrace || (unset(KSHARRAYS) && vunset)) &&
4231    //     isbrack(*s)))` — bare + KSHARRAYS leaves `[...]` as literal
4232    //     trailing text.
4233    // The bare `$name` expands (first element for identifier-named
4234    // arrays per c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`),
4235    // the literal `[idx]` (+ any literal suffix) joins the last word,
4236    // and the word undergoes filename generation: `[...]` is a glob
4237    // char class, so unquoted it hits the c:Src/glob.c:1873-1886
4238    // nomatch/nullglob dispatch (reused via exec.expand_glob).
4239    // Operands: [name, idx, suffix, quoted] — `quoted` set when the
4240    // word carries DQ markers (no filename generation in DQ; zsh 5.9:
4241    // `setopt ksharrays; a=(x y z); print "$a[0]"` → `x[0]`).
4242    // Verbatim zsh 5.9 ground truth for the unquoted form:
4243    //   `setopt ksharrays; a=(x y z); print -- $a[0]` →
4244    //   stderr `zsh:1: no matches found: x[0]`, rc=1, empty stdout.
4245    vm.register_builtin(BUILTIN_ARRAY_INDEX_UNBRACED, |vm, _argc| {
4246        let quoted = vm.pop().to_str() == "1";
4247        let suffix = vm.pop().to_str();
4248        let idx = vm.pop().to_str();
4249        let name = vm.pop().to_str();
4250        if !crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
4251            let v = array_index_lookup(&name, &idx);
4252            if suffix.is_empty() {
4253                return v;
4254            }
4255            // Mirrors the previous compile-shape `ARRAY_INDEX +
4256            // Op::Concat` exactly: Concat stringifies via as_str_cow
4257            // (fusevm value.rs:132-146, arrays join with " ").
4258            return Value::str(format!("{}{}", v.to_str(), suffix));
4259        }
4260        // KSHARRAYS bare form: no subscript. Bare-`$name` words +
4261        // literal `[idx]suffix` glued onto the last word.
4262        let mut words = ksharrays_bare_words(&name);
4263        let last = format!("{}[{}]{}", words.pop().unwrap_or_default(), idx, suffix);
4264        if quoted {
4265            // DQ context — no filename generation, bracket text stays
4266            // literal.
4267            words.push(last);
4268            return Value::str(words.join(" "));
4269        }
4270        // c:Src/glob.c:1873-1886 — expand_glob handles nullglob /
4271        // NOMATCH (zerr "no matches found" + errflag + the
4272        // current_command_glob_failed cell consumed at the command
4273        // dispatch boundary) / literal passthrough for glob-free text.
4274        let matches = with_executor(|exec| exec.expand_glob(&last));
4275        let mut out: Vec<Value> = words.into_iter().map(Value::str).collect();
4276        out.extend(matches.into_iter().map(Value::str));
4277        if out.len() == 1 {
4278            return out.pop().unwrap();
4279        }
4280        Value::array(out)
4281    });
4282    // BUILTIN_ASSOC_HAS_KEY — `${(k)assoc[name]}` key-existence query.
4283    // Pops [assoc_name, key]; returns key (Str) if present in the
4284    // assoc, empty Str otherwise. Mirrors zsh's `${(k)h[name]}`
4285    // documented semantics in zshparam(1) "Parameter Expansion Flags".
4286    // Distinct from BUILTIN_ARRAY_INDEX (which returns the VALUE) and
4287    // from `${+h[name]}` (which returns "0"/"1"). Bug #145.
4288    vm.register_builtin(BUILTIN_ASSOC_HAS_KEY, |vm, _argc| {
4289        let key = vm.pop().to_str();
4290        let name = vm.pop().to_str();
4291        // c:Src/params.c getindex — the subscript text is substituted
4292        // (singsub) before the lookup; `${(k)H[$k]}` must resolve $k.
4293        // The compiler hands this opcode the RAW subscript text, so a
4294        // dynamic key arrived literally ("$k") and matched nothing.
4295        // singsub is identity for plain keys.
4296        let key = if key.contains('$') || key.contains('`') || key.contains('\u{8c}') {
4297            crate::ported::subst::singsub(&key)
4298        } else {
4299            key
4300        };
4301        // c:Src/params.c:3131 gethkparam covers ordinary PM_HASHED
4302        // paramtab entries only. Special/magic hashes (`parameters`,
4303        // `options`, … — the zsh/parameter module's partab-backed
4304        // params, Src/Modules/parameter.c) aren't in that storage, so
4305        // a None here doesn't mean "no such assoc". Route those
4306        // through paramsubst, whose assoc materialization handles the
4307        // magic hashes and whose getarg port (c:Src/params.c:1591 +
4308        // Src/subst.c:2922) returns the KEY for `(k)` on a plain
4309        // subscript. zsh 5.9: `${(k)parameters[PATH]}` → "PATH".
4310        // c:Src/params.c:1602-1612 — on a hash subscript C dispatches
4311        // `ht->getnode(ht, s)`; it never enumerates. For the magic hashes that
4312        // distinction is observable: `getfunction_source` (c:Src/Modules/
4313        // parameter.c:549-566) answers for names its scan never lists, `mapfile`
4314        // answers for any readable file, and the job trio calls `getjob(name,
4315        // NULL)` whose `job not found` diagnostic (Src/jobs.c:2150-2151) must be
4316        // emitted by the read. `gethkparam` answers Some(<enumerated keys>) for
4317        // these names, which shortcut the dispatch entirely — `${(k)jobstates[x]}`
4318        // stayed silent and `${(k)functions_source[x]}` returned "" where zsh
4319        // returns the key. Send PARTAB names to paramsubst, which owns the
4320        // getnode path (c:Src/subst.c:2923-2925 — key when set, "" when unset).
4321        // Keys carrying `]`/`}` can't survive the flat rebuild (see
4322        // array_index_lookup), so those keep the enumeration answer.
4323        let magic_getnode = !key.contains(']')
4324            && !key.contains('}')
4325            && crate::ported::modules::parameter::PARTAB
4326                .iter()
4327                .any(|e_| e_.name == name);
4328        match crate::ported::params::gethkparam(&name) {
4329            Some(keys) if !magic_getnode => {
4330                if keys.iter().any(|k| k == &key) {
4331                    Value::str(key)
4332                } else {
4333                    Value::str("")
4334                }
4335            }
4336            _ => paramsubst_to_value(&format!("${{(k){}[{}]}}", name, key)),
4337        }
4338    });
4339    vm.register_builtin(BUILTIN_BRIDGE_BRACE_ARRAY, |vm, _argc| {
4340        // Inner body of `${(...)...}` (already stripped of `${`/`}` by
4341        // the caller). The compiler optionally prefixes Qstring
4342        // (\u{8c}) to signal "expanded in DQ context" — strip it
4343        // here and bump in_dq_context for the paramsubst call so the
4344        // SUB_ZIP and other qt-aware paths fire.
4345        let body = vm.pop().to_str();
4346        let (dq, inner) = if let Some(rest) = body.strip_prefix('\u{8c}') {
4347            (true, rest.to_string())
4348        } else {
4349            (false, body)
4350        };
4351        if dq {
4352            with_executor(|exec| exec.in_dq_context += 1);
4353        }
4354        let v = paramsubst_to_value(&format!("${{{}}}", inner));
4355        if dq {
4356            with_executor(|exec| exec.in_dq_context -= 1);
4357        }
4358        v
4359    });
4360
4361    // BUILTIN_PARAM_FLAG — `${(flags)name}` paramsubst dispatch.
4362    // PURE PASSTHRU: pops sentinel-tagged flags + name, hands the
4363    // canonical `${(flags)name}` form to `subst::paramsubst` (C port
4364    // of `Src/subst.c::paramsubst`). The bridge does no flag
4365    // walking, no DQ-context branching, no array/scalar shape
4366    // selection — all of that lives inside paramsubst. Compile-time
4367    // context (DQ / scalar-assign-RHS) flows through executor cells
4368    // (in_dq_context, in_scalar_assign) bumped by BUILTIN_EXPAND_TEXT.
4369    vm.register_builtin(BUILTIN_PARAM_FLAG, |vm, argc| {
4370        // argc 3 = the compiler flagged this expansion as the VALUE of a
4371        // scalar assignment (`x=…` / `local x=…`), which C preforks with
4372        // PREFORK_SINGLE|PREFORK_ASSIGN (c:Src/exec.c:2603 / :4239-4241).
4373        // PREFORK_SINGLE is paramsubst's `ssub` (c:Src/subst.c:1761) and
4374        // gates off c:3913's `force_split`, so `(s::)` / `(f)` / `(0)` do
4375        // not split there. argc 2 = ordinary word, no ssub.
4376        let ssub = if argc >= 3 {
4377            vm.pop().to_int() != 0
4378        } else {
4379            false
4380        };
4381        let flags = vm.pop().to_str();
4382        let name = vm.pop().to_str();
4383        let body = format!("${{({}){}}}", flags, name);
4384        let pf_flags = if ssub {
4385            crate::ported::zsh_h::PREFORK_SINGLE
4386        } else {
4387            0
4388        };
4389        paramsubst_to_value_pf(&body, pf_flags)
4390    });
4391
4392    // `foo[key]=val` — single-key set on an assoc array. Stack: [name, key, value].
4393    // PURE PASSTHRU: assignsparam with `name[key]` form (C port of
4394    // `Src/params.c::assignsparam` subscript path at c:3210-3231)
4395    // already does the indexed-array vs assoc decision, PM_HASHED
4396    // auto-vivification, numeric-subscript bounds handling, and
4397    // PM_READONLY rejection.
4398    /// Assign `val` to one element of the PM_HASHED parameter `name`.
4399    ///
4400    /// This is the tail of C's `assignsparam` for a subscripted target:
4401    /// c:Src/params.c:3251 `getvalue(&vbuf, &t, 1)` (→ `fetchvalue` →
4402    /// `getindex`) followed by c:3343 `assignstrvalue(v, val, flags)`.
4403    ///
4404    /// C hands `getindex` the FLAT `"name[subscript]"` text, and that is
4405    /// safe there because its subscript is still the SOURCE spelling: the
4406    /// bracket walk at c:2008 `parse_subscript` runs BEFORE the
4407    /// `parsestr`/`singsub` round at c:1585-1592, so a `]` that arrives by
4408    /// expansion can never terminate it. zshrs expands a subscript before
4409    /// this builtin runs, so re-flattening to `name[key]` and re-splitting
4410    /// corrupts any key containing `]` (`k='x]y'; h[$k]=5` stored `x`). The
4411    /// two halves therefore stay separate the whole way down:
4412    ///
4413    ///   * `sub` is the EXPANDED subscript — the key text.
4414    ///   * `sub_src` is the SOURCE subscript, used for ONE decision, the
4415    ///     one C makes at c:1410: `if (v->pm && (*s == '(' || *s == Inpar))`
4416    ///     — is there a flag block? Flags can only be literal (they are read
4417    ///     at c:1409, before any expansion), so `x='(r)v'; h[$x]=Z` has no
4418    ///     flag block and stores the literal key `(r)v`, while `h[(r)$x]=Z`
4419    ///     does have one and is a search.
4420    ///
4421    /// With no flag block there is nothing for `getindex` to resolve beyond
4422    /// the exact-key rebind at c:1596-1616, so the key goes straight to the
4423    /// element store and never meets a parser. With one, `getindex` runs on
4424    /// the EXPANDED text: its flag block is byte-identical to the source's
4425    /// (flags are literal) and its pattern is already substituted, which is
4426    /// what c:1585-1592 would have produced anyway.
4427    fn assign_hash_element(name: &str, sub: &str, sub_src: &str, val: &str) -> i32 {
4428        use crate::ported::zsh_h::{Inpar, PM_HASHED, PM_READONLY, SCANPM_ARRONLY};
4429        let pm = crate::ported::params::paramtab()
4430            .read()
4431            .ok()
4432            .and_then(|t| t.get(name).cloned());
4433        // c:3216-3221 — `if (v->pm->node.flags & PM_READONLY)`.
4434        if pm
4435            .as_ref()
4436            .is_some_and(|p| (p.node.flags as u32 & PM_READONLY) != 0)
4437        {
4438            crate::ported::utils::zerr(&format!("read-only variable: {}", name)); // c:3217
4439            return 1; // c:3221
4440        }
4441        // c:1410 — `if (v->pm && (*s == '(' || *s == Inpar))`, read off the
4442        // SOURCE spelling.
4443        let has_flags = sub_src.starts_with('(') || sub_src.starts_with(Inpar);
4444        if has_flags {
4445            let mut v = crate::ported::zsh_h::value {
4446                pm,
4447                arr: Vec::new(),
4448                // c:2274-2280 — fetchvalue promotes a PM_ARRAY/PM_HASHED
4449                // value with no caller flags to SCANPM_ARRONLY; assignsparam
4450                // arrives through `getvalue`, i.e. flags 0.
4451                scanflags: SCANPM_ARRONLY as i32,
4452                valflags: 0,
4453                start: 0,
4454                end: -1, // c:2279
4455            };
4456            let bracketed = format!("[{}]", sub); // c:2281 `*s == '['`
4457            let mut sp: &str = &bracketed;
4458            if crate::ported::params::getindex(&mut sp, &mut v, 0) != 0 {
4459                // c:2020-2022 — `zerr("invalid subscript")` already reported.
4460                return 1;
4461            }
4462            let elem_is_hash = v
4463                .pm
4464                .as_ref()
4465                .is_some_and(|p| crate::ported::zsh_h::PM_TYPE(p.node.flags as u32) == PM_HASHED);
4466            if elem_is_hash {
4467                // A search subscript: the c:1596 exact-key rebind did not
4468                // happen, so the value still refers to the whole association
4469                // and c:3343 `assignstrvalue` reports it — either "attempt to
4470                // set slice of associative array" (c:2701-2706, the scanflags
4471                // survived the c:2179 clear) or "attempt to set associative
4472                // array to scalar" (c:2831-2839, they did not and no member
4473                // was found).
4474                let pre = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
4475                crate::ported::params::assignstrvalue(
4476                    Some(&mut v),
4477                    Some(val.to_string()),
4478                    crate::ported::zsh_h::ASSPM_WARN,
4479                ); // c:3343
4480                let post = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
4481                return if post != pre { 1 } else { 0 };
4482            }
4483            // c:1596-1616 — a non-search flag group (`(e)`, `(w)`, `(n:N:)`,
4484            // `(p)`, or an unrecognised one, c:1498 `flagerr`): `v->pm` is now
4485            // the ELEMENT and its name is the subscript with the group already
4486            // consumed. That name IS the key.
4487            let key =
4488                v.pm.as_ref()
4489                    .map_or(sub, |p| p.node.nam.as_str())
4490                    .to_string();
4491            return store_hash_element(name, &key, val);
4492        }
4493        // c:1596-1616 with no flag group at all — the subscript is the key
4494        // verbatim. Nothing to parse, so an expanded `]` stays intact.
4495        store_hash_element(name, sub, val)
4496    }
4497
4498    /// c:Src/params.c:2841 — `foundparam->gsu.s->setfn(foundparam, val)`,
4499    /// the write to one member of an association. This port keeps assoc
4500    /// members as plain strings in `paramtab_hashed_storage` rather than as
4501    /// Params carrying their own `strsetfn` (the same substitution
4502    /// `arrhashsetfn` makes at c:4113), so the member write is a map insert.
4503    fn store_hash_element(name: &str, key: &str, val: &str) -> i32 {
4504        if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
4505            store
4506                .entry(name.to_string())
4507                .or_default()
4508                .insert(key.to_string(), val.to_string()); // c:2841
4509        }
4510        0
4511    }
4512
4513    vm.register_builtin(BUILTIN_SET_ASSOC, |vm, _argc| {
4514        // `${~spec}` carrier: an assignment statement is a word-
4515        // pipeline boundary too — restore the user's GLOB_SUBST
4516        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
4517        // ${options[globsubst]}` must read the user value).
4518        consume_tilde_globsubst_carrier();
4519        // argc 4 = compile flagged the subscript as DYNAMIC (`H[$k]`):
4520        // an EXPANDED-empty key is then a legal assoc key (C's
4521        // assignsparam isident gate sees the raw `$k` text and the
4522        // empty key stores at getindex time — zinit's
4523        // ZINIT_SICE[$1…$2] relies on it). argc 3 = source-literal
4524        // key; `H[]` stays the "not an identifier" error.
4525        // Stack shapes (compile_zsh::compile_assign):
4526        //   argc 4 = [name, key, value, key_src]            — literal key
4527        //   argc 5 = [name, key, value, key_src, dynamic]   — expanded key
4528        // `key_src` is the SOURCE spelling of the subscript, kept beside
4529        // the expanded one so nothing downstream has to re-flatten
4530        // `name[key]` and re-split it (c:Src/params.c:2008 parses the
4531        // subscript BEFORE expansion; see `assign_hash_element`).
4532        let key_is_dynamic = if _argc >= 5 {
4533            vm.pop().to_int() != 0
4534        } else {
4535            false
4536        };
4537        let key_src = if _argc >= 4 {
4538            Some(vm.pop().to_str())
4539        } else {
4540            None
4541        };
4542        let value = vm.pop().to_str();
4543        let key = vm.pop().to_str();
4544        let name = vm.pop().to_str();
4545        let key_src = key_src.unwrap_or_else(|| key.clone());
4546        // c:Src/params.c:3203-3207 — `if (!isident(s)) { zerr("not an
4547        // identifier: %s", s); errflag |= ERRFLAG_ERROR; return NULL; }`.
4548        // Every subscripted assignment passes through that gate, and isident
4549        // rejects an empty subscript at c:1334 `if (!(ss =
4550        // parse_subscript(++ss, 1, ']'))) return 0;` — the LHS text is
4551        // untokenized by then, so the `]` IS parse_subscript's literal endchar
4552        // and c:Src/lex.c:1748 returns NULL. `m[]=z` / `A[]=z` / `s[]=z` are
4553        // therefore all `not an identifier: NAME[]`, never a store.
4554        // Only the SOURCE-LITERAL empty subscript is affected: with a dynamic
4555        // key (`H[$k]=v`) C's isident sees the unexpanded `$k` text, passes,
4556        // and getindex stores the expanded — possibly empty — key.
4557        // The gate lives here because the PM_HASHED fast path and the numeric
4558        // pre-resolve below both reach the store without calling assignsparam
4559        // (the empty key resolved to "" for a hash and to math-0 for an
4560        // indexed array, so `A[]=z` reported "assignment to invalid subscript
4561        // range" instead). Route through assignsparam so the diagnostic and
4562        // the errflag are the canonical ones.
4563        if !key_is_dynamic && key.is_empty() {
4564            crate::ported::params::assignsparam(
4565                &format!("{}[]", name), // c:3203 — the LHS spelling zsh reports
4566                &value,
4567                crate::ported::zsh_h::ASSPM_WARN,
4568            );
4569            return Value::Status(1);
4570        }
4571        // Bash sparse-array tracking for `a[i]=v` (scalar single-index). A set
4572        // that pads the dense Vec past its old end leaves old_len..i as holes;
4573        // on an undefined array, `a[5]=q` leaves only index 5 (count 1). Only
4574        // for INDEXED arrays (assoc keys are strings), bash mode, numeric key.
4575        // Captured before the assign; applied after (on the array path).
4576        let sparse_track: Option<(String, usize, usize)> =
4577            if crate::dash_mode::sparse_arrays() && !key.contains(',') {
4578                key.trim().parse::<usize>().ok().and_then(|i| {
4579                    with_executor(|exec| {
4580                        if !exec.has_assoc(&name) {
4581                            let old_len = exec.array(&name).map(|a| a.len()).unwrap_or(0);
4582                            Some((name.clone(), old_len, i))
4583                        } else {
4584                            None
4585                        }
4586                    })
4587                })
4588            } else {
4589                None
4590            };
4591        if key_is_dynamic && key.is_empty() {
4592            with_executor(|exec| {
4593                let _ = exec;
4594            });
4595            // Mirror assignsparam's PM_HASHED tail directly (the
4596            // textual `name[]` reconstruction can't pass isident).
4597            if let Ok(mut store) = crate::ported::params::paramtab_hashed_storage().lock() {
4598                let entry = store.entry(name.clone()).or_default();
4599                let newval = if let Some(old) = entry.get("") {
4600                    // `+=` arrives pre-concatenated by the compile
4601                    // read-modify-write; plain `=` overwrites.
4602                    let _ = old;
4603                    value.clone()
4604                } else {
4605                    value.clone()
4606                };
4607                entry.insert(String::new(), newval);
4608            }
4609            return Value::Status(0);
4610        }
4611        with_executor(|exec| {
4612            #[cfg(feature = "recorder")]
4613            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
4614                let ctx = exec.recorder_ctx();
4615                let attrs = exec.recorder_attrs_for(&name);
4616                crate::recorder::emit_assoc_assign(
4617                    &name,
4618                    vec![(key.clone(), value.clone())],
4619                    attrs,
4620                    true,
4621                    ctx,
4622                );
4623            }
4624            let _ = exec;
4625        });
4626        // Build `name[key]=value` shape for assignsparam's subscript
4627        // dispatch. Arith-evaluate numeric subscripts on an existing
4628        // indexed array (`a[i+1]=v` form) before handing off — the
4629        // canonical port currently only handles literal int / string
4630        // keys, so pre-resolve here.
4631        let resolved_key = with_executor(|exec| {
4632            // Existence probes only — use the non-cloning `has_*`
4633            // checks. `exec.assoc()` / `exec.array()` return owned
4634            // clones of the whole map/vector, so probing `.is_some()`
4635            // here copied the entire associative array on every
4636            // `h[k]=v` (O(n) per store → O(n²) for a fill loop). The
4637            // profiler flagged `ShellExecutor::assoc → IndexMap::clone`
4638            // as the dominant cost.
4639            let is_indexed = exec.has_array(&name);
4640            let is_assoc = exec.has_assoc(&name);
4641            let is_scalar = !is_indexed && !is_assoc && exec.has_scalar(&name);
4642            // c:Src/params.c::getindex — `(i)pat` / `(I)pat` / `(R)pat`
4643            // / `(r)pat` subscript flags on an indexed array LHS resolve
4644            // to a numeric index (first / last match of pat). On a
4645            // SCALAR LHS the same flags resolve to a CHAR position
4646            // (1-based first/last match of pat in the scalar string)
4647            // for the c:2748+ char-splice assignment. zshrs's
4648            // read-form `${a[(i)pat]}` already implements both shapes;
4649            // the LHS assignment path silently stored the literal
4650            // "(i)pat" as an assoc key (for scalar: auto-vivified to
4651            // PM_HASHED via the assignsparam unknown-subscript
4652            // fallback). Bug #293 (array) / scalar sibling.
4653            //
4654            // Detect the `(flags)pat` shape and resolve to a numeric
4655            // index before assignsparam.
4656            if is_indexed || is_scalar {
4657                if let Some(rest) = key.strip_prefix('(') {
4658                    if let Some(close) = rest.find(')') {
4659                        let flags = &rest[..close];
4660                        let pat = &rest[close + 1..];
4661                        if !flags.is_empty()
4662                            && flags
4663                                .chars()
4664                                .all(|c| matches!(c, 'I' | 'R' | 'i' | 'r' | 'n' | 'e'))
4665                        {
4666                            // Resolve via the array's contents.
4667                            if let Some(arr) = exec.array(&name) {
4668                                let return_index = true; // LHS write — index needed
4669                                let down = flags.contains('I') || flags.contains('R');
4670                                let exact = flags.contains('e');
4671                                let iter: Box<dyn Iterator<Item = (usize, &String)>> = if down {
4672                                    Box::new(arr.iter().enumerate().rev())
4673                                } else {
4674                                    Box::new(arr.iter().enumerate())
4675                                };
4676                                let mut found: Option<usize> = None;
4677                                for (idx, elem) in iter {
4678                                    let matched = if exact {
4679                                        elem == pat
4680                                    } else {
4681                                        crate::ported::pattern::patcompile(
4682                                            &{
4683                                                let mut __pat_tok = (pat).to_string();
4684                                                crate::ported::glob::tokenize(&mut __pat_tok);
4685                                                __pat_tok
4686                                            },
4687                                            crate::ported::zsh_h::PAT_HEAPDUP as i32,
4688                                            None,
4689                                        )
4690                                        .map_or(false, |p| crate::ported::pattern::pattry(&p, elem))
4691                                    };
4692                                    if matched {
4693                                        found = Some(idx);
4694                                        break;
4695                                    }
4696                                }
4697                                let _ = return_index;
4698                                // (i)/(r) return 1-based index of match,
4699                                // arr.len()+1 (or 1 for I/R) on miss
4700                                // per zsh docs. We mirror the read-form
4701                                // semantics from subst.rs.
4702                                let idx_1based = match found {
4703                                    Some(i) => (i + 1) as i64,
4704                                    None => (arr.len() + 1) as i64,
4705                                };
4706                                return idx_1based.to_string();
4707                            }
4708                            // Scalar LHS — resolve to a CHAR position
4709                            // (1-based first/last match of pat in the
4710                            // string). c:Src/params.c:1411-1418 — the
4711                            // scalar path returns the char index from
4712                            // sliding-window pattern match against
4713                            // pm.u_str. Same algorithm as the read-form
4714                            // at subst.rs:5283-5306. Bug (scalar
4715                            // sibling of #293): `a=hello; a[(I)l]=X`
4716                            // previously auto-vivified `a` into
4717                            // PM_HASHED with key "(I)l" instead of
4718                            // splicing X at the last 'l' position
4719                            // (yielding "helXo").
4720                            if is_scalar {
4721                                let s = exec.scalar(&name).unwrap_or_default();
4722                                let s_chars: Vec<char> = s.chars().collect();
4723                                let n = s_chars.len();
4724                                let want_last = flags.contains('I') || flags.contains('R');
4725                                let exact = flags.contains('e');
4726                                let mut found: Option<usize> = None;
4727                                'outer: for start in 0..=n {
4728                                    let lengths: Box<dyn Iterator<Item = usize>> = if want_last {
4729                                        Box::new((1..=(n - start)).rev())
4730                                    } else {
4731                                        Box::new(1..=(n - start))
4732                                    };
4733                                    for len in lengths {
4734                                        let cand: String =
4735                                            s_chars[start..start + len].iter().collect();
4736                                        let matched = if exact {
4737                                            cand == pat
4738                                        } else {
4739                                            crate::ported::pattern::patcompile(
4740                                                &{
4741                                                    let mut __pat_tok = (pat).to_string();
4742                                                    crate::ported::glob::tokenize(&mut __pat_tok);
4743                                                    __pat_tok
4744                                                },
4745                                                crate::ported::zsh_h::PAT_HEAPDUP as i32,
4746                                                None,
4747                                            )
4748                                            .map_or(false, |p| {
4749                                                crate::ported::pattern::pattry(&p, &cand)
4750                                            })
4751                                        };
4752                                        if matched {
4753                                            found = Some(start);
4754                                            if !want_last {
4755                                                break 'outer;
4756                                            }
4757                                            break;
4758                                        }
4759                                    }
4760                                }
4761                                // (I)/(R): scan again to find LAST.
4762                                if want_last {
4763                                    let mut last_found: Option<usize> = found;
4764                                    for start in (0..=n).rev() {
4765                                        for len in 1..=(n - start) {
4766                                            let cand: String =
4767                                                s_chars[start..start + len].iter().collect();
4768                                            let matched = if exact {
4769                                                cand == pat
4770                                            } else {
4771                                                crate::ported::pattern::patcompile(
4772                                                    &{
4773                                                        let mut __pat_tok = (pat).to_string();
4774                                                        crate::ported::glob::tokenize(
4775                                                            &mut __pat_tok,
4776                                                        );
4777                                                        __pat_tok
4778                                                    },
4779                                                    crate::ported::zsh_h::PAT_HEAPDUP as i32,
4780                                                    None,
4781                                                )
4782                                                .map_or(false, |p| {
4783                                                    crate::ported::pattern::pattry(&p, &cand)
4784                                                })
4785                                            };
4786                                            if matched {
4787                                                last_found = Some(start);
4788                                                break;
4789                                            }
4790                                        }
4791                                        if last_found.is_some() && last_found.unwrap() >= start {
4792                                            break;
4793                                        }
4794                                    }
4795                                    found = last_found;
4796                                }
4797                                let idx_1based = match found {
4798                                    Some(i) => (i + 1) as i64,
4799                                    // (i) miss → len+1 (one past end).
4800                                    None => (n + 1) as i64,
4801                                };
4802                                return idx_1based.to_string();
4803                            }
4804                        }
4805                    }
4806                }
4807            }
4808            if is_indexed && key.trim().parse::<i64>().is_err() {
4809                crate::ported::math::mathevali(&crate::ported::subst::singsub(&key))
4810                    .map(|n| n.to_string())
4811                    .unwrap_or(key.clone())
4812            } else {
4813                key.clone()
4814            }
4815        });
4816        // c:Src/params.c getindex — C parses the subscript from the
4817        // TOKENIZED source word, so a `]`/`}` that arrived via `$key`
4818        // expansion is plain data and can never terminate the
4819        // subscript. The textual `name[key]` rebuild below re-parses
4820        // the FLAT string, where an expanded `]` splits the key at the
4821        // first bracket (`c[$k]=5` with k='x]y' stored key "x" and
4822        // spilled junk — zpwr expandstats died on the spill in a later
4823        // math expr). For a PM_HASHED target the compile-time split
4824        // already isolated the exact key: store it directly via the
4825        // canonical hashed storage (same mechanism as the
4826        // dynamic-empty-key arm above / assignsparam's PM_HASHED
4827        // tail), with the readonly guard assignsparam would apply.
4828        let target_flags = with_executor(|exec| exec.param_flags(&name));
4829        // PM_SPECIAL exclusion: the zsh/parameter magic assocs
4830        // (functions / aliases / galiases / saliases / options / …)
4831        // have per-key setfns with SIDE EFFECTS — `functions[x]=body`
4832        // must parse the body into shfunctab (Src/Modules/
4833        // parameter.c:296 setfunction), `aliases[x]=v` must write
4834        // aliastab. The direct hashed-storage store below silently
4835        // swallowed those: zinit's tmp-subst wrappers
4836        // (`functions[autoload]=':zinit-tmp-subst-autoload "$@";'`)
4837        // never became real functions, so every
4838        // `.zinit-tmp-subst-off` spammed `unfunction: no such hash
4839        // table element: autoload/compdef/bindkey/…`. Route specials
4840        // through assignsparam's canonical per-name arms instead.
4841        if (target_flags as u32 & crate::ported::zsh_h::PM_HASHED) != 0
4842            && (target_flags as u32 & crate::ported::zsh_h::PM_SPECIAL) == 0
4843        {
4844            // c:3251 + c:3343 — run the real chain (getindex →
4845            // assignstrvalue) instead of storing the subscript verbatim.
4846            // Storing it verbatim is what made `h[(r)v]=Z` invent a key
4847            // named `(r)v` where zsh reports a slice assignment, and it
4848            // also skipped the c:2701 / c:2831 guards entirely.
4849            let _ = &resolved_key;
4850            return Value::Status(assign_hash_element(&name, &key, &key_src, &value));
4851        }
4852        let subscripted = format!("{}[{}]", name, resolved_key);
4853        crate::ported::params::assignsparam(&subscripted, &value, crate::ported::zsh_h::ASSPM_WARN);
4854        if let Some((nm, old_len, i)) = sparse_track {
4855            crate::bash_arrays::note_subscript_set(&nm, old_len, i);
4856        }
4857        Value::Status(0)
4858    });
4859
4860    // Brace expansion. Routes through executor.xpandbraces (already
4861    // implemented for the pre-fusevm executor). Returns Value::Array.
4862    // BUILTIN_ARRAY_DROP_EMPTY — filter out empty Value::Str entries
4863    // from a Value::Array on the stack. Used by `for x in $@` /
4864    // `for x in $*` unquoted forms which drop empty positionals
4865    // (POSIX-like) but do NOT IFS-split each element internally
4866    // (zsh-specific — scalar word splitting is off by default).
4867    // Distinct from BUILTIN_WORD_SPLIT which routes through
4868    // multsub PREFORK_SPLIT (full IFS-split). Bug #166.
4869    vm.register_builtin(BUILTIN_ARRAY_DROP_EMPTY, |vm, _argc| {
4870        let v = vm.pop();
4871        match v {
4872            Value::Array(items) => {
4873                let filtered: Vec<Value> = items
4874                    .iter()
4875                    .filter(|x| !x.to_str().is_empty())
4876                    .cloned()
4877                    .collect();
4878                Value::array(filtered)
4879            }
4880            Value::Str(s) if s.is_empty() => Value::array(Vec::new()),
4881            other => other,
4882        }
4883    });
4884
4885    // zsh nofork command substitution (c:Src/subst.c:1904-2100) — and the
4886    // ksh93 funsub / mksh valsub it subsumes. See the BUILTIN_KSH_FUNSUB
4887    // doc comment.
4888    vm.register_builtin(BUILTIN_KSH_FUNSUB, |vm, _argc| {
4889        let mut qt = vm.pop().to_int() != 0;
4890        let kind = vm.pop().to_int();
4891        let rplyvar = vm.pop().to_str();
4892        let body = vm.pop().to_str();
4893        // !!! POSIX-FAMILY GATE !!! bash, dash and sh have no nofork
4894        // command substitution — all three answer `bad substitution` and
4895        // fail, measured on this host:
4896        //   bash -c 'printf "%s\\n" "${ printf inner; }"'  -> rc 1
4897        //   dash -c 'printf "%s\\n" "${ printf inner; }"'  -> rc 2
4898        //   sh   -c 'printf "%s\\n" "${ printf inner; }"'  -> rc 1
4899        // The Korn family DOES have it (funsub/valsub) and so does zsh
4900        // 5.10 (`${ … }` / `${| … }` / `${{VAR} … }`), so the gate is the
4901        // bare bash/sh/dash drop-in only: `posix_faithful()` without the
4902        // Korn leg. `--zsh` and native zshrs keep the substitution.
4903        if crate::dash_mode::posix_faithful() && !crate::dash_mode::korn_mode() {
4904            crate::ported::utils::zerr("bad substitution");
4905            crate::ported::utils::errflag.fetch_or(
4906                crate::ported::zsh_h::ERRFLAG_ERROR,
4907                std::sync::atomic::Ordering::Relaxed,
4908            );
4909            with_executor(|exec| exec.set_last_status(1));
4910            return Value::str("");
4911        }
4912        let live_status = vm.last_status;
4913        // c:Src/subst.c:1625 — paramsubst's `qt` is "am I inside double
4914        // quotes", which is a LEXICAL property of the whole word. The
4915        // compiler can see it only when the substitution IS the entire
4916        // word; `"${ print INNER } $?"` reaches here as a segment whose own
4917        // text carries no quotes, so pick the enclosing quoting up from the
4918        // executor's live DQ flag as well (D10nofork.ztst "return statement
4919        // inside, part 1+": trim must NOT eat `print`'s newline there).
4920        if !qt && with_executor(|exec| exec.in_dq_context) != 0 {
4921            qt = true;
4922        }
4923        // c:Src/subst.c — the split decision is read BEFORE the body runs.
4924        // D10nofork.ztst "test word splitting on result" pins that
4925        // explicitly ("setting option inside is too late for that
4926        // substitution"): a `setopt shwordsplit` executed by the body
4927        // must not retroactively split the value it produced.
4928        let split = !qt
4929            && (crate::dash_mode::korn_mode()
4930                || crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT));
4931        let out = with_executor(|exec| {
4932            exec.set_last_status(live_status);
4933            // c:2016 — `startparamscope(); /* "local" behaves as if in a
4934            // function */`, paired with c:2093 `endparamscope()`. All three
4935            // forms take it (the C block is under `if (rplyvar)`), which is
4936            // what makes `outer=GLOBAL; ${| local outer=LOCAL; … }` leave
4937            // the outer value alone (D10nofork.ztst "local declaration
4938            // inside").
4939            crate::ported::utils::inc_locallevel(); // c:2016
4940            let val = match kind {
4941                1 => {
4942                    // c:2018-2024 — `${| cmd }`: `rplypm = createparam(
4943                    // "REPLY", PM_LOCAL|PM_UNSET|PM_HIDE)` inside a
4944                    // `startparamscope()`, so the body sees NO outer REPLY
4945                    // and the outer value is intact afterwards
4946                    // (D10nofork.ztst "Basic substitution and REPLY
4947                    // scoping": `REPLY=OUTER; purr ${| REPLY=INNER } $REPLY`
4948                    // → `INNER OUTER`). mksh's valsub is identical
4949                    // (`mksh -c 'REPLY=outer; y=${|:;}; print "[$y][$REPLY]"'`
4950                    // → `[][outer]`), so one save/clear/restore serves both.
4951                    let saved = crate::ported::params::getsparam(&rplyvar);
4952                    crate::ported::params::unsetparam(&rplyvar);
4953                    let st = exec.execute_script(&body).unwrap_or(0);
4954                    exec.set_last_status(st);
4955                    let reply = crate::ported::params::getsparam(&rplyvar).unwrap_or_default();
4956                    match saved {
4957                        Some(v) => {
4958                            crate::ported::params::setsparam(&rplyvar, &v);
4959                        }
4960                        None => {
4961                            crate::ported::params::unsetparam(&rplyvar);
4962                        }
4963                    }
4964                    Value::str(reply)
4965                }
4966                2 => {
4967                    // c:2026-2033 — `${{VAR} cmd }`: VAR is the result
4968                    // parameter and gets NO local scope (`rplypm` stays
4969                    // NULL for the Inbrace form), so an assignment inside
4970                    // is global. c:2082-2083 then re-enters the ordinary
4971                    // parameter path with `s = dyncat(rplyvar, s)`, which
4972                    // is why an ARRAY result stays an array
4973                    // (D10nofork.ztst "Basic substitution, brace quoting,
4974                    // and array result").
4975                    let st = exec.execute_script(&body).unwrap_or(0);
4976                    exec.set_last_status(st);
4977                    match exec.array(&rplyvar) {
4978                        Some(items) => {
4979                            Value::array(items.into_iter().map(Value::str).collect::<Vec<_>>())
4980                        }
4981                        None => Value::str(
4982                            crate::ported::params::getsparam(&rplyvar).unwrap_or_default(),
4983                        ),
4984                    }
4985                }
4986                _ => {
4987                    // c:2035-2075 — `${ cmd }`: C redirects the body's
4988                    // stdout into a temp file (`">| %s {\n%s\n;}"`,
4989                    // c:2107) and reads it back, so the body still runs in
4990                    // the CURRENT shell. `run_shared_state_substitution`
4991                    // is the same thing with an fd-level capture instead
4992                    // of a temp file.
4993                    let captured = exec.run_shared_state_substitution(&body);
4994                    // c:1908 — `int trim = (!EMULATION(EMULATE_ZSH)) ? 2 : !qt;`
4995                    // and c:2062-2069: trim==2 strips EVERY trailing
4996                    // newline (ksh/bash behaviour), trim==1 strips exactly
4997                    // one, trim==0 strips none.
4998                    let trim: i32 = if crate::dash_mode::korn_mode()
4999                        || !crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_ZSH)
5000                    {
5001                        2
5002                    } else if qt {
5003                        0
5004                    } else {
5005                        1
5006                    };
5007                    let mut b = captured;
5008                    // c:2064-2069 — `while (rplylen > 0 && cmdarg[rplylen-1]
5009                    // == '\n') { rplylen--; if (trim == 1) break; }`
5010                    while trim > 0 && b.ends_with('\n') {
5011                        b.pop();
5012                        if trim == 1 {
5013                            break;
5014                        }
5015                    }
5016                    Value::str(b)
5017                }
5018            };
5019            crate::ported::params::endparamscope(); // c:2093
5020            val
5021        });
5022        // A nofork substitution IS a command substitution, so it publishes
5023        // the body's exit the way `$( … )` does: `ksh -c 'v=${ false; };
5024        // print "rc=$?"'` → `rc=1`. `run_shared_state_substitution` /
5025        // `execute_script` leave it in the executor; the VM's own counter
5026        // is what BUILTIN_SET_VAR hands back as the assignment's status
5027        // (c:Src/exec.c:3396 `lastval = cmdoutval`), so mirror it here.
5028        vm.last_status = with_executor(|exec| exec.last_status());
5029        // An UNQUOTED substitution is word-split only when the shell splits
5030        // ordinary expansions: always under ksh/mksh, and under zsh only
5031        // with SH_WORD_SPLIT. `split` was decided above, before the body
5032        // ran — see the comment there.
5033        match out {
5034            Value::Str(ref s) if split => {
5035                let (_joined, parts, _isarr, _flags) =
5036                    crate::ported::subst::multsub(s, crate::ported::zsh_h::PREFORK_SPLIT);
5037                Value::array(parts.into_iter().map(Value::str).collect::<Vec<_>>())
5038            }
5039            other => other,
5040        }
5041    });
5042
5043    // c:Src/subst.c:3032 `val = sepjoin(aval, sep, 1)` — see the
5044    // BUILTIN_QUOTED_STAR_ONE_WORD doc comment.
5045    vm.register_builtin(BUILTIN_QUOTED_STAR_ONE_WORD, |vm, _argc| match vm.pop() {
5046        Value::Array(items) if items.is_empty() => Value::str(String::new()),
5047        other => other,
5048    });
5049
5050    // BUILTIN_QUOTEDZPUTS — re-wrap top-of-stack scalar via the
5051    // canonical quotedzputs (Src/utils.c:6464). Non-printable bytes
5052    // come back as `$'…'` C-string form so the cond xtrace prefix
5053    // line preserves the source-quoting form for `[[ -n $'\C-[OP' ]]`
5054    // instead of leaking raw ESC + "OP" bytes through the terminal.
5055    vm.register_builtin(BUILTIN_QUOTEDZPUTS, |vm, _argc| {
5056        let s = vm.pop().to_str();
5057        Value::str(crate::ported::utils::quotedzputs(&s))
5058    });
5059
5060    // BUILTIN_QUOTE_TOKENIZED_OUTPUT — char-aware mirror of
5061    // c:Src/exec.c:2114 `quote_tokenized_output`. The canonical
5062    // port at exec::quote_tokenized_output operates on bytes
5063    // (zsh's metafied encoding); zshrs strings are UTF-8 so
5064    // `\u{87}` Star is `[0xC2, 0x87]`, and a byte walk writes
5065    // 0xC2 raw (invalid UTF-8 lead → U+FFFD on lossy decode).
5066    // Walk by char and dispatch the same switch the byte port
5067    // uses, but with the token chars matching the UTF-8 form.
5068    vm.register_builtin(BUILTIN_QUOTE_TOKENIZED_OUTPUT, |vm, _argc| {
5069        let s = vm.pop().to_str();
5070        let mut out = String::with_capacity(s.len());
5071        let chars: Vec<char> = s.chars().collect();
5072        let mut i = 0;
5073        while i < chars.len() {
5074            let c = chars[i];
5075            // c:2120 — Meta-quoted byte: emit `*++s ^ 32`.
5076            // In UTF-8 strings Meta is `\u{83}`; the next char is
5077            // the metafied payload.
5078            if c == '\u{83}' {
5079                if let Some(&n) = chars.get(i + 1) {
5080                    if (n as u32) < 0x80 {
5081                        out.push(((n as u8) ^ 32) as char);
5082                    } else {
5083                        out.push(n);
5084                    }
5085                    i += 2;
5086                    continue;
5087                }
5088                i += 1;
5089                continue;
5090            }
5091            // c:2124 — Nularg: skip.
5092            if c == '\u{a1}' {
5093                i += 1;
5094                continue;
5095            }
5096            // c:2128-2143 — ASCII specials get backslash-prefixed
5097            // then fall through to emit the literal char.
5098            match c {
5099                '\\' | '<' | '>' | '(' | '|' | ')' | '^' | '#' | '~' | '[' | ']' | '*' | '?'
5100                | '$' | ' ' => {
5101                    out.push('\\');
5102                    out.push(c);
5103                    i += 1;
5104                    continue;
5105                }
5106                '\t' => {
5107                    out.push_str("$'\\t'");
5108                    i += 1;
5109                    continue;
5110                }
5111                '\n' => {
5112                    out.push_str("$'\\n'");
5113                    i += 1;
5114                    continue;
5115                }
5116                '\r' => {
5117                    out.push_str("$'\\r'");
5118                    i += 1;
5119                    continue;
5120                }
5121                '=' => {
5122                    if i == 0 {
5123                        out.push('\\');
5124                    }
5125                    out.push(c);
5126                    i += 1;
5127                    continue;
5128                }
5129                _ => {}
5130            }
5131            // c:2163 — `if (itok(*s)) putc(ztokens[*s - Pound]);`
5132            // Map zsh token chars (`\u{84}`..`\u{a1}` range, the
5133            // ones the lexer emits for `#$^*()…`) back to their
5134            // source ASCII via the `ztokens` table.
5135            let cp = c as u32;
5136            if (0x84..=0xa1).contains(&cp) {
5137                let idx = (cp - 0x84) as usize;
5138                let ztokens = crate::ported::lex::ztokens.as_bytes();
5139                if idx < ztokens.len() {
5140                    out.push(ztokens[idx] as char);
5141                    i += 1;
5142                    continue;
5143                }
5144            }
5145            out.push(c);
5146            i += 1;
5147        }
5148        Value::str(out)
5149    });
5150
5151    // BUILTIN_WORD_SPLIT — `${=var}` IFS-split runtime.
5152    // PURE PASSTHRU: route through canonical `subst::multsub` with
5153    // PREFORK_SPLIT flag (C port of `Src/subst.c::multsub` at c:544
5154    // — the IFS-split walker with whitespace-vs-non-whitespace
5155    // gating, quote-aware parsing, and empty-field handling).
5156    vm.register_builtin(BUILTIN_WORD_SPLIT, |vm, _argc| {
5157        let s = vm.pop().to_str();
5158        let (_joined, parts, _isarr, _flags) =
5159            crate::ported::subst::multsub(&s, crate::ported::zsh_h::PREFORK_SPLIT);
5160        // Empty single-string special case → empty Array (drop empty arg).
5161        // The Array is a carrier for "no argv word", not an array-SHAPED
5162        // result: a single empty field came from an empty SCALAR (c:3922-3923
5163        // `if (!aval || !aval[0]) val = dupstring("");`). Record that, or
5164        // under RC_EXPAND_PARAM `concat_plan9` reads a stale bit and applies
5165        // c:4362's `uremnode` to a word zsh keeps — `x$(true)y` and
5166        // `v=""; x${=v}y` are each the single word `xy`, not zero words.
5167        if parts.len() == 1 && parts[0].is_empty() {
5168            note_empty_is_scalar(true);
5169            return Value::array(Vec::new());
5170        }
5171        // Zero parts is the same story reached by a different route: an empty
5172        // command substitution splits to nothing at all rather than to one
5173        // empty field. `to_str()` above means this builtin's input is always
5174        // a scalar, so an empty result here is never array-shaped.
5175        restore_empty_shape(nodes_to_value(parts), true)
5176    });
5177
5178    // BUILTIN_FORCE_SPLIT — `${=name}` / SH_WORD_SPLIT forced split.
5179    // c:Src/subst.c:3920-3928 —
5180    //     if (force_split && !isarr) {
5181    //         aval = sepsplit(val, spsep, 0, 1);
5182    //         if (!aval || !aval[0])   val = dupstring("");
5183    //         else if (!aval[1])       val = aval[0];
5184    //         else                     isarr = nojoin ? 1 : 2;
5185    //     }
5186    // with spsep == NULL for the `=` flag, so sepsplit falls through to
5187    // Src/utils.c:3711 spacesplit(s, allownull=0). See BUILTIN_FORCE_SPLIT's
5188    // doc comment for the empty-field rule and the argc contract.
5189    vm.register_builtin(BUILTIN_FORCE_SPLIT, |vm, argc| {
5190        let s = vm.pop().to_str();
5191        let keep_empties = argc == 1;
5192        // c:3921 — `sepsplit(val, spsep, 0, 1)`; spsep NULL → spacesplit.
5193        let raw = crate::ported::utils::sepsplit(&s, None, false);
5194        // c:Src/subst.c:36 `char nulstring[] = {Nularg, '\0'};` — spacesplit
5195        // emits this for an empty field delimited by IFS-NON-whitespace
5196        // (c:Src/utils.c:3732 / :3752); it survives prefork's empty-node
5197        // delete and remnulargs (c:Src/glob.c:3649) turns it back into "".
5198        // A plain "" field (c:3734 / :3757) is what a skipped run of
5199        // IFS-WHITESPACE leaves behind, and prefork DOES delete that one.
5200        let nulstring = crate::ported::zsh_h::Nularg.to_string();
5201        let mut out: Vec<String> = Vec::with_capacity(raw.len());
5202        for w in raw {
5203            if w == nulstring {
5204                out.push(String::new());
5205            } else if w.is_empty() {
5206                if keep_empties {
5207                    out.push(String::new());
5208                }
5209            } else {
5210                out.push(w);
5211            }
5212        }
5213        if out.is_empty() {
5214            // c:3922-3923 — `if (!aval || !aval[0]) val = dupstring("");`:
5215            // the split produced nothing, so the value is the empty SCALAR.
5216            // Quoted, that is one empty word (c:4465 `if (qt && !*y) y =
5217            // dupstring(nulstring);` → `a=( "${=v}" )` has one element);
5218            // unquoted, prefork deletes it and the word vanishes.
5219            if keep_empties {
5220                return Value::str(String::new());
5221            }
5222            note_empty_is_scalar(true);
5223            return Value::array(Vec::new());
5224        }
5225        if out.len() == 1 {
5226            // c:3924 — `else if (!aval[1]) val = aval[0];` — a one-field
5227            // split stays a SCALAR (this is why `${#${(f)v}}` counts
5228            // characters when the split yields a single line).
5229            return Value::str(out.into_iter().next().unwrap());
5230        }
5231        // c:3927 — `isarr = nojoin ? 1 : 2;`
5232        Value::array(out.into_iter().map(Value::str).collect())
5233    });
5234
5235    vm.register_builtin(BUILTIN_BRACE_EXPAND, |vm, _argc| {
5236        // c:Src/glob.c::xpandbraces — brace expansion runs per word.
5237        // When the upstream produced an array (e.g. `${a:e}` splat),
5238        // expand braces on each element separately so the splat
5239        // survives. `pop().to_str()` would join with space and lose
5240        // the array shape. Parity bug #28 cousin: the BRACE_EXPAND
5241        // emit always fires for any word containing `{` (including
5242        // `${...}` param-expansion braces), so its collapse hit even
5243        // pure-paramsubst args.
5244        let raw = vm.pop();
5245        // Brace expansion runs BETWEEN the expansion builtin that produced
5246        // this word and the concat that consumes it (`x${${P}}y` compiles to
5247        // EXPAND_TEXT, BRACE_EXPAND, CONCAT_DISTRIBUTE). It cannot change
5248        // whether an empty result was scalar- or array-SHAPED — c:Src/glob.c
5249        // xpandbraces only ever rewrites the text of existing words. But both
5250        // exits below funnel through `nodes_to_value`, which records
5251        // `note_empty_is_scalar(false)` for an empty result, so the shape bit
5252        // its producer set was being overwritten with "array" before
5253        // `concat_plan9` could read it. Under RC_EXPAND_PARAM that deleted
5254        // words zsh keeps: `unset P; x${${P}}y` and `x$(true)y` are each the
5255        // single word `xy` (c:4438-4467 scalar arm), not zero words.
5256        // Carry the incoming bit across an empty→empty pass-through.
5257        let incoming_empty_is_scalar = empty_is_scalar();
5258        // c:Src/options.c — `no_brace_expand` (negated braceexpand)
5259        // disables brace expansion entirely. When set, `{a,b}` stays
5260        // literal. Mirror by short-circuiting xpandbraces; pass the
5261        // input through unchanged.
5262        let brace_expand = opt_state_get("braceexpand").unwrap_or(true);
5263        let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
5264        // c:Src/glob.c xpandbraces rewrites word TEXT; it never turns a
5265        // scalar word into an array one. Remember the incoming shape so a
5266        // scalar that brace-expands to exactly one word stays SCALAR:
5267        // nodes_to_value collapses a lone EMPTY node to zero words unless
5268        // in_dq_context > 0, and BUILTIN_EXPAND_TEXT has already decremented
5269        // that by the time this runs — so a quoted word whose expansion came
5270        // out empty, and which carries the Inbrace token so it reaches this
5271        // builtin at all, lost its argument entirely.
5272        let raw_was_scalar = !matches!(raw, Value::Array(_));
5273        let inputs: Vec<String> = match raw {
5274            Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
5275            other => vec![other.to_str()],
5276        };
5277        if !brace_expand {
5278            return restore_empty_shape(nodes_to_value(inputs), incoming_empty_is_scalar);
5279        }
5280        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
5281        for s in inputs {
5282            for w in crate::ported::glob::xpandbraces(&s, brace_ccl) {
5283                out.push(w);
5284            }
5285        }
5286        if raw_was_scalar && out.len() == 1 {
5287            let mut only = out.into_iter().next().unwrap();
5288            crate::ported::glob::remnulargs(&mut only);
5289            return Value::str(only);
5290        }
5291        restore_empty_shape(nodes_to_value(out), incoming_empty_is_scalar)
5292    });
5293
5294    // `*(qual)` glob qualifier filter. Stack: [pattern, qualifier].
5295    // Pattern is glob-expanded normally, then each result is filtered by the
5296    // qualifier predicate. Common qualifiers:
5297    //   .  — regular files only
5298    //   /  — directories only
5299    //   @  — symlinks
5300    //   x  — executable
5301    //   r/w/x — readable/writable/executable
5302    //   N  — nullglob (no error if no match)
5303    //   L+N / L-N — size > N / size < N (in bytes)
5304    //   mh-N / mh+N — modified within N hours / older than N hours
5305    //   md-N / md+N — modified within N days / older than N days
5306    //   on/On — sort by name asc/desc (default)
5307    //   oL/OL — sort by length
5308    //   om/Om — sort by mtime
5309    // Pop a scalar pattern, run expand_glob, push Value::Array. Used
5310    // by the segment-concat compile path for `$D/*`-style words.
5311    vm.register_builtin(BUILTIN_GLOB_EXPAND, |vm, _argc| {
5312        // c:Src/glob.c:1872 — honour `setopt noglob` / `noglob CMD`
5313        // precommand. When the option is on, the word stays literal
5314        // (zsh skips the glob expansion entirely). Without this, the
5315        // segment-fast-path BUILTIN_GLOB_EXPAND fired even after
5316        // `noglob` set the option, so `noglob echo *.xyz` saw the
5317        // NOMATCH error instead of the literal pass-through.
5318        let raw = vm.pop();
5319        let noglob =
5320            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
5321        glob_expand_word_value(raw, noglob)
5322    });
5323    // Redirect-target variant of BUILTIN_GLOB_EXPAND. c:Src/glob.c:
5324    // 2161-2167 xpandredir — `prefork(&fake, isset(MULTIOS) ? 0 :
5325    // PREFORK_SINGLE, NULL)` then "Globbing is only done for
5326    // multios.": a redirect target word is only globbed when the
5327    // MULTIOS option is set. With it unset, `echo hi > *.txt`
5328    // creates the literal file `*.txt`, and `wc -c < *.txt` errors
5329    // "no such file or directory: *.txt". Bug #36 follow-up in
5330    // docs/BUGS.md.
5331    vm.register_builtin(BUILTIN_REDIR_GLOB_EXPAND, |vm, _argc| {
5332        let raw = vm.pop();
5333        let noglob =
5334            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
5335        let multios = opt_state_get("multios").unwrap_or(true);
5336        // c:Src/glob.c:2164-2166 — `in_expandredir = 1; globlist(&fake, 0);
5337        // in_expandredir = 0;`. The flag is what lets `zglob`'s no-match
5338        // dispatch (c:1888-1894) tell a redirect target apart from an
5339        // ordinary word: under NULL_GLOB an ordinary word is dropped, but
5340        // a redirect still needs exactly one target, so the empty result
5341        // is `redirection failed (no match)` instead.
5342        crate::ported::glob::IN_EXPANDREDIR.store(1, std::sync::atomic::Ordering::SeqCst); // c:2164
5343        let out = glob_expand_word_value(raw, noglob || !multios); // c:2165
5344        crate::ported::glob::IN_EXPANDREDIR.store(0, std::sync::atomic::Ordering::SeqCst); // c:2166
5345        out
5346    });
5347    // Clear the default-word glob-pending carrier before the word's
5348    // expansion runs, so a flag set by a prior word never leaks in.
5349    vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB_RESET, |_vm, _argc| {
5350        crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| c.set(false));
5351        Value::Status(0)
5352    });
5353    // After the word is assembled, run filename generation ONLY if the
5354    // default/alternate paramsubst arm flagged a source-glob default
5355    // (DEFAULT_WORD_GLOB_PENDING). Otherwise pass the word through
5356    // literally — a parameter VALUE must not glob. c:Src/subst.c globlist.
5357    vm.register_builtin(BUILTIN_DEFAULT_WORD_GLOB, |vm, _argc| {
5358        let raw = vm.pop();
5359        let pending = crate::ported::subst::DEFAULT_WORD_GLOB_PENDING.with(|c| {
5360            let v = c.get();
5361            c.set(false); // read + clear
5362            v
5363        });
5364        if !pending {
5365            return raw;
5366        }
5367        let noglob =
5368            opt_state_get("noglob").unwrap_or(false) || !opt_state_get("glob").unwrap_or(true);
5369        glob_expand_word_value(raw, noglob)
5370    });
5371
5372    // `break`/`continue` from a sub-VM body. The compile path emits
5373    // these when the keyword appears at chunk top-level (no enclosing
5374    // for/while in the current chunk's patch lists). Outer-loop
5375    // builtins (BUILTIN_RUN_SELECT and any future loop-via-builtin
5376    // construct) drain canonical BREAKS/CONTFLAG after each iteration.
5377    //
5378    // Writes match `bin_break`'s c:5836+ pattern:
5379    //   continue: contflag = 1; breaks++   (Src/builtin.c::bin_break)
5380    //   break:    breaks++
5381    vm.register_builtin(BUILTIN_SET_BREAK, |_vm, _argc| {
5382        use std::sync::atomic::Ordering::SeqCst;
5383        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
5384        Value::Status(0)
5385    });
5386    vm.register_builtin(BUILTIN_SET_CONTINUE, |_vm, _argc| {
5387        use std::sync::atomic::Ordering::SeqCst;
5388        crate::ported::builtin::CONTFLAG.store(1, SeqCst);
5389        crate::ported::builtin::BREAKS.fetch_add(1, SeqCst);
5390        Value::Status(0)
5391    });
5392
5393    // `break N`/`continue N` with a RUNTIME level count. Pops [count,
5394    // name]; math-evaluates count (c:builtin.c:5811 `mathevali`); on
5395    // count <= 0 emits `argument is not positive: N` via zerrnam (sets
5396    // errflag → abort, c:5813) and pushes Int(0) (matches no jump-table
5397    // entry → control falls through to the errflag abort). Otherwise
5398    // pushes Int(count) for the compiled jump table to dispatch on.
5399    vm.register_builtin(BUILTIN_BREAK_COUNT_VALIDATE, |vm, _argc| {
5400        let name = vm.pop().to_str();
5401        let count_s = vm.pop().to_str();
5402        let count = crate::ported::math::mathevali(&count_s).unwrap_or(0);
5403        if count <= 0 {
5404            crate::ported::utils::zerrnam(&name, &format!("argument is not positive: {count}"));
5405            return Value::Int(0);
5406        }
5407        Value::Int(count)
5408    });
5409
5410    // `${arr[*]}` — join array elements with the first IFS char into
5411    // a single string. Matches zsh: in DQ context this preserves the
5412    // join; in array context too the result is one Value::Str.
5413    // Set or clear a shell option directly. Used by `noglob CMD ...`
5414    // precommand wrapping — the compiler emits SET_RAW_OPT to flip the
5415    // option ON before compiling the inner words and OFF after, so glob
5416    // expansion of the inner args sees the temporary state.
5417    vm.register_builtin(BUILTIN_SET_RAW_OPT, |vm, _argc| {
5418        let on = vm.pop().to_int() != 0;
5419        let opt = vm.pop().to_str();
5420        // Pure passthru: canonical port lives in
5421        // src/ported/options.rs::opt_state_set_via_alias and
5422        // handles negation-alias resolution per c:Src/options.c.
5423        crate::ported::options::opt_state_set_via_alias(&opt, on);
5424        Value::Status(0)
5425    });
5426
5427    // c:Src/options.c GLOB_SUBST — runtime glob expansion of
5428    // substituted words. Pop a Value (Str or Array); when
5429    // GLOB_SUBST is ON, run expand_glob on each string element;
5430    // when OFF, pass through unchanged. Bug #119 in docs/BUGS.md.
5431    vm.register_builtin(BUILTIN_GLOB_SUBST_EXPAND, |vm, _argc| {
5432        let raw = vm.pop();
5433        let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
5434        if !glob_subst {
5435            return raw;
5436        }
5437        // Collect input strings (Str → vec![s]; Array → multiple).
5438        let inputs: Vec<String> = match raw {
5439            Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
5440            other => vec![other.to_str()],
5441        };
5442        // Run expand_glob on each. Empty matches collapse to a
5443        // single literal pass-through to mirror nullglob-off default.
5444        let mut out: Vec<String> = Vec::with_capacity(inputs.len());
5445        for pattern in inputs {
5446            // c:Src/subst.c — GLOB_SUBST subjects the value to the FULL
5447            // filename-generation pipeline: `filesub` (tilde/`=` expansion)
5448            // BEFORE globbing (prefork runs filesub then globlist). zshrs
5449            // globbed but skipped filesub, so `${~x}` / `setopt globsubst`
5450            // left `~/foo` un-expanded. filesubstr matches the Tilde TOKEN,
5451            // so shtokenize the value first (`~`→Tilde, glob metas active),
5452            // run filesub, then untokenize the surviving glob metas back to
5453            // raw for expand_glob (which re-tokenizes internally).
5454            let pattern = if pattern.contains('~') || pattern.contains('=') {
5455                let mut tok = pattern.clone();
5456                crate::ported::glob::shtokenize(&mut tok);
5457                let fs = crate::ported::subst::filesub(&tok, 0);
5458                crate::ported::lex::untokenize(&fs)
5459            } else {
5460                pattern
5461            };
5462            let matches = with_executor(|exec| exec.expand_glob(&pattern));
5463            if matches.is_empty() {
5464                // No match: keep the literal (like nullglob off).
5465                out.push(pattern);
5466            } else {
5467                for m in matches {
5468                    out.push(m);
5469                }
5470            }
5471        }
5472        if out.len() == 1 {
5473            Value::str(out.into_iter().next().unwrap())
5474        } else {
5475            Value::array(out.into_iter().map(Value::str).collect())
5476        }
5477    });
5478
5479    // c:Src/math.c:336-364 — `getmathparam` for ArithCompiler pre-load.
5480    // Pop a variable name, return its math value.
5481    //
5482    // This used to be a second, smaller getmathparam: `getsparam` then
5483    // `parse::<i64>` / `parse::<f64>` / `mathevali`. Two consequences,
5484    // both invisible until you compared the two arithmetic backends on
5485    // the same expression. It had no FORCEFLOAT coercion (c:359-362) and
5486    // no `unset(UNSET)` diagnostic (c:345-346), so which of `setopt
5487    // force_float` and `set -u` applied to `$(( x ))` depended on
5488    // whether `compile_arith` had routed the expression to the
5489    // ArithCompiler or to BUILTIN_ARITH_EVAL. And it re-derived a
5490    // typed param's value from `getsparam`'s printed form, the same
5491    // convbase round-trip c:2641 exists to avoid.
5492    //
5493    // There is one `getmathparam` in C; there is one here now.
5494    vm.register_builtin(BUILTIN_GET_MATH_VAR, |vm, _argc| {
5495        let name = vm.pop().to_str();
5496        let n = crate::ported::math::getmathparam(&name); // c:337
5497        if n.type_ == crate::ported::zsh_h::MN_FLOAT {
5498            Value::Float(n.d)
5499        } else {
5500            Value::Int(n.l)
5501        }
5502    });
5503
5504    // c:Src/subst.c:822/830 `if (glbsub) shtokenize(dest)` for the
5505    // `${~spec}` / `$~spec` FLAG, where the compiler emits no
5506    // GLOB_SUBST guard because the metas are meant to be active. Only
5507    // the value's backslashes still need settling — c:Src/glob.c:3651
5508    // leaves the ones before a non-`ztokens` char as data.
5509    // See BUILTIN_PAT_DATA_BACKSLASH docs below for full rationale.
5510    vm.register_builtin(BUILTIN_PAT_DATA_BACKSLASH, |vm, _argc| {
5511        let p = vm.pop().to_str();
5512        Value::str(crate::pattern_data_escape::escape_data_backslashes(&p))
5513    });
5514
5515    // c:Src/options.c GLOB_SUBST + Src/cond.c:552 cond_match.
5516    // Pop pattern string; when GLOB_SUBST is OFF, escape every glob
5517    // metachar with `\` so the downstream StrMatch + patcompile
5518    // treat them as literals (matching C's tokenization-based
5519    // gate). When GLOB_SUBST is ON, only the data-backslash respelling
5520    // runs and the metas stay active.
5521    // See BUILTIN_GLOB_SUBST_GUARD docs below for full rationale.
5522    vm.register_builtin(BUILTIN_GLOB_SUBST_GUARD, |vm, _argc| {
5523        let p = vm.pop().to_str();
5524        let glob_subst = crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBSUBST);
5525        if glob_subst {
5526            // c:Src/subst.c:822/830 `if (glbsub) shtokenize(dest)` — the
5527            // value's metas go ACTIVE, but c:Src/glob.c:3651 still leaves a
5528            // backslash before a non-`ztokens` char as ordinary data. Respell
5529            // those in the normalizer's literal-backslash form; the ones
5530            // `zshtokenize` WOULD fold into a quote are left alone.
5531            // docs/BUGS.md #1090.
5532            return Value::str(crate::pattern_data_escape::escape_data_backslashes(&p));
5533        }
5534        let mut out = String::with_capacity(p.len() * 2);
5535        for c in p.chars() {
5536            match c {
5537                // c:Src/lex.c:1390-1404 — `-` / `!` are Dash / Bang TOKENS
5538                // only when the LEXER sees them unquoted; pattern.c's range
5539                // parser (c:1483) and negation test look for the tokens, so
5540                // a SUBSTITUTED `-` / `!` must stay an ordinary character
5541                // with GLOB_SUBST off. Without these two,
5542                // `cset='^a-z'; [[ - = ["$cset"] ]]` built a live `a-z`
5543                // range out of substituted text.
5544                '*' | '?' | '[' | ']' | '(' | ')' | '|' | '<' | '>' | '#' | '^' | '~' | '-'
5545                | '!' | '\\' => {
5546                    out.push('\\');
5547                    out.push(c);
5548                }
5549                _ => out.push(c),
5550            }
5551        }
5552        Value::str(out)
5553    });
5554
5555    vm.register_builtin(BUILTIN_ARRAY_JOIN_STAR, |vm, _argc| {
5556        let name = vm.pop().to_str();
5557        let (joined, ifs_full, in_dq) = with_executor(|exec| {
5558            // c:Src/params.c — `"$*"` joins by IFS[0]. zsh
5559            // distinguishes IFS=unset (→ default `" "`) from
5560            // IFS="" (→ EMPTY separator → fields concatenate).
5561            // chars().next() collapsed both into the default, so
5562            // IFS="" was treated as IFS=" ".
5563            let ifs_full = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
5564            let sep = ifs_full
5565                .chars()
5566                .next()
5567                .map(|c| c.to_string())
5568                .unwrap_or_default();
5569            let in_dq = exec.in_dq_context > 0;
5570            let joined = if let Some(v) = crate::dash_mode::bash_special_array(&name) {
5571                // bash `"${PIPESTATUS[*]}"` / `"${FUNCNAME[*]}"` join.
5572                v.join(&sep)
5573            } else if name == "@" || name == "*" || name == "argv" {
5574                exec.pparams().join(&sep)
5575            } else if let Some(assoc_map) = exec.assoc(&name) {
5576                // c:Src/params.c — assoc-splat values for
5577                // `"${h[@]}"` / `"${h[*]}"`. Bug #109 in
5578                // docs/BUGS.md.
5579                assoc_map.values().cloned().collect::<Vec<_>>().join(&sep)
5580            } else if let Some(arr) = exec.array(&name) {
5581                // bash sparse arrays: `"${a[*]}"` joins only LIVE elements,
5582                // dropping hole slots. No-op in --zsh (no holes tracked).
5583                crate::bash_arrays::compact(&name, arr).join(&sep)
5584            } else if let Some(arr) = crate::ported::subst::arrays_get(&name) {
5585                // c:Src/Modules/parameter.c:2239-2291 partab[] — the PM_ARRAY
5586                // magic specials (reswords/patchars/dis_*/…) are getfn-backed,
5587                // so `getaparam`'s `pm->u.arr` read (behind `exec.array`) comes
5588                // back NULL and the join fell through to the scalar fallback,
5589                // which is empty. Same omission the `[@]` splat had.
5590                arr.join(&sep)
5591            } else if let Some(map) = crate::ported::subst::assoc_get(&name) {
5592                // c:Src/Modules/parameter.c:2235-2298 partab[] — the PM_HASHED
5593                // magic assocs (aliases/functions/options/…) live behind a
5594                // scanfn+getfn pair, not in the executor's assoc storage, so
5595                // `exec.assoc` above misses them and `${aliases[*]}` joined an
5596                // empty scalar where zsh joins the alias VALUES.
5597                map.values().cloned().collect::<Vec<_>>().join(&sep)
5598            } else {
5599                exec.get_variable(&name)
5600            };
5601            (joined, ifs_full, in_dq)
5602        });
5603        // c:Src/subst.c — UNQUOTED `${name[*]}` (or `$*`) goes
5604        // through the canonical "join via IFS[0], then word-split
5605        // via IFS" pipeline. The fast-path bypassed paramsubst
5606        // entirely so it never word-split, producing one joined
5607        // string instead of N argv entries. Bug #428.
5608        //
5609        // In QUOTED (`"${name[*]}"`) context, the result IS a
5610        // single scalar — return it as Str without splitting.
5611        if in_dq {
5612            return Value::str(joined);
5613        }
5614        if joined.is_empty() {
5615            return Value::array(Vec::new());
5616        }
5617        // IFS word-split — every IFS char is a separator. Empty
5618        // resulting fields are dropped (the canonical
5619        // "remove empty unquoted words" pass from
5620        // Src/subst.c::prefork c:184-187).
5621        let parts: Vec<String> = joined
5622            .split(|c: char| ifs_full.contains(c))
5623            .filter(|s| !s.is_empty())
5624            .map(String::from)
5625            .collect();
5626        if parts.is_empty() {
5627            Value::array(Vec::new())
5628        } else if parts.len() == 1 {
5629            Value::str(parts.into_iter().next().unwrap())
5630        } else {
5631            Value::array(parts.into_iter().map(Value::str).collect())
5632        }
5633    });
5634
5635    vm.register_builtin(BUILTIN_ARRAY_ALL, |vm, _argc| {
5636        let name = vm.pop().to_str();
5637        // c:Src/params.c:2027-2029 — a `[@]`/`[*]` subscript sets
5638        // SCANPM_ISVAR_AT, i.e. `isarr != 0` (c:2915). An empty result is
5639        // therefore an empty ARRAY, and plan9 deletes the whole word
5640        // (c:4362) rather than keeping the surrounding text.
5641        //
5642        // The note describes the EMPTY value this expansion produces, so it
5643        // must not survive a NON-empty one: the bit is read by concat_plan9
5644        // when the word folds left-associatively, and a later non-empty
5645        // segment overwriting it made `setopt rcexpandparam; e=''; a=(x y);
5646        // print -rl -- ${e}${a}` delete the whole word instead of printing
5647        // `x` and `y` (c:4437 keeps the surrounding text for a scalar
5648        // empty; only c:4362's empty ARRAY deletes it). Restore the
5649        // incoming bit whenever the result is not an empty array.
5650        let saved_empty_is_scalar = empty_is_scalar();
5651        note_empty_is_scalar(false);
5652        let array_all = |vm: &mut fusevm::VM| -> Value {
5653            let _ = &vm;
5654            // bash `"${PIPESTATUS[@]}"` / `"${FUNCNAME[@]}"` / `"${BASH_VERSINFO[@]}"`
5655            // splat — alias the zsh-native special. No-op in --zsh.
5656            if let Some(v) = crate::dash_mode::bash_special_array(&name) {
5657                return Value::array(v.into_iter().map(Value::str).collect());
5658            }
5659            with_executor(|exec| {
5660                // Special positional names — splice the positional list.
5661                if name == "@" || name == "*" || name == "argv" {
5662                    return Value::array(exec.pparams().iter().map(Value::str).collect());
5663                }
5664                // c:Src/Modules/parameter.c — funcstack/funcfiletrace/
5665                // funcsourcetrace/functrace are PM_ARRAY|PM_READONLY
5666                // specials backed by the canonical FUNCSTACK Vec.
5667                // `${funcstack[@]}` inside a function call should splat
5668                // the innermost-first names; without this branch the
5669                // runtime fell to the scalar fallback (get_variable
5670                // returns empty for these specials) and `[@]` came out
5671                // empty. Bug #276 in docs/BUGS.md. Mirrors the parallel
5672                // arrays_get handler at src/ported/subst.rs ~10685.
5673                // c:Src/Modules/datetime.c:256 — `epochtime` PM_ARRAY|
5674                // PM_READONLY backed by getcurrenttime(). Same parallel
5675                // arrangement as the FUNCSTACK-backed specials below.
5676                if name == "epochtime" {
5677                    // c:Src/params.c:589-594 getparamnode → c:563-585 loadparamnode —
5678                    // the `[@]` splat resolves the NAME, clearing PM_AUTOLOAD so
5679                    // paramtypestr (c:Src/Modules/parameter.c:48-50) reports the real
5680                    // type. Mirrors the arrays_get arm in src/ported/subst.rs.
5681                    if !crate::vm_helper::magic_special_shadowed(&name) {
5682                        crate::vm_helper::mark_module_param_used(&name);
5683                    }
5684                    let arr = crate::ported::modules::datetime::getcurrenttime();
5685                    return Value::array(arr.into_iter().map(Value::str).collect());
5686                }
5687                if matches!(
5688                    name.as_str(),
5689                    "funcstack" | "funcfiletrace" | "funcsourcetrace" | "functrace"
5690                ) {
5691                    // c:Src/params.c:589-594 — see the epochtime arm above.
5692                    if !crate::vm_helper::magic_special_shadowed(&name) {
5693                        crate::vm_helper::mark_module_param_used(&name);
5694                    }
5695                    // Route the three trace arrays through the canonical
5696                    // ported getfns (Src/Modules/parameter.c:648/:679/:711)
5697                    // — the previous inline copy emitted wrong shapes
5698                    // (bare filename for funcfiletrace, `name:lineno` for
5699                    // functrace instead of `caller:lineno`); same dedup as
5700                    // the parallel arrays_get handler in subst.rs.
5701                    let vals: Vec<String> = match name.as_str() {
5702                        "funcstack" => crate::ported::modules::parameter::FUNCSTACK
5703                            .lock()
5704                            .map(|f| f.iter().rev().map(|fs| fs.name.clone()).collect())
5705                            .unwrap_or_default(),
5706                        "funcfiletrace" => crate::ported::modules::parameter::funcfiletracegetfn(
5707                            std::ptr::null_mut(),
5708                        ),
5709                        "funcsourcetrace" => {
5710                            crate::ported::modules::parameter::funcsourcetracegetfn(
5711                                std::ptr::null_mut(),
5712                            )
5713                        }
5714                        _ => {
5715                            crate::ported::modules::parameter::functracegetfn(std::ptr::null_mut())
5716                        }
5717                    };
5718                    return Value::array(vals.into_iter().map(Value::str).collect());
5719                }
5720                // c:Src/params.c — `${assoc[@]}` enumerates VALUES (per
5721                // params.c:1696-1750 hashparam splat). Check assoc
5722                // storage BEFORE the scalar fallback so an associative
5723                // array named X resolves `${X[@]}` to the values, not
5724                // empty. Bug #109 in docs/BUGS.md: `${h[@]}` on an
5725                // assoc routed through BUILTIN_ARRAY_ALL, which only
5726                // consulted `exec.array(name)` (the indexed-array map)
5727                // — that lookup missed for assocs, fell through to
5728                // `get_variable("h")` (also empty for an assoc-only
5729                // name), and returned `Array(vec![])`. zsh's expected
5730                // behavior is to enumerate values.
5731                if let Some(assoc_map) = exec.assoc(&name) {
5732                    return Value::array(assoc_map.values().cloned().map(Value::str).collect());
5733                }
5734                match exec.array(&name) {
5735                    Some(v) => {
5736                        // bash sparse arrays: `"${a[@]}"` splats only LIVE
5737                        // elements, dropping hole slots (`a[5]=q` padding,
5738                        // `unset a[i]`). No-op in --zsh (no holes tracked).
5739                        let v = crate::bash_arrays::compact(&name, v);
5740                        Value::array(v.iter().map(Value::str).collect())
5741                    }
5742                    None => {
5743                        // c:Src/Modules/parameter.c:2235-2298 partab[] — the
5744                        // PM_HASHED magic assocs (aliases/functions/parameters/
5745                        // options/commands/builtins/modules/widgets/nameddirs/…)
5746                        // are real hash params in C, so `${aliases[@]}` takes the
5747                        // ordinary getvaluearr path and enumerates their VALUES.
5748                        // zshrs keeps them OUT of the executor's assoc storage
5749                        // (they are synthesized on demand by `subst::assoc_get`),
5750                        // so the `exec.assoc` probe above missed and this arm fell
5751                        // through to the scalar fallback, which returned an EMPTY
5752                        // array: `alias foo=bar; print -r -- "${aliases[@]}"` gave
5753                        // nothing where zsh gives `bar man whence`. Every other
5754                        // form already routed through paramsubst's own magic-assoc
5755                        // arms; only the flagless `[@]`/`[*]` splat compiles to
5756                        // BUILTIN_ARRAY_ALL and reached here.
5757                        //
5758                        // Placed in the `None` arm so a real indexed array or a
5759                        // user-defined assoc of the same name still wins, and so
5760                        // no ordinary array read pays for the PARTAB scan.
5761                        //
5762                        // Same gap on the PM_ARRAY side (c:2239-2291 partab[]
5763                        // rows: reswords/dis_reswords/patchars/dis_patchars/…):
5764                        // `getaparam` reads `pm->u.arr`, which is NULL on the
5765                        // placeholder node zshrs installs for a getfn-backed
5766                        // special, so `${reswords[@]}` splatted nothing. Route
5767                        // through the canonical `arrays_get` getfn dispatch.
5768                        if let Some(arr) = crate::ported::subst::arrays_get(&name) {
5769                            return Value::array(arr.into_iter().map(Value::str).collect());
5770                        }
5771                        if let Some(map) = crate::ported::subst::assoc_get(&name) {
5772                            return Value::array(map.values().cloned().map(Value::str).collect());
5773                        }
5774                        // Fall back to scalar lookup. zsh (unlike bash)
5775                        // does NOT IFS-split a scalar variable in a for
5776                        // list — `for w in $scalar` iterates ONCE with the
5777                        // scalar value. Word-splitting requires either
5778                        // sh_word_split option or explicit `${(s.,.)scalar}`.
5779                        let val = exec.get_variable(&name);
5780                        if val.is_empty() && !exec.has_scalar(&name) && env::var(&name).is_err() {
5781                            // c:Src/subst.c:3480-3485 — `${arr[@]}` on a genuinely
5782                            // UNSET parameter under NO_UNSET is a "parameter not set"
5783                            // error (vunset > 0 && unset(UNSET)), exactly like the
5784                            // scalar `$arr`, the `${arr[*]}` splat, and `${arr[1]}`
5785                            // — all of which already fire it via GET_VAR. The `[@]`
5786                            // splat path returned an empty array silently, so
5787                            // `setopt NO_UNSET; print "${arr[@]}"` exited 0 where zsh
5788                            // exits 1. A DECLARED-but-empty array (`arr=()`) resolves
5789                            // to `Some(vec![])` above and never reaches here, so it
5790                            // still splats to nothing without erroring — matching zsh.
5791                            if opt_state_get("nounset").unwrap_or(false) {
5792                                crate::ported::utils::zerr(&format!("{}: parameter not set", name));
5793                                crate::ported::utils::errflag.fetch_or(
5794                                    crate::ported::zsh_h::ERRFLAG_ERROR,
5795                                    std::sync::atomic::Ordering::Relaxed,
5796                                );
5797                                exec.set_last_status(1);
5798                            }
5799                            // c:Src/subst.c:3480-3485 — an UNSET parameter takes the
5800                            // `vunset` arm: `val = dupstring("")` with isarr left at
5801                            // 0. That is a SCALAR empty, so plan9 keeps the
5802                            // surrounding text (`setopt rcexpandparam;
5803                            // print -r -- "[${unset[@]}]"` → `[]`), unlike a
5804                            // DECLARED-but-empty array (`arr=()`, matched by the
5805                            // `Some(vec![])` arm above), which sets isarr and gets
5806                            // the word deleted at c:4362.
5807                            note_empty_is_scalar(true);
5808                            // c:Src/subst.c:3603-3610 — an UNSET parameter leaves `isarr` at 0
5809                            // and yields `val = ""`, i.e. a SCALAR empty, so a quoted
5810                            // `"${u[@]}"` is ONE empty word (`f "${u[@]}"` → $# == 1) while
5811                            // `"${empty_array[@]}"` is zero. Returning an empty ARRAY here
5812                            // collapsed both to zero words. The unquoted form still drops it:
5813                            // the compiler emits BUILTIN_ARRAY_DROP_EMPTY after this call for
5814                            // non-DQ splices (compile_zsh.rs:6149), and that builtin maps an
5815                            // empty Str to an empty array.
5816                            Value::str(String::new())
5817                        } else if opt_state_get("shwordsplit").unwrap_or(false) {
5818                            // c:3921 `aval = sepsplit(val, spsep, 0, 1)` — same
5819                            // splitter as `${=name}` (Src/utils.c:3711 spacesplit),
5820                            // not a naive `split().filter(non-empty)`: only the
5821                            // IFS-WHITESPACE-derived empty fields are elided; the
5822                            // `nulstring` ones an IFS-NON-whitespace separator makes
5823                            // survive (c:Src/subst.c:36).
5824                            let nulstring = crate::ported::zsh_h::Nularg.to_string();
5825                            let parts: Vec<Value> =
5826                                crate::ported::utils::sepsplit(&val, None, false)
5827                                    .into_iter()
5828                                    .filter_map(|w| {
5829                                        if w == nulstring {
5830                                            Some(Value::str(String::new()))
5831                                        } else if w.is_empty() {
5832                                            None // c:184-187 prefork uremnode
5833                                        } else {
5834                                            Some(Value::str(w))
5835                                        }
5836                                    })
5837                                    .collect();
5838                            Value::array(parts)
5839                        } else {
5840                            Value::array(vec![Value::str(val)])
5841                        }
5842                    }
5843                }
5844            })
5845        };
5846        let result = array_all(vm);
5847        if !matches!(&result, Value::Array(a) if a.is_empty()) {
5848            note_empty_is_scalar(saved_empty_is_scalar);
5849        }
5850        result
5851    });
5852
5853    // BUILTIN_ARRAY_FLATTEN(N): pops N values, flattens one level of Array
5854    // nesting, pushes the resulting Array AND its length as a separate Int.
5855    // The two-value return shape lets the caller (for-loop compile path)
5856    // SetSlot the length before SetSlot'ing the array, without re-deriving
5857    // the length from the array via a second builtin call.
5858    // `coproc [name] { body }` — bidirectional pipe to backgrounded body.
5859    // Stack discipline (top first): [name (str, "" for default), sub_idx (int)].
5860    // On success: parent's `executor.arrays[name]` becomes [write_fd, read_fd]
5861    // and Status(0) is returned. The caller writes to the child's stdin via
5862    // write_fd, reads its stdout via read_fd, and closes both when done.
5863    //
5864    // Bash's coproc convention is `${NAME[0]}` = read_fd, `${NAME[1]}` =
5865    // write_fd. We follow that: arrays[name] = [read_fd_str, write_fd_str].
5866    vm.register_builtin(BUILTIN_RUN_COPROC, |vm, _argc| {
5867        let sub_idx = vm.pop().to_int() as usize;
5868        let job_text = vm.pop().to_str();
5869        let raw_name = vm.pop().to_str();
5870        let name = if raw_name.is_empty() {
5871            "COPROC".to_string()
5872        } else {
5873            raw_name
5874        };
5875        let chunk = match vm.chunk.sub_chunks.get(sub_idx).cloned() {
5876            Some(c) => c,
5877            None => return Value::Status(1),
5878        };
5879
5880        // c:Src/exec.c:1710-1712 — starting a new coproc closes the
5881        // previous one's fds FIRST:
5882        //     if (coprocin >= 0) { zclose(coprocin); zclose(coprocout); }
5883        // The old coproc child then sees EOF on its stdin and exits on
5884        // its own schedule (its job-table entry stays until it's
5885        // reaped) — zsh does NOT deletejob it here. This is also what
5886        // makes the `exec 4<&p; coproc exit; read -u4` EOF idiom work:
5887        // the replacement coproc closes the shell's write end to the
5888        // old one.
5889        {
5890            use std::sync::atomic::Ordering;
5891            let old_in = crate::ported::modules::clone::coprocin.load(Ordering::Relaxed);
5892            if old_in >= 0 {
5893                let old_out = crate::ported::modules::clone::coprocout.load(Ordering::Relaxed);
5894                unsafe {
5895                    libc::close(old_in);
5896                    if old_out >= 0 {
5897                        libc::close(old_out);
5898                    }
5899                }
5900                crate::ported::modules::clone::coprocin.store(-1, Ordering::Relaxed);
5901                crate::ported::modules::clone::coprocout.store(-1, Ordering::Relaxed);
5902            }
5903        }
5904
5905        // (parent_read ← child_stdout)
5906        let mut p2c = [0i32; 2]; // parent writes, child reads
5907        let mut c2p = [0i32; 2]; // child writes, parent reads
5908        if unsafe { libc::pipe(p2c.as_mut_ptr()) } < 0 {
5909            return Value::Status(1);
5910        }
5911        if unsafe { libc::pipe(c2p.as_mut_ptr()) } < 0 {
5912            unsafe {
5913                libc::close(p2c[0]);
5914                libc::close(p2c[1]);
5915            }
5916            return Value::Status(1);
5917        }
5918        // c:Src/exec.c:5160 mpipe — both pipes' fds are moved above
5919        // the user-visible range (movefd → F_DUPFD ≥ 10) so the
5920        // coproc fds never collide with explicit user fds like
5921        // `exec 3>&p`.
5922        for fd in p2c.iter_mut().chain(c2p.iter_mut()) {
5923            *fd = crate::ported::utils::movefd(*fd);
5924        }
5925
5926        match unsafe { libc::fork() } {
5927            -1 => {
5928                unsafe {
5929                    libc::close(p2c[0]);
5930                    libc::close(p2c[1]);
5931                    libc::close(c2p[0]);
5932                    libc::close(c2p[1]);
5933                }
5934                Value::Status(1)
5935            }
5936            0 => {
5937                // Child: stdin from p2c[0], stdout to c2p[1]. Close all
5938                // unused fds. setsid so SIGINT to fg doesn't hit us.
5939                unsafe {
5940                    libc::dup2(p2c[0], libc::STDIN_FILENO);
5941                    libc::dup2(c2p[1], libc::STDOUT_FILENO);
5942                    libc::close(p2c[0]);
5943                    libc::close(p2c[1]);
5944                    libc::close(c2p[0]);
5945                    libc::close(c2p[1]);
5946                    libc::setsid();
5947                }
5948                crate::fusevm_disasm::maybe_print_stdout("coproc:child", &chunk);
5949                let mut co_vm = fusevm::VM::new(chunk);
5950                register_builtins(&mut co_vm);
5951                let _ = co_vm.run();
5952                let _ = std::io::stdout().flush();
5953                let _ = std::io::stderr().flush();
5954                std::process::exit(co_vm.last_status);
5955            }
5956            pid => {
5957                // Parent: close child ends, store [read_fd, write_fd] in NAME.
5958                unsafe {
5959                    libc::close(p2c[0]);
5960                    libc::close(c2p[1]);
5961                }
5962                let read_fd = c2p[0];
5963                let write_fd = p2c[1];
5964                with_executor(|exec| {
5965                    exec.unset_scalar(&name);
5966                    exec.set_array(name, vec![read_fd.to_string(), write_fd.to_string()]);
5967                });
5968                // c:Src/exec.c — `coprocin`/`coprocout` are the
5969                // canonical globals that bin_read's `-p` arm
5970                // (Src/builtin.c:6510) and bin_print's `-p` arm
5971                // (Src/builtin.c:4827) read to find the
5972                // coprocess fds. The Rust port has the atomic
5973                // declarations at src/ported/modules/clone.rs:262
5974                // but the coproc-launch path never updated them,
5975                // so `read -p` / `print -p` always errored with
5976                // "-p: no coprocess" even when a coproc was
5977                // running. Bug #388 in docs/BUGS.md. Update them
5978                // here so the canonical builtins find the live
5979                // pipe.
5980                crate::ported::modules::clone::coprocin
5981                    .store(read_fd, std::sync::atomic::Ordering::Relaxed);
5982                crate::ported::modules::clone::coprocout
5983                    .store(write_fd, std::sync::atomic::Ordering::Relaxed);
5984                // c:Src/exec.c:1725 — `fdtable[coprocin] =
5985                // fdtable[coprocout] = FDT_UNUSED;`: the two kept ends
5986                // are user-reachable (via `>&p` / `<&p`), so they drop
5987                // the FDT_INTERNAL mark movefd gave them.
5988                crate::ported::utils::fdtable_set(read_fd, crate::ported::zsh_h::FDT_UNUSED);
5989                crate::ported::utils::fdtable_set(write_fd, crate::ported::zsh_h::FDT_UNUSED);
5990                // c:Src/exec.c:2837 — `lastpid = (zlong) pid;`. zsh
5991                // sets the `$!` global to the coproc child's PID so
5992                // subsequent `$!` reads return it. The Rust port at
5993                // exec.rs:6773 mirrors this for regular background
5994                // jobs but the coproc launch path was missing the
5995                // assignment, leaving `$!` at 0 after `coproc cmd`.
5996                crate::ported::modules::clone::lastpid
5997                    .store(pid, std::sync::atomic::Ordering::Relaxed);
5998                // c:Src/exec.c:1700-1758 — the coproc rides the SAME
5999                // Z_ASYNC job-table path as `cmd &`: `thisjob = newjob
6000                // = initjob()` (c:1700), addproc hangs the pid+text
6001                // proc entry off the job, `jobtab[thisjob].stat |=
6002                // STAT_NOSTTY` (c:1746), `clearoldjobtab()` (c:1744)
6003                // and `spawnjob()` (c:1758) promote it to curjob. This
6004                // is what makes `jobs` list the coproc as
6005                // `[1]  + running    cat` and `kill %1` resolve it.
6006                // Mirrors the BUILTIN_RUN_BG parent arm exactly.
6007                {
6008                    use crate::ported::jobs;
6009                    use std::sync::Mutex;
6010                    let table = jobs::JOBTAB.get_or_init(|| Mutex::new(Vec::new()));
6011                    let idx = {
6012                        let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
6013                        let idx = jobs::initjob(&mut tab); // c:exec.c:1700
6014                        jobs::addproc(
6015                            &mut tab[idx],
6016                            pid,
6017                            &job_text,
6018                            false,
6019                            Some(std::time::Instant::now()),
6020                            -1,
6021                            -1,
6022                        );
6023                        tab[idx].stat |= crate::ported::zsh_h::STAT_NOSTTY; // c:exec.c:1746
6024                        idx
6025                    };
6026                    jobs::clearoldjobtab(); // c:exec.c:1744
6027                    if let Ok(mut tj) = jobs::THISJOB.get_or_init(|| Mutex::new(-1)).lock() {
6028                        *tj = idx as i32;
6029                    }
6030                    jobs::spawnjob(); // c:exec.c:1758
6031                }
6032                with_executor(|exec| {
6033                    exec.jobs
6034                        .add_pid_job(pid, job_text.clone(), JobState::Running);
6035                });
6036                Value::Status(0)
6037            }
6038        }
6039    });
6040
6041    vm.register_builtin(BUILTIN_ARRAY_FLATTEN, |vm, argc| {
6042        // `${~spec}` carrier: a `for`/`select` WORD LIST is a word-
6043        // pipeline boundary too. In C the `globsubst` flag is a
6044        // paramsubst-LOCAL int (Src/subst.c:1671 `int globsubst =
6045        // isset(GLOBSUBST);`, forced to 2 by `${~…}` at
6046        // Src/subst.c:2603) whose only lasting effect is the
6047        // `shtokenize` of that substitution's own result — it never
6048        // reaches the option table, so `execfor`'s list prefork
6049        // (Src/loop.c:196-235) cannot leak it into the loop BODY.
6050        // zshrs carries the flag through the global option table so
6051        // the compile-emitted glob ops of the SAME word can see it
6052        // (documented deviation at subst.rs:3190), and restores it at
6053        // command-dispatch boundaries — but a `for` list has no
6054        // trailing dispatch of its own, so `for i in "${a:#${~p}*}"`
6055        // left GLOB_SUBST ON and filename-generated the FIRST body
6056        // command's words (`_parameters:34` → `ary+=($i:"$v")` glob-
6057        // erroring "bad pattern: HISTCHARS:!^#", which aborted the
6058        // whole `pr<TAB>` completion). This builtin ends EVERY for/
6059        // select list expansion and runs AFTER each word's
6060        // GLOB_SUBST_EXPAND op, so the carrier has been read by then.
6061        consume_tilde_globsubst_carrier();
6062        let n = argc as usize;
6063        let start = vm.stack.len().saturating_sub(n);
6064        let raw: Vec<Value> = vm.stack.drain(start..).collect();
6065        let mut flat: Vec<Value> = Vec::with_capacity(raw.len());
6066        for v in raw {
6067            match v {
6068                Value::Array(items) => flat.extend(items.iter().cloned()),
6069                other => flat.push(other),
6070            }
6071        }
6072        let len = flat.len() as i64;
6073        // Push the array first; the Int(len) becomes the builtin's return
6074        // value (which CallBuiltin already pushes). Caller consumes in
6075        // reverse: SetSlot(len_slot) pops Int, SetSlot(arr_slot) pops Array.
6076        vm.push(Value::array(flat));
6077        Value::Int(len)
6078    });
6079
6080    // Shell variable get/set — routes through executor.variables so nested
6081    // VMs (function calls) and tree-walker callers see the same storage.
6082    // GET_VAR / GET_VAR_DQ share one body via `get_var_impl`; the only
6083    // difference is `force_dq`, which the compiler sets for QUOTED simple
6084    // reads (`"$name"`) so an array's empty elements are preserved (the
6085    // `in_dq_context` runtime flag is 0 for these compiler-direct reads).
6086    fn get_var_impl(vm: &mut fusevm::VM, argc: u8, force_dq: bool) -> Value {
6087        let args = pop_args(vm, argc);
6088        let name = args.into_iter().next().unwrap_or_default();
6089        let live_status = vm.last_status;
6090        // `$@` and `$*` need splice semantics — return Value::Array of
6091        // positional params so for-loop's BUILTIN_ARRAY_FLATTEN spreads them
6092        // and pop_args splits them into argv slots. zsh's `"$@"` bslashquote-each-
6093        // word semantics matches: each pos-param becomes its own arg.
6094        // Same for arrays accessed by name (e.g. `$arr` in some contexts).
6095        //
6096        // vm.last_status is authoritative: `subshell_end` now returns
6097        // Some(status) and fusevm's `Op::SubshellEnd` writes it into
6098        // vm.last_status, so a deferred subshell `exit N` is visible
6099        // here. Suppressing this sync (as an older revision did, back
6100        // when the host hook returned nothing) made LASTVAL win over
6101        // any status the VM set AFTER SubshellEnd — which dropped the
6102        // `!` negation of `Src/exec.c:1979-1980`
6103        //   if ((slflags & WC_SUBLIST_NOT) && !errflag && !retflag)
6104        //       lastval = !lastval;
6105        // for `! (exit 7)` (emit_negate_status' SetStatus updated
6106        // vm.last_status, then `$?` read the stale LASTVAL=7).
6107        let sync_status = |exec: &mut ShellExecutor| {
6108            exec.set_last_status(live_status);
6109        };
6110        if name == "@" || name == "*" {
6111            // Quoting decides empty-word retention (c:Src/subst.c:
6112            // 184-187): the COMPILE site knows it and emits
6113            // BUILTIN_ARRAY_DROP_EMPTY after this read for the
6114            // unquoted form only — in_dq_context is NOT a valid
6115            // discriminator here (the quoted "$@" fast path emits
6116            // GET_VAR directly without an EXPAND_TEXT wrapper).
6117            let pp = with_executor(|exec| {
6118                sync_status(exec);
6119                exec.pparams()
6120            });
6121            // c:Src/subst.c:1817 — `int nojoin = (pf_flags &
6122            // PREFORK_SHWORDSPLIT) ? !(ifs && *ifs) && !qt : 0;`
6123            // c:Src/subst.c:3908-3911 — `if (nojoin == 0 || sep) { val =
6124            //     sepjoin(aval, sep, 1); isarr = 0; }`
6125            // c:Src/subst.c:3919-3921 — `if (force_split && !isarr) { aval =
6126            //     sepsplit(val, spsep, 0, 1); … }`
6127            //
6128            // So under SH_WORD_SPLIT an UNQUOTED `$@`/`$*` with a NON-EMPTY
6129            // `$IFS` is first JOINED on `$IFS[1]` and then re-split on `$IFS`
6130            // — which is why `setopt shwordsplit; set -- one:two b:c; IFS=:;
6131            // print -l $@` is four words in zsh (and in bash, and in ksh).
6132            // The port returned the raw positional list, so an element
6133            // carrying an IFS byte was never broken up
6134            // (D04parameter.ztst "Splitting of $@ on IFS: single element";
6135            // `zshrs --bash -c 'set -- "a b" c; printf "[%s]\n" $@'` printed
6136            // `[a b]` where bash prints `[a]` `[b]`).
6137            //
6138            // The gates are C's, verbatim: `!force_dq` is `!qt`, an UNSET or
6139            // EMPTY `$IFS` leaves `nojoin` at 1 (no join, no split), and the
6140            // whole rule is inert without SH_WORD_SPLIT (`nojoin = 0` there,
6141            // but `force_split` at c:3913 is `!ssub && (spbreak || spsep)`,
6142            // all clear, so neither branch runs).
6143            if !force_dq && crate::ported::zsh_h::isset(crate::ported::zsh_h::SHWORDSPLIT) {
6144                let ifs = crate::ported::params::getsparam("IFS").unwrap_or_default(); // c:1817
6145                if !ifs.is_empty() {
6146                    // c:3909 `sepjoin(aval, sep, 1)` with sep NULL → $IFS[1].
6147                    let sep0: String = ifs.chars().next().map(String::from).unwrap_or_default();
6148                    let joined = pp.join(&sep0);
6149                    // c:3919 `sepsplit(val, spsep, 0, 1)` with spsep NULL →
6150                    // split on $IFS; multsub's PREFORK_SPLIT walker is the
6151                    // port of that (subst.rs:1603).
6152                    let (_j, parts, _isarr, _f) =
6153                        crate::ported::subst::multsub(&joined, crate::ported::zsh_h::PREFORK_SPLIT);
6154                    return Value::array(parts.into_iter().map(Value::str).collect());
6155                }
6156            }
6157            return Value::array(pp.iter().map(Value::str).collect());
6158        }
6159        // RC_EXPAND_PARAM: when the option is set and `name` refers to
6160        // an array, return Value::Array so the enclosing word's
6161        // BUILTIN_CONCAT_DISTRIBUTE distributes element-wise. Without
6162        // the option, arrays still join to a space-separated scalar
6163        // (zsh's default unquoted-array-as-scalar semantics).
6164        let rc_expand = with_executor(|exec| opt_state_get("rcexpandparam").unwrap_or(false));
6165        // c:Src/subst.c — under KSHARRAYS a bare `$name` (no [@]/[*] subscript;
6166        // this GET_VAR path only handles the bare form) is element 1 ONLY — a
6167        // scalar. RC_EXPAND_PARAM then has a single value to distribute, so
6168        // `$acc` → "p1", NOT the whole array. Skip the whole-array rc_expand
6169        // shortcut when KSHARRAYS is set and fall through to the normal path,
6170        // which applies the element-1 collapse. Without this gate,
6171        // `setopt KSH_ARRAYS rc_expand_param; print -r -- $acc` splatted every
6172        // element while zsh prints just "p1".
6173        let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
6174        // c:Src/subst.c:4245 `if (isarr)` gates the whole plan9 block
6175        // (c:4316), and TWO earlier arms have already zeroed `isarr` for a
6176        // bare array read that is quoted or scalar-substituted:
6177        //   c:3032 `if (qt && !getlen && isarr > 0) { val = sepjoin(aval,
6178        //           sep, 1); isarr = 0; }`                       — DQ context
6179        //   c:3905 `if (nojoin == 0 || sep) { val = sepjoin(aval, sep, 1);
6180        //           isarr = 0; }` under `if (ssub || …)` at c:3901
6181        //                                          — PREFORK_SINGLE (scalar
6182        //                                            assignment RHS)
6183        // So RC_EXPAND_PARAM never cross-products a plain `"$a"` / `b=$a`;
6184        // the array collapses to one IFS-joined scalar first. `force_dq` is
6185        // exactly the compiler's flag for those two contexts (it is set for
6186        // `in_dq || scalar_assign_depth > 0 || assign_builtin_arg_depth > 0`),
6187        // Without this gate `setopt rcexpandparam; a=(x y); print -rl -- "$a"Z`
6188        // emitted `xZ` / `yZ` instead of zsh's single `x yZ` — which is how
6189        // `_sqlite`'s `"($exclusive)"$^dashes'-header[…]'` reached
6190        // `comparguments` as five words starting `(-noheader`.
6191        //
6192        // Only the compile-time flag is consulted. The runtime
6193        // `in_dq_context` counter stays set while a `$(…)` INSIDE double
6194        // quotes runs its body, so reading it here would join an unquoted
6195        // `$a` in `"$(print -l -- $a)"`.
6196        if rc_expand && !ksh_arrays && !force_dq {
6197            let arr_val = with_executor(|exec| {
6198                sync_status(exec);
6199                exec.array(&name)
6200            });
6201            if let Some(arr) = arr_val {
6202                // c:4245 — a real array reference (`isarr != 0`). An empty one
6203                // takes plan9's word-removal path (c:4362), so clear the
6204                // scalar bit; a preceding empty-SCALAR expansion in the same
6205                // word would otherwise leave it set and keep the word alive
6206                // (`empty=''; e=(); a=("$empty"); print -rl -- x$e y`).
6207                // Only an EMPTY array may clear it: a non-empty one carries
6208                // no emptiness of its own, and clearing on it wiped the bit a
6209                // preceding empty SCALAR had set — `setopt rcexpandparam;
6210                // e=''; a=(x y); print -rl -- $e$a` lost the whole word.
6211                if arr.is_empty() {
6212                    note_empty_is_scalar(false);
6213                }
6214                return Value::array(arr.into_iter().map(Value::str).collect());
6215            }
6216        }
6217        // Magic-assoc fallback FIRST — `${aliases}` / `${functions}`
6218        // / `${commands}` / etc. should return the value list per
6219        // zsh's bare-assoc semantics. Without this, those names fell
6220        // through to `get_variable` which is empty (they live in
6221        // separate executor tables, not `assoc_arrays`). Return as
6222        // a Value::Array so `arr=(${aliases})` distributes into
6223        // multiple elements, matching zsh's array-context word
6224        // splitting for assoc-bare references.
6225        let magic_vals = with_executor(|exec| {
6226            sync_status(exec);
6227            // Canonical PARTAB dispatch (Src/Modules/parameter.c:2235-
6228            // 2298 + SPECIALPMDEFs in mapfile/terminfo/termcap/system/
6229            // zleparameter): PARTAB_ARRAY entries → whole-array getfn;
6230            // PARTAB entries → scan keys + per-key getpm/scanpm fn
6231            // pointers.
6232            let _ = exec;
6233            if let Some(values) = partab_array_get(&name) {
6234                Some(values)
6235            } else if let Some(keys) = partab_scan_keys(&name) {
6236                Some(
6237                    keys.iter()
6238                        .map(|k| partab_get(&name, k).unwrap_or_default())
6239                        .collect::<Vec<_>>(),
6240                )
6241            } else {
6242                None
6243            }
6244        });
6245        if let Some(vals) = magic_vals {
6246            // Distinguish "name IS a magic-assoc with no entries"
6247            // (return Array(empty)) from "name is unknown — fall
6248            // through to get_variable".
6249            // c:Src/params.c:2293-2296 — KSHARRAYS bare reference
6250            // collapses to the FIRST element in scan order
6251            // (`v->end = 1, v->isarr = 0`). For `options` the scan
6252            // order is optiontab bucket order (OPTIONTAB), so zsh 5.9
6253            // prints `off` (posixargzero) for
6254            // `setopt ksharrays; print $options`.
6255            if opt_state_get("ksharrays").unwrap_or(false) {
6256                return Value::str(vals.into_iter().next().unwrap_or_default());
6257            }
6258            return Value::array(vals.into_iter().map(Value::str).collect());
6259        }
6260        // Indexed-array path: return Value::Array so pop_args splats
6261        // each element into its own argv slot. Direct port of zsh's
6262        // unquoted `$arr` semantics — each element becomes a separate
6263        // word in command-arg position.
6264        //
6265        // DQ context exception: inside `"...$arr..."`, zsh joins with
6266        // the first char of $IFS (default space) so the DQ word stays
6267        // a single argv slot. Detect via in_dq_context (bumped by
6268        // BUILTIN_EXPAND_TEXT mode 1) and return the joined scalar.
6269        // Direct port of Src/subst.c:1759-1813 nojoin/sepjoin: in DQ
6270        // (qt=1) without explicit `(@)`, sepjoin runs and the result
6271        // is one word.
6272        let arr_assoc_data = with_executor(|exec| {
6273            sync_status(exec);
6274            let in_dq = force_dq || exec.in_dq_context > 0;
6275            // KSH_ARRAYS: bare `$arr` returns ONLY arr[0] (zero-
6276            // based first-element-only semantics). Direct port of
6277            // Src/params.c getstrvalue's KSH_ARRAYS gate which
6278            // returns aval[0] instead of the whole array.
6279            let ksh_arrays = opt_state_get("ksharrays").unwrap_or(false);
6280            if let Some(arr) = exec.array(&name) {
6281                if ksh_arrays {
6282                    return Some((vec![arr.first().cloned().unwrap_or_default()], in_dq));
6283                }
6284                return Some((arr.clone(), in_dq));
6285            }
6286            if exec.assoc(&name).is_some() {
6287                // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
6288                // `$assoc` is `${assoc[0]}` (a KEY-"0" lookup), so it is
6289                // EMPTY unless the hash actually has a key "0". This is
6290                // EMULATION-gated, not KSHARRAYS-option-gated: `emulate -L
6291                // ksh; typeset -A h=(a 1 b 2); print $h` is empty, whereas
6292                // `setopt ksharrays; …; print $h` collapses to the bucket-
6293                // first value below.
6294                if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
6295                    let v = crate::ported::subst::assoc_get(&name)
6296                        .and_then(|m| m.get("0").cloned())
6297                        .unwrap_or_default();
6298                    return Some((vec![v], in_dq));
6299                }
6300                // c:Src/hashtable.c scanhashtable — a bare `$assoc` joins its
6301                // VALUES in zsh hash-BUCKET order (the same order `(k)`/`(v)`
6302                // enumerate), NOT sorted or insertion order. `assoc_get`
6303                // rebuilds zsh's bucket layout; use it so `$as` matches
6304                // `${(v)as}` (`as=(zebra 9 apple 1)` → `9 1`, not the
6305                // alphabetical `1 9`). Under KSHARRAYS the bare form collapses
6306                // to the bucket-FIRST value (`9`), matching zsh.
6307                let values: Vec<String> = crate::ported::subst::assoc_get(&name)
6308                    .map(|m| m.values().cloned().collect())
6309                    .unwrap_or_default();
6310                if ksh_arrays {
6311                    return Some((vec![values.into_iter().next().unwrap_or_default()], in_dq));
6312                }
6313                return Some((values, in_dq));
6314            }
6315            None
6316        });
6317        if let Some((items, in_dq)) = arr_assoc_data {
6318            // c:Src/subst.c:184-187 — prefork's `else if (!keep)
6319            // uremnode(list, node)`: UNQUOTED expansion drops empty
6320            // list nodes before they reach argv, so `a=(y '' x);
6321            // print -- $a` passes TWO args in zsh (`y x`), while the
6322            // quoted "${a[@]}" splat keeps the empty slot. The
6323            // paramsubst splat path already does this (Bug #578
6324            // retain); this GET_VAR fast path bypassed it and leaked
6325            // empty argv slots (visible double-space, wrong arg
6326            // counts in `for`/`print -l`).
6327            let items: Vec<String> = if in_dq {
6328                items
6329            } else {
6330                items.into_iter().filter(|s| !s.is_empty()).collect()
6331            };
6332            if in_dq {
6333                // c:Src/utils.c:3936-3945 sepjoin default-sep rule:
6334                // set-but-empty IFS joins with "" (`IFS=""; echo
6335                // "$arr"` concatenates); only unset / space-leading
6336                // IFS yields " ". The previous get_variable read
6337                // couldn't distinguish unset from set-empty.
6338                return Value::str(crate::ported::utils::sepjoin(&items, None));
6339            }
6340            // c:4245 — a real array reference: `isarr != 0`, so an empty
6341            // one takes plan9's word-removal path, not the scalar path.
6342            // Only note it when the array IS empty — see the rc_expand arm
6343            // above for why a non-empty read must not touch the bit.
6344            if items.is_empty() {
6345                note_empty_is_scalar(false);
6346            }
6347            return Value::array(items.into_iter().map(Value::str).collect());
6348        }
6349        let (val, in_dq, is_known) = with_executor(|exec| {
6350            sync_status(exec);
6351            let v = exec.get_variable(&name);
6352            // For nounset detection: a name is "known" when it has a
6353            // paramtab/array/assoc/env entry. Special chars ($?, $#,
6354            // $@, $*, $-, $$, $!, $_, $0) always count as known
6355            // regardless of value. Pure-digit positional params
6356            // count as known iff index <= $# (set -- has populated
6357            // that slot). c:Src/subst.c:1689 — NOUNSET fires on
6358            // unset positional param too: `set --; echo "$1"` with
6359            // nounset must diagnose.
6360            let is_special_single = name.len() == 1
6361                && matches!(
6362                    name.chars().next().unwrap(),
6363                    '?' | '#' | '@' | '*' | '-' | '$' | '!' | '_' | '0'
6364                );
6365            let is_pure_digit = !name.is_empty() && name.chars().all(|c| c.is_ascii_digit());
6366            let positional_known = if is_pure_digit {
6367                let idx: usize = name.parse().unwrap_or(0);
6368                if idx == 0 {
6369                    true // $0 always set
6370                } else {
6371                    idx <= exec.pparams().len()
6372                }
6373            } else {
6374                false
6375            };
6376            let known = !v.is_empty()
6377                || name.is_empty()
6378                || is_special_single
6379                || positional_known
6380                || crate::ported::params::paramtab()
6381                    .read()
6382                    .ok()
6383                    .map(|t| t.contains_key(&name))
6384                    .unwrap_or(false)
6385                || env::var(&name).is_ok();
6386            (v, force_dq || exec.in_dq_context > 0, known)
6387        });
6388        // c:Src/subst.c:1689 — NO_UNSET / nounset: reading an unset
6389        // parameter fires "parameter not set" diagnostic and aborts
6390        // the substitution. Direct port of the noerrs gate at c:1689
6391        // (zerr + errflag). Matches `set -u` POSIX semantics.
6392        if !is_known && opt_state_get("nounset").unwrap_or(false) {
6393            crate::ported::utils::zerr(&format!("{}: parameter not set", name));
6394            crate::ported::utils::errflag.fetch_or(
6395                crate::ported::zsh_h::ERRFLAG_ERROR,
6396                std::sync::atomic::Ordering::Relaxed,
6397            );
6398            with_executor(|exec| exec.set_last_status(1));
6399            return Value::str("");
6400        }
6401        // Empty unquoted scalar → drop the arg (zsh "remove empty
6402        // unquoted words" rule). Returning empty Value::Array makes
6403        // pop_args contribute zero items. DQ context keeps the empty
6404        // string so "$a" stays a single empty arg. Direct port of
6405        // subst.c's elide-empty pass.
6406        if val.is_empty() && !in_dq {
6407            // c:1650-1656 / c:4437 — a SCALAR parameter has `isarr == 0`,
6408            // so it never reaches plan9's word-removal at c:4362. Flag the
6409            // empty Array below as a scalar so `setopt rcexpandparam;
6410            // v=; print -rl -- x$v y` still emits `x` (only an empty
6411            // ARRAY deletes the word).
6412            note_empty_is_scalar(true);
6413            return Value::array(Vec::new());
6414        }
6415        // c:Src/subst.c:1759 SH_WORD_SPLIT — when shwordsplit is set and
6416        // we're in unquoted command-arg position (not DQ), split scalar
6417        // value on IFS into multiple words. Matches BUILTIN_ARRAY_ALL's
6418        // shwordsplit arm (fusevm_bridge.rs:2200). Without this, bare
6419        // `$s` in `print $s` stayed a single arg even with the option
6420        // set, breaking POSIX-style scalar word-splitting.
6421        if !in_dq && opt_state_get("shwordsplit").unwrap_or(false) {
6422            // c:1705 — `spbreak = (pf_flags & PREFORK_SHWORDSPLIT) && !qt`,
6423            // then c:3902 `force_split = !ssub && (spbreak || spsep)` and
6424            // c:3921 `aval = sepsplit(val, spsep, 0, 1)`. SH_WORD_SPLIT runs
6425            // the SAME splitter as `${=name}`, so route it through the same
6426            // port. The previous `split(|c| ifs.contains(c)).filter(non-empty)`
6427            // dropped every empty field, but spacesplit (Src/utils.c:3711)
6428            // only elides the ones a run of IFS-WHITESPACE produces — an
6429            // IFS-NON-whitespace separator preserves them as `nulstring`.
6430            // `IFS=x; v=xaxbx; setopt shwordsplit; print -rl -- $v` is four
6431            // words in zsh (``, a, b, ``), not two.
6432            let raw = crate::ported::utils::sepsplit(&val, None, false); // c:3921
6433            let nulstring = crate::ported::zsh_h::Nularg.to_string(); // c:36
6434            let parts: Vec<Value> = raw
6435                .into_iter()
6436                .filter_map(|w| {
6437                    if w == nulstring {
6438                        Some(Value::str(String::new()))
6439                    } else if w.is_empty() {
6440                        // c:184-187 — prefork deletes the truly-empty node.
6441                        None
6442                    } else {
6443                        Some(Value::str(w))
6444                    }
6445                })
6446                .collect();
6447            if parts.is_empty() {
6448                // c:3922 — `val = dupstring("")`: an empty SCALAR, not an
6449                // empty array (see EMPTY_EXPANSION_IS_SCALAR).
6450                note_empty_is_scalar(true);
6451                return Value::array(Vec::new());
6452            } else if parts.len() == 1 {
6453                // c:3924 — `else if (!aval[1]) val = aval[0];`
6454                return parts.into_iter().next().unwrap();
6455            } else {
6456                return Value::array(parts); // c:3927
6457            }
6458        }
6459        Value::str(val)
6460    }
6461    // Provenance: BUILTIN_GET_VAR / _DQ are the bytecode-level parameter
6462    // READ ops, so this is the tap that hands a tracked parameter's
6463    // lineage to the value the read produced. The name is peeked off the
6464    // stack before `get_var_impl` consumes it.
6465    fn get_var_prov(vm: &mut fusevm::VM, argc: u8, force_dq: bool) -> Value {
6466        if !crate::provenance::active() {
6467            return get_var_impl(vm, argc, force_dq);
6468        }
6469        let name = vm.peek().to_str();
6470        let value = get_var_impl(vm, argc, force_dq);
6471        crate::provenance::on_param_read(&name, &value);
6472        value
6473    }
6474    vm.register_builtin(BUILTIN_GET_VAR, |vm, argc| get_var_prov(vm, argc, false));
6475    vm.register_builtin(BUILTIN_GET_VAR_DQ, |vm, argc| get_var_prov(vm, argc, true));
6476
6477    // `name+=val` (no parens) — runtime dispatch:
6478    //   - if `name` is in `arrays` → push `val` as new element
6479    //   - if `name` is in `assoc_arrays` → refuse (zsh errors here)
6480    //   - else → scalar concat (existing behavior)
6481    // Stack: [name, value].
6482    vm.register_builtin(BUILTIN_APPEND_SCALAR_OR_PUSH, |vm, argc| {
6483        let args = pop_args(vm, argc);
6484        let mut iter = args.into_iter();
6485        let name = iter.next().unwrap_or_default();
6486        let value = iter.next().unwrap_or_default();
6487        with_executor(|exec| {
6488            // Array form: `arr+=elem` pushes a single element.
6489            // Routes through canonical assignaparam(name, [value],
6490            // ASSPM_AUGMENT) — Src/params.c:3357 c:3402-3412 augment
6491            // path prepends prior scalar / appends to existing array.
6492            // Existence probe uses the non-cloning `has_array` — the
6493            // owning `exec.array()` clone here made `arr+=x` in a loop
6494            // O(n²) (see the assoc-store fix above).
6495            if exec.has_array(&name) {
6496                // c:Src/params.c — under KSHARRAYS a bare array name
6497                // addresses element 0 (ksh), so `a+=X` (scalar augment)
6498                // CONCATENATES onto the first element ("firstlast second"),
6499                // it does NOT push a new element. C routes scalar `+=`
6500                // through assignsparam (which targets the elem-0 value);
6501                // zshrs's APPEND_SCALAR_OR_PUSH would otherwise push.
6502                if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS) {
6503                    let mut arr = exec.array(&name).unwrap_or_default();
6504                    if arr.is_empty() {
6505                        arr.push(value.clone());
6506                    } else {
6507                        arr[0] = format!("{}{}", arr[0], value);
6508                    }
6509                    exec.set_array(name.clone(), arr);
6510                    #[cfg(feature = "recorder")]
6511                    if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
6512                        let ctx = exec.recorder_ctx();
6513                        let attrs = exec.recorder_attrs_for(&name);
6514                        emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
6515                    }
6516                    return;
6517                }
6518                let _ = crate::ported::params::assignaparam(
6519                    &name,
6520                    vec![value.clone()],
6521                    crate::ported::zsh_h::ASSPM_AUGMENT,
6522                );
6523                #[cfg(feature = "recorder")]
6524                if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
6525                    let ctx = exec.recorder_ctx();
6526                    let attrs = exec.recorder_attrs_for(&name);
6527                    emit_path_or_assign(&name, std::slice::from_ref(&value), attrs, true, &ctx);
6528                }
6529                return;
6530            }
6531            if exec.has_assoc(&name) {
6532                eprintln!("zshrs: {}: cannot use += on assoc without (key val)", name);
6533                return;
6534            }
6535            // Scalar / integer / float form: route through canonical
6536            // assignsparam(name, value, ASSPM_AUGMENT) which
6537            // dispatches PM_TYPE — PM_SCALAR concats, PM_INTEGER
6538            // arith-adds (c:2775-2778), PM_FLOAT float-adds.
6539            let _ = crate::ported::params::assignsparam(
6540                &name,
6541                &value,
6542                crate::ported::zsh_h::ASSPM_AUGMENT,
6543            );
6544            #[cfg(feature = "recorder")]
6545            if crate::recorder::is_enabled() && exec.local_scope_depth == 0 {
6546                let ctx = exec.recorder_ctx();
6547                let attrs = exec.recorder_attrs_for(&name);
6548                // Re-read the canonical value via get_variable for the
6549                // recorder bundle (assignsparam may have transformed it
6550                // through integer/float arithmetic).
6551                let final_val = exec.get_variable(&name);
6552                let lower = name.to_ascii_lowercase();
6553                if matches!(
6554                    lower.as_str(),
6555                    "path" | "fpath" | "manpath" | "module_path" | "cdpath"
6556                ) {
6557                    emit_path_or_assign(&name, std::slice::from_ref(&final_val), attrs, true, &ctx);
6558                } else {
6559                    crate::recorder::emit_assign_typed(&name, &final_val, attrs, ctx);
6560                }
6561            }
6562        });
6563        Value::Status(0)
6564    });
6565
6566    // BUILTIN_SET_VAR — `name=value` runtime scalar assignment.
6567    // PURE PASSTHRU: hand to canonical `setsparam` (C port of
6568    // `Src/params.c::setsparam`). That walks assignsparam →
6569    // assignstrvalue which already does:
6570    //   - readonly rejection (zerr + errflag at c:2701)
6571    //   - PM_INTEGER math evaluation (mathevali at c:3590)
6572    //   - PM_EFLOAT / PM_FFLOAT float coercion (c:3608)
6573    //   - PM_LOWER / PM_UPPER case fold (via setstrvalue)
6574    //   - GSU special-param dispatch (homesetfn / ifssetfn / etc.)
6575    //   - allexport env mirror via the PM_EXPORTED setfn
6576    //
6577    // Bridge-only concerns kept here:
6578    //   - inline_env_stack (zsh `X=foo cmd` scoped env)
6579    //   - recorder emission (PFA-SMR)
6580    //   - vm.last_status propagation for `a=$(cmd)` exit-code chaining
6581    // Sets the GLOB_ASSIGN-eligibility flag consumed by the NEXT BUILTIN_SET_VAR.
6582    // Emitted only when a scalar-assign RHS had an unquoted glob token. Takes no
6583    // args; its pushed return is discarded by a following Op::Pop.
6584    vm.register_builtin(BUILTIN_MARK_GLOB_ELIGIBLE, |_vm, _argc| {
6585        SET_VAR_GLOB_ELIGIBLE.with(|c| c.set(true));
6586        fusevm::Value::Int(0)
6587    });
6588    vm.register_builtin(BUILTIN_SET_VAR, |vm, argc| {
6589        // `${~spec}` carrier: an assignment statement is a word-
6590        // pipeline boundary too — restore the user's GLOB_SUBST
6591        // before the NEXT word expands (`Z[d]=${~Z[d]}; print
6592        // ${options[globsubst]}` must read the user value).
6593        consume_tilde_globsubst_carrier();
6594        // Snapshot the raw Values BEFORE pop_args's to_str
6595        // flattening — needed to distinguish Int (arith assignment,
6596        // integer-typed param) from Str (scalar assignment).
6597        let mut raw_values: Vec<fusevm::Value> = Vec::with_capacity(argc as usize);
6598        for _ in 0..argc {
6599            raw_values.push(vm.pop());
6600        }
6601        raw_values.reverse();
6602        let name = raw_values.first().map(|v| v.to_str()).unwrap_or_default();
6603        let value_raw = raw_values.get(1).cloned();
6604        let value = value_raw.as_ref().map(|v| v.to_str()).unwrap_or_default();
6605        // c:Src/params.c — when the bytecode hands us an Int value
6606        // (only the arith assignment paths emit this — `(( X = N ))`
6607        // is the canonical site), route through setiparam so the
6608        // param ends up PM_INTEGER + inherits the math layer's
6609        // `lastbase` for display formatting (`(( X = 16#ff ));
6610        // echo \$X` → `16#FF`). Scalar `X=val` and `$((expr))`
6611        // assignments still take the setsparam path below.
6612        let int_assign = matches!(value_raw, Some(fusevm::Value::Int(_)));
6613        let float_assign = matches!(value_raw, Some(fusevm::Value::Float(_)));
6614        let mut assign_failed = false;
6615        with_executor(|exec| {
6616            // c:Src/params.c assignsparam — PM_READONLY rejection
6617            // BEFORE any env mutation. The inline-env-prefix path
6618            // (`X=2 env`) called env::set_var unconditionally before
6619            // the readonly check fired in setsparam, so the OS env
6620            // got X=2 even though the assignment errored. env then
6621            // inherited the polluted env from fork, leaking the
6622            // attempted override past the readonly guard. Mirror
6623            // C's order: readonly check → zerr → bail; only mutate
6624            // env when the assignment is admissible. Bug #551
6625            // (security-relevant).
6626            if exec.is_readonly_param(&name) {
6627                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
6628                return;
6629            }
6630            // Inline-assignment frame tracking (`X=foo cmd` reverts on
6631            // command return). Only the PREFIX assignments belong in
6632            // the frame: c:Src/exec.c:4410 save_params snapshots the
6633            // parsed WC_ASSIGN chain and nothing else. The frame stays
6634            // on the stack while the command runs, so gate on
6635            // `recording` (cleared by SEAL_INLINE_ENV once the prefix
6636            // assignments have committed) — otherwise every assignment
6637            // the command itself makes gets recorded and then reverted
6638            // (`X=y . file` wiped every global the file defined).
6639            if exec
6640                .inline_env_stack
6641                .last()
6642                .is_some_and(|frame| frame.recording)
6643            {
6644                let prev_var = crate::ported::params::getsparam(&name);
6645                let prev_env = env::var(&name).ok();
6646                exec.inline_env_stack.last_mut().unwrap().saved.push((
6647                    name.clone(),
6648                    prev_var,
6649                    prev_env,
6650                ));
6651                let _ = crate::ported::params::zputenv(&format!("{}={}", &name, &value));
6652                // c:Src/params.c:5354
6653            }
6654            // Canonical setsparam handles readonly, integer math, case
6655            // fold, GSU dispatch. For Int values (arith assigns) route
6656            // through setiparam so the param is PM_INTEGER + inherits
6657            // the math layer's lastbase for display formatting. For
6658            // Float (arith assigns producing MN_FLOAT) route through
6659            // setnparam so the param is PM_FFLOAT — `(( b = a * 2 ))`
6660            // with scalar `a="3.14"` should create b as typeset -F,
6661            // not a scalar holding "6.28".
6662            if int_assign {
6663                if let Some(fusevm::Value::Int(i)) = value_raw {
6664                    crate::ported::params::setiparam(&name, i);
6665                } else {
6666                    assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
6667                }
6668            } else if float_assign {
6669                if let Some(fusevm::Value::Float(f)) = value_raw {
6670                    // ArithCompiler returns Value::Float whenever any
6671                    // operand came through Str (BUILTIN_GET_VAR yields
6672                    // Value::Str even for integer-shaped scalars). To
6673                    // avoid forcing every `(( b = a + 3 ))` to PM_FFLOAT
6674                    // when `a="5"` (integer-shaped), detect integer-
6675                    // valued floats and route through setiparam instead.
6676                    // True floats (non-integral) reach setnparam →
6677                    // PM_FFLOAT so `typeset -p b` shows `typeset -F …`.
6678                    if f.fract() == 0.0 && f.is_finite() && f.abs() <= i64::MAX as f64 {
6679                        crate::ported::params::setiparam(&name, f as i64);
6680                    } else {
6681                        let mnval = crate::ported::math::mnumber {
6682                            l: 0,
6683                            d: f,
6684                            type_: crate::ported::math::MN_FLOAT,
6685                        };
6686                        crate::ported::params::setnparam(&name, mnval);
6687                    }
6688                } else {
6689                    assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
6690                }
6691            } else {
6692                // c:Src/exec.c:2554-2567 — GLOB_ASSIGN. When the
6693                // `globassign` option is on and the scalar RHS is a glob
6694                // pattern, glob it and recreate the parameter as a scalar
6695                // (≤1 match) or array (>1) — csh-style assignment. The
6696                // bridge hands `value` UNTOKENIZED, so re-tokenize
6697                // (shtokenize) before haswilds/globlist; zsh's wordcode
6698                // value arrives pre-tokenized via `htok`. The
6699                // `isset(GLOBASSIGN)` gate is first and cheap (option off
6700                // by default), so the common path is unchanged.
6701                let mut globbed = false;
6702                // Only glob the RHS when the compiler flagged an UNQUOTED glob
6703                // token in the literal wordcode (SET_VAR_GLOB_ELIGIBLE). zsh's
6704                // GLOB_ASSIGN (Src/exec.c:2554) globs literal patterns only —
6705                // `x="/tmp/*"`, `x='/tmp/*'`, `x=$param`, `x=$(cmd)` all assign
6706                // verbatim. The value arrives here untokenized (DQ-wrapped by
6707                // the compiler), so this compile-time flag is the only surviving
6708                // signal of whether the pattern was quote-protected.
6709                let glob_eligible = SET_VAR_GLOB_ELIGIBLE.with(|c| c.replace(false));
6710                if glob_eligible && crate::ported::zsh_h::isset(crate::ported::zsh_h::GLOBASSIGN) {
6711                    let mut tv = value.clone();
6712                    crate::ported::glob::shtokenize(&mut tv);
6713                    if crate::ported::pattern::haswilds(&tv) {
6714                        // Committed to the glob path: never fall back to
6715                        // assigning the literal pattern (zsh errors on
6716                        // no-match instead).
6717                        globbed = true;
6718                        // globlist tokenizes its input internally (for
6719                        // haswilds + glob_path) and prints the ORIGINAL
6720                        // string verbatim in its "no matches found" error,
6721                        // so feed it the UNtokenized value — passing the
6722                        // tokenized form would leak the Star/Quest token
6723                        // bytes into the error message.
6724                        let mut ll: crate::ported::linklist::LinkList<String> = Default::default();
6725                        ll.push_back(value.clone());
6726                        crate::ported::subst::globlist(&mut ll, 0); // c:2556
6727                        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
6728                            == 0
6729                        {
6730                            let matches: Vec<String> = ll
6731                                .nodes
6732                                .iter()
6733                                .map(|s| crate::ported::lex::untokenize(s).to_string())
6734                                .collect();
6735                            crate::ported::params::unsetparam(&name); // c:2562
6736                            if matches.len() <= 1 {
6737                                let v = matches.into_iter().next().unwrap_or_default();
6738                                assign_failed =
6739                                    crate::ported::params::setsparam(&name, &v).is_none();
6740                            } else {
6741                                crate::ported::params::setaparam(&name, matches);
6742                            }
6743                        }
6744                        // errflag set → globlist already reported
6745                        // "no matches found"; leave the param unassigned
6746                        // to match zsh's abort.
6747                    }
6748                }
6749                // c:Src/exec.c addvars — a NULL return from
6750                // assignsparam (e.g. nameref resolving out of scope,
6751                // createparam refusal at c:1108-1118) fails the
6752                // assignment with status 1.
6753                if !globbed {
6754                    assign_failed = crate::ported::params::setsparam(&name, &value).is_none();
6755                }
6756            }
6757            // PM_EXPORTED / allexport env mirror — read AFTER setsparam
6758            // so the flag bit reflects any GSU setfn side-effects.
6759            let allexport = opt_state_get("allexport").unwrap_or(false);
6760            let already_exported =
6761                (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
6762            if allexport || already_exported {
6763                // c:Src/params.c:3024 — the env mirror is `addenv(pm, value)`,
6764                // and addenv builds its string with `mkenvstr(nam, value,
6765                // pm->flags)` (c:5463) so `copyenvstr` (c:5434) can apply the
6766                // PM_LOWER / PM_UPPER fold. Formatting `name=value` by hand
6767                // skipped that: `typeset -lx v; v=HeLLo` exported `HeLLo`
6768                // where zsh exports `hello`. The fold has to happen HERE
6769                // because the paramtab now stores the value verbatim.
6770                let envstr = crate::ported::params::mkenvstr(
6771                    &name,
6772                    &value,
6773                    exec.param_flags(&name), // c:5463 pm->flags
6774                );
6775                let _ = crate::ported::params::zputenv(&envstr); // c:Src/params.c:5354
6776            }
6777            #[cfg(feature = "recorder")]
6778            if crate::recorder::is_enabled()
6779                && exec.local_scope_depth == 0
6780                && !matches!(
6781                    name.as_str(),
6782                    "PPID" | "LINENO" | "ZSH_ARGZERO" | "argv0" | "ARGC" | "?" | "_" | "RANDOM"
6783                )
6784            {
6785                let ctx = exec.recorder_ctx();
6786                let attrs = exec.recorder_attrs_for(&name);
6787                crate::recorder::emit_assign_typed(&name, &value, attrs, ctx);
6788            }
6789            // c:Src/exec.c:1367-1370 — `if (code == WC_ASSIGN) { cmdoutval = 0;
6790            // addvars(state, state->pc - 1, 0); setunderscore(""); … }`. A
6791            // simple command consisting ONLY of scalar assignments clears `$_`;
6792            // it never goes through execcmd's c:3545-3547
6793            // `setunderscore(lastnode(args))`. src/ported/exec.rs:6946-6950
6794            // already ports that arm, but the WC_ASSIGN wordcode never
6795            // executes under fusevm — a bare `x=1` arrives here as
6796            // BUILTIN_SET_VAR, so `$_` kept the PREVIOUS command's last
6797            // argument. Symptom: in the `unset <TAB>` listing (the user's
6798            // `_parameters` override runs `maxLen=50` right before the
6799            // `$parameters` walk) zsh shows `_` empty while zshrs showed the
6800            // completion-internal `^a*` — `_parameters -g '^a*'`'s last arg.
6801            //
6802            // Two exclusions, both verified against zsh 5.9.2
6803            // (`true aa; <form>; print -r -- "[$_]"`):
6804            //   * PREFIX assignments (`x=1 true dd` → `dd`) are part of a
6805            //     command, so c:3545-3547 owns `$_`. They are exactly the
6806            //     assignments recorded into an open inline-env frame above.
6807            //   * `(( q = 1 ))` (→ `aa`, unchanged) is WC_ARITH, not
6808            //     WC_ASSIGN; the arith paths are the only ones that hand this
6809            //     builtin an Int/Float `Value` (see the `int_assign` note).
6810            if !int_assign
6811                && !float_assign
6812                && !exec
6813                    .inline_env_stack
6814                    .last()
6815                    .is_some_and(|frame| frame.recording)
6816            {
6817                // c:1369 — assignment-only command clears `$_`. The
6818                // former DUAL-STATE note here is obsolete: `set_zunderscore`
6819                // and the ported `setunderscore` now write the SAME
6820                // `init::zunderscore` global (params.rs `zunderscore_lock`
6821                // points at it), matching C's single store, so this one call
6822                // is the whole effect.
6823                crate::ported::exec::setunderscore(""); // c:1369
6824            }
6825        });
6826        Value::Status(vm.last_status)
6827    });
6828
6829    // c:Src/exec.c execfor → Src/params.c:6362 setloopvar — the
6830    // for-loop variable bind. Distinct from BUILTIN_SET_VAR because a
6831    // PM_NAMEREF loop variable REBINDS (new refname) instead of
6832    // assigning through the resolved chain.
6833    vm.register_builtin(BUILTIN_SET_LOOP_VAR, |vm, argc| {
6834        let args = pop_args(vm, argc);
6835        let name = args.first().cloned().unwrap_or_default();
6836        let value = args.get(1).cloned().unwrap_or_default();
6837        if crate::vm_helper::is_nameref(&name) {
6838            let ef_before =
6839                crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
6840            crate::ported::params::setloopvar(&name, &value); // c:6362
6841            let ef_after = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
6842            if (ef_after & crate::ported::utils::ERRFLAG_ERROR) != 0 && ef_after != ef_before {
6843                // zerr fired (read-only reference / invalid self
6844                // reference) — abort the loop, status 1 (C errflag).
6845                vm.last_status = 1;
6846                return Value::Bool(false);
6847            }
6848            return Value::Bool(true);
6849        }
6850        // Plain loop var — canonical scalar path (same shape as
6851        // BUILTIN_SET_VAR's setsparam arm).
6852        with_executor(|exec| {
6853            if exec.is_readonly_param(&name) {
6854                crate::ported::utils::zerr(&format!("read-only variable: {}", name));
6855                return;
6856            }
6857            crate::ported::params::setsparam(&name, &value);
6858            let allexport = opt_state_get("allexport").unwrap_or(false);
6859            let already_exported =
6860                (exec.param_flags(&name) as u32 & crate::ported::zsh_h::PM_EXPORTED) != 0;
6861            if allexport || already_exported {
6862                // c:Src/params.c:3024 — the env mirror is `addenv(pm, value)`,
6863                // and addenv builds its string with `mkenvstr(nam, value,
6864                // pm->flags)` (c:5463) so `copyenvstr` (c:5434) can apply the
6865                // PM_LOWER / PM_UPPER fold. Formatting `name=value` by hand
6866                // skipped that: `typeset -lx v; v=HeLLo` exported `HeLLo`
6867                // where zsh exports `hello`. The fold has to happen HERE
6868                // because the paramtab now stores the value verbatim.
6869                let envstr = crate::ported::params::mkenvstr(
6870                    &name,
6871                    &value,
6872                    exec.param_flags(&name), // c:5463 pm->flags
6873                );
6874                let _ = crate::ported::params::zputenv(&envstr); // c:Src/params.c:5354
6875            }
6876        });
6877        Value::Bool(true)
6878    });
6879
6880    // Pre-compiled function registration — used by compile_zsh.rs's
6881    // FuncDef path. Stack: [name, base64-bincode-of-Chunk]. We decode
6882    // the base64, deserialize the Chunk, and store directly in
6883    // executor.functions_compiled. Bypasses the ShellCommand JSON layer.
6884    // BUILTIN_VAR_EXISTS — `[[ -v name ]]` set-test.
6885    // PURE PASSTHRU: build `${+name}` and route through canonical
6886    // `subst::paramsubst` which returns "1" for set / "0" for unset
6887    // (C port of `Src/subst.c::paramsubst` plus-prefix arm).
6888    // paramsubst handles all the shapes the 48-line hand-roll did:
6889    //   - bare scalar / array / assoc
6890    //   - subscripted `a[N]` / `h[key]`
6891    //   - positional params (any digit-only name)
6892    //   - env-var fallback (`HOME` set via getsparam → lookup_special_var)
6893    vm.register_builtin(BUILTIN_VAR_EXISTS, |vm, _argc| {
6894        let name = vm.pop().to_str();
6895        // c:Src/cond.c:361 `case 'v': return !issetvar(left)`. `-v` is
6896        // NOT `${+name}` — issetvar (params.c:751) additionally rejects
6897        // trailing chars after the parsed name/subscript (`arr[3]extra`,
6898        // nested `arr[2][1]`) and validates array-slice bounds (an
6899        // out-of-range `(i)`-not-found index is "unset"). `${+}` is
6900        // lenient and reported those as set.
6901        Value::Bool(crate::ported::params::issetvar(&name) != 0)
6902    });
6903
6904    // `time { compound; ... }` — runs the sub-chunk and prints elapsed
6905    // wall-clock time. zsh's full `time` also tracks user/system CPU via
6906    // getrusage on the *child*; we approximate via wall-time only since
6907    // the sub-chunk runs in-process (no fork). Output format matches
6908    // `time simple-cmd` (already implemented elsewhere via exectime).
6909    vm.register_builtin(BUILTIN_TIME_SUBLIST, |vm, argc| {
6910        // A negative sub-chunk index is the compiler's marker for the BARE
6911        // `time` keyword, which has no body to run.
6912        // c:Src/exec.c:5331-5334 exectime:
6913        //   if (WC_TIMED_TYPE(state->pc[-1]) == WC_TIMED_EMPTY) {
6914        //       shelltime(NULL,NULL,NULL,0);
6915        //       return 0;
6916        //   }
6917        // `shelltime(NULL, NULL, NULL, 0)` is the ONE call that prints the
6918        // shell/children pair: with delta==0 and both pointers NULL, both
6919        // `!delta == !shell` (c:Src/jobs.c:1964) and `!delta == !kids`
6920        // (c:1985) hold. Verified: `zsh -fc 'time'` prints
6921        //   shell  0.00s user 0.00s system … / children  0.00s user …
6922        // while every `time <body>` form prints nothing (see the is_cursh
6923        // note below). zshrs previously compiled bare `time` to a plain
6924        // `status = 0` and printed nothing at all.
6925        let sub_idx_raw = vm.pop().to_int();
6926        let sub_idx = sub_idx_raw as usize;
6927        // c:Src/jobs.c:1028-1029 — `pn->text` arg to printtime. argc==2
6928        // means the compiler also pushed a desc string (bug #66 fix);
6929        // older callers with argc==1 push only sub_idx and we synthesize
6930        // an empty desc for backward compat with cached bytecode that
6931        // predates the desc-threading patch.
6932        let desc = if argc >= 2 {
6933            vm.pop().to_str().to_string()
6934        } else {
6935            String::new()
6936        };
6937        // c:Src/exec.c:3690 — the compiler's `is_cursh` verdict for the
6938        // timed body (see compile_zsh.rs `time_cursh_hint`): 1 = current
6939        // shell, 0 = forked job, 2 = decide from the command name below.
6940        // argc < 4 means bytecode cached before this operand pair existed;
6941        // fall back to the old fork-counter heuristic in that case.
6942        let (cursh_hint, cursh_name) = if argc >= 4 {
6943            let hint = vm.pop().to_int();
6944            let name = vm.pop().to_str().to_string();
6945            (hint, name)
6946        } else {
6947            (-1, String::new())
6948        };
6949        if sub_idx_raw < 0 {
6950            crate::ported::jobs::shelltime(None, None, None, 0); // c:5333
6951            return Value::Status(0); // c:5334
6952        }
6953        let chunk_opt = vm.chunk.sub_chunks.get(sub_idx).cloned();
6954        let Some(chunk) = chunk_opt else {
6955            return Value::Status(0);
6956        };
6957        // c:Src/jobs.c:1968 — `getrusage(RUSAGE_CHILDREN, &ti)` before
6958        // and after the timed sublist gives accurate per-stage user/sys
6959        // CPU. Wall-time-only approximation (0.7×/0.1× fudge factors)
6960        // produced bogus user/sys columns and ignored TIMEFMT. Bug #66
6961        // in docs/BUGS.md.
6962        let ru_before: libc::rusage = unsafe {
6963            let mut r: libc::rusage = std::mem::zeroed();
6964            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
6965            r
6966        };
6967        // c:Src/jobs.c — zsh's `time` reports only for JOBS (forked
6968        // work). Builtins/brace-groups/functions run in the shell
6969        // process with no job, so `zsh -fc 'time true'` emits NOTHING.
6970        // Snapshot the fork-event counter; report only if the timed
6971        // body forked (external command or subshell).
6972        let forks_before = crate::vm_helper::FORK_EVENTS.load(std::sync::atomic::Ordering::Relaxed);
6973        // c:Src/jobs.c:1943 — `getrusage(RUSAGE_SELF, &ti)` — shelltime's
6974        // "shell" line reports the SHELL PROCESS's own CPU delta, which is
6975        // where a current-shell (`is_cursh`) body's work lands.
6976        let ru_self_before: libc::rusage = unsafe {
6977            let mut r: libc::rusage = std::mem::zeroed();
6978            libc::getrusage(libc::RUSAGE_SELF, &mut r);
6979            r
6980        };
6981        let start = Instant::now();
6982        crate::fusevm_disasm::maybe_print_stdout("time_sublist", &chunk);
6983        let mut sub_vm = fusevm::VM::new(chunk);
6984        register_builtins(&mut sub_vm);
6985        let _ = sub_vm.run();
6986        let status = sub_vm.last_status;
6987        let elapsed = start.elapsed();
6988        let ru_self_after: libc::rusage = unsafe {
6989            let mut r: libc::rusage = std::mem::zeroed();
6990            libc::getrusage(libc::RUSAGE_SELF, &mut r);
6991            r
6992        };
6993        let ru_after: libc::rusage = unsafe {
6994            let mut r: libc::rusage = std::mem::zeroed();
6995            libc::getrusage(libc::RUSAGE_CHILDREN, &mut r);
6996            r
6997        };
6998        // Delta children rusage = timed work's CPU.
6999        let mut delta = ru_after;
7000        let sub = |a: libc::timeval, b: libc::timeval| -> libc::timeval {
7001            let mut sec = a.tv_sec - b.tv_sec;
7002            let mut usec = a.tv_usec as i64 - b.tv_usec as i64;
7003            if usec < 0 {
7004                sec -= 1;
7005                usec += 1_000_000;
7006            }
7007            libc::timeval {
7008                tv_sec: sec,
7009                tv_usec: usec as libc::suseconds_t,
7010            }
7011        };
7012        delta.ru_utime = sub(ru_after.ru_utime, ru_before.ru_utime);
7013        delta.ru_stime = sub(ru_after.ru_stime, ru_before.ru_stime);
7014        let ti = crate::ported::zsh_h::timeinfo::from_rusage(&delta);
7015        // c:Src/jobs.c:808-809 — `s = getsparam("TIMEFMT"); s ||
7016        // DEFAULT_TIMEFMT`. Honor user-set TIMEFMT, fall back to the
7017        // canonical default.
7018        let fmt = crate::ported::params::getsparam("TIMEFMT")
7019            .unwrap_or_else(|| crate::ported::zsh_system_h::DEFAULT_TIMEFMT.to_string());
7020        // c:Src/jobs.c:768 `desc` arg — for the `time { sublist }` /
7021        // `time simple-cmd` keyword path, zsh passes the sublist's
7022        // source text (used by %J via printtime). The compiler now
7023        // threads the rendered source text through as the desc operand
7024        // (compile_zsh.rs Time arm, argc==2 form). Bug #66.
7025        // c:Src/exec.c:3690 — resolve the compiler's verdict. Hint 2 means
7026        // "a simple command whose head word decides it": `is_builtin ||
7027        // is_shfunc`. A reserved-word / builtin / function head runs in the
7028        // current shell; anything else forks and becomes a job.
7029        let is_cursh = match cursh_hint {
7030            1 => true,
7031            0 => false,
7032            2 => {
7033                // c:3488-3491 — shfunctab is consulted BEFORE builtintab.
7034                let is_shfunc = crate::ported::hashtable::shfunctab_lock()
7035                    .read()
7036                    .map(|t| t.get(&cursh_name).is_some())
7037                    .unwrap_or(false);
7038                is_shfunc
7039                    || crate::ported::builtin::createbuiltintable()
7040                        .contains_key(cursh_name.as_str())
7041            }
7042            // Bytecode cached before the hint operands existed: keep the
7043            // historical fork-counter heuristic (report only if something
7044            // forked) so a stale cache doesn't start emitting shell/children
7045            // lines for every builtin.
7046            _ => {
7047                let forked = crate::vm_helper::FORK_EVENTS
7048                    .load(std::sync::atomic::Ordering::Relaxed)
7049                    != forks_before;
7050                if forked {
7051                    let line =
7052                        crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
7053                    eprintln!("{}", line);
7054                }
7055                return Value::Status(status);
7056            }
7057        };
7058        let _ = forks_before;
7059        if is_cursh {
7060            // c:Src/exec.c:4443-4444 — `if ((is_cursh || do_exec) && (how &
7061            // Z_TIMED)) shelltime(&shti, &chti, &then, 1);`
7062            //
7063            // c:Src/jobs.c:1933-1993 shelltime(shell, kids, then, delta=1):
7064            //   getrusage(RUSAGE_SELF, &ti);  dtime_tv(… shell delta …);
7065            //   dtime_ts(&dtimespec, then, &now);
7066            //   if (!delta == !shell)  printtime(&dtimespec, &ti, "shell");
7067            //   getrusage(RUSAGE_CHILDREN, &ti); dtime_tv(… kids delta …);
7068            //   if (!delta == !kids)   printtime(&dtimespec, &ti, "children");
7069            // With delta=1 and both pointers non-NULL, BOTH lines print.
7070            //
7071            // !!! DO NOT "FIX" THIS TO PRINT NOTHING !!!
7072            // The locally-installed `zsh` may print nothing here and look
7073            // like the oracle. It is not: this behaviour was ADDED by
7074            // upstream 53088 (ChangeLog 2024-09-14, Bart Schaefer) —
7075            // "Src/exec.c, Src/jobs.c, Test/A01grammar.ztst,
7076            //  Test/A08time.ztst: enable `time' on builtins, assignments,
7077            //  and other current-shell actions, including failed commands."
7078            // — which also ADDED Test/A08time.ztst chunks 8-15, the ones
7079            // that assert `shell*` / `children*` for `time x=1`,
7080            // `time echo $(…)`, `time for ((…))`, `time builtin nonesuch`
7081            // and `time false`. zsh 5.9 (2022-05) predates 53088, so a 5.9.x
7082            // binary is silent for every one of those shapes while both
7083            // vendored C trees (src/zsh 5.9.0.3-test and 5.9.999.3-test)
7084            // carry the c:4443 call. Corpus + C source are the spec here.
7085            let mut d_self = ru_self_after;
7086            d_self.ru_utime = sub(ru_self_after.ru_utime, ru_self_before.ru_utime); // c:1954
7087            d_self.ru_stime = sub(ru_self_after.ru_stime, ru_self_before.ru_stime); // c:1955
7088            let ti_self = crate::ported::zsh_h::timeinfo::from_rusage(&d_self);
7089            // c:1972 — `printtime(&dtimespec, &ti, "shell");`
7090            eprintln!(
7091                "{}",
7092                crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti_self, &fmt, "shell")
7093            );
7094            // c:1993 — `printtime(&dtimespec, &ti, "children");`
7095            eprintln!(
7096                "{}",
7097                crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, "children")
7098            );
7099        } else {
7100            // c:Src/jobs.c:1037 — the forked job's own printtime line, with
7101            // `pn->text` (the command source) as %J.
7102            let line = crate::ported::jobs::printtime(elapsed.as_secs_f64(), &ti, &fmt, &desc);
7103            eprintln!("{}", line);
7104        }
7105        Value::Status(status)
7106    });
7107
7108    // `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocator.
7109    // Stack: [path, varid, op_byte]. Opens path with the appropriate mode
7110    // and stores the resulting fd number in $varid as a string. We use
7111    // a high starting fd (10+) by allocating then dup'ing — matches zsh's
7112    // "fresh fd >= 10" promise so subsequent commands don't collide on
7113    // stdin/out/err.
7114    vm.register_builtin(BUILTIN_OPEN_NAMED_FD, |vm, _argc| {
7115        use std::sync::atomic::Ordering;
7116        let op_byte = vm.pop().to_int() as u8;
7117        let varid = vm.pop().to_str();
7118        let path = vm.pop().to_str();
7119        // Param introspection used by both the open and close forms.
7120        let param_flags = crate::ported::params::paramtab()
7121            .read()
7122            .ok()
7123            .and_then(|t| t.get(&varid).map(|p| p.node.flags));
7124        let param_readonly = param_flags
7125            .map(|f| (f & crate::ported::zsh_h::PM_READONLY as i32) != 0)
7126            .unwrap_or(false);
7127        // `{varid}>&-` / `{varid}<&-` — REDIR_CLOSE with varid.
7128        // Direct port of Src/exec.c:3805-3850.
7129        if matches!(
7130            op_byte,
7131            b if b == fusevm::op::redirect_op::DUP_WRITE
7132                || b == fusevm::op::redirect_op::DUP_READ
7133        ) {
7134            let n = path.trim_start_matches('&');
7135            if n == "-" {
7136                let val = with_executor(|exec| exec.scalar(&varid)).unwrap_or_default();
7137                let fd1 = val.parse::<i32>();
7138                // c:3811-3816 — bad=1: parameter doesn't contain an fd.
7139                let Ok(fd1) = fd1 else {
7140                    crate::ported::utils::zwarn(&format!(
7141                        "parameter {} does not contain a file descriptor",
7142                        varid
7143                    ));
7144                    with_executor(|exec| exec.redirect_failed = true);
7145                    return Value::Status(1);
7146                };
7147                // c:3813-3814 — bad=2: readonly parameter.
7148                if param_readonly {
7149                    crate::ported::utils::zwarn(&format!(
7150                        "can't close file descriptor from readonly parameter {}",
7151                        varid
7152                    ));
7153                    with_executor(|exec| exec.redirect_failed = true);
7154                    return Value::Status(1);
7155                }
7156                // c:3830-3835 — bad=3: fd >= 10 marked FDT_INTERNAL.
7157                if fd1 >= 10
7158                    && fd1 <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
7159                    && crate::ported::utils::fdtable_get(fd1) == crate::ported::zsh_h::FDT_INTERNAL
7160                {
7161                    crate::ported::utils::zwarn(&format!(
7162                        "file descriptor {} used by shell, not closed",
7163                        fd1
7164                    ));
7165                    with_executor(|exec| exec.redirect_failed = true);
7166                    return Value::Status(1);
7167                }
7168                // c:3870-3873 — close; report failure (varid form
7169                // always reports, unlike bare `N>&-`).
7170                if crate::ported::utils::zclose(fd1) < 0 {
7171                    crate::ported::utils::zwarn(&format!(
7172                        "failed to close file descriptor {}: {}",
7173                        fd1,
7174                        std::io::Error::last_os_error()
7175                    ));
7176                    return Value::Status(1);
7177                }
7178                return Value::Status(0);
7179            }
7180            // `{varid}>&N` — dup N to a fresh fd >= 10, store in varid.
7181            if let Ok(src) = n.parse::<i32>() {
7182                if param_readonly {
7183                    crate::ported::utils::zwarn(&format!(
7184                        "can't allocate file descriptor to readonly parameter {}",
7185                        varid
7186                    ));
7187                    with_executor(|exec| exec.redirect_failed = true);
7188                    return Value::Status(1);
7189                }
7190                let dup = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
7191                if dup < 0 {
7192                    crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src));
7193                    with_executor(|exec| exec.redirect_failed = true);
7194                    return Value::Status(1);
7195                }
7196                // c:2404-2412 addfd varid arm — movefd + FDT_EXTERNAL.
7197                let final_fd = crate::ported::utils::movefd(dup);
7198                crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
7199                with_executor(|exec| {
7200                    exec.set_scalar(varid, final_fd.to_string());
7201                });
7202                return Value::Status(0);
7203            }
7204            return Value::Status(1);
7205        }
7206        // `{varid}<<HERE` (op byte 255) / `{varid}<<<str` (op byte
7207        // 254) — zshrs-side contract with compile_redir; fusevm's
7208        // redirect_op stops at 8. C path: gethere/getherestr write the
7209        // body to a temp file (Src/exec.c:4660-4682), then addfd's
7210        // varid arm moves the read fd >= 10, marks FDT_EXTERNAL and
7211        // sets the param (c:2402-2412). `path` carries the BODY text
7212        // here.
7213        //
7214        // The two markers ARE the `REDIRF_FROM_HEREDOC` distinction of
7215        // c:Src/exec.c:4671-4672 (flag set at c:Src/parse.c:2970-2971):
7216        // 255 is the here-DOCUMENT spelling, whose body reaches the
7217        // consumer byte-for-byte, and 254 the genuine here-STRING,
7218        // which gains one trailing newline "as if the string given was
7219        // a complete command line" (c:4665-4666).
7220        if op_byte == 255 || op_byte == 254 {
7221            let from_heredoc = op_byte == 255;
7222            if param_readonly {
7223                crate::ported::utils::zwarn(&format!(
7224                    "can't allocate file descriptor to readonly parameter {}",
7225                    varid
7226                ));
7227                with_executor(|exec| exec.redirect_failed = true);
7228                return Value::Status(1);
7229            }
7230            // c:4671-4672 — `trim_end_matches('\n')` + unconditional
7231            // append was lossy in both directions: `exec {f}<<EOF`
7232            // with two trailing blank lines produced "hello\n" where
7233            // zsh produces "hello\n\n\n".
7234            let body = if from_heredoc {
7235                path
7236            } else {
7237                format!("{}\n", path)
7238            };
7239            let mut tmpl: Vec<u8> = b"/tmp/zshrs_hd_XXXXXX\0".to_vec();
7240            let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
7241            if write_fd < 0 {
7242                crate::ported::utils::zwarn(&format!(
7243                    "can't create temp file for here document: {}",
7244                    std::io::Error::last_os_error()
7245                ));
7246                return Value::Status(1);
7247            }
7248            let bytes = body.as_bytes();
7249            let mut off = 0;
7250            while off < bytes.len() {
7251                let n = unsafe {
7252                    libc::write(
7253                        write_fd,
7254                        bytes[off..].as_ptr() as *const libc::c_void,
7255                        bytes.len() - off,
7256                    )
7257                };
7258                if n <= 0 {
7259                    unsafe { libc::close(write_fd) };
7260                    return Value::Status(1);
7261                }
7262                off += n as usize;
7263            }
7264            unsafe { libc::close(write_fd) };
7265            let read_fd =
7266                unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
7267            unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
7268            if read_fd < 0 {
7269                return Value::Status(1);
7270            }
7271            let final_fd = crate::ported::utils::movefd(read_fd);
7272            if final_fd < 0 {
7273                return Value::Status(1);
7274            }
7275            crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
7276            with_executor(|exec| {
7277                exec.set_scalar(varid, final_fd.to_string());
7278            });
7279            return Value::Status(0);
7280        }
7281        // Open form: `{varid}>file` etc.
7282        // c:Src/exec.c:2177-2215 checkclobberparam — gate BEFORE open.
7283        if param_readonly {
7284            // c:2191-2197
7285            crate::ported::utils::zwarn(&format!(
7286                "can't allocate file descriptor to readonly parameter {}",
7287                varid
7288            ));
7289            with_executor(|exec| exec.redirect_failed = true);
7290            return Value::Status(1);
7291        }
7292        // c:2199-2213 — NO_CLOBBER refuses to overwrite a parameter
7293        // already holding an OPEN fd (decimal value, fdtable says
7294        // FDT_EXTERNAL).
7295        if !isset(crate::ported::zsh_h::CLOBBER) && op_byte != fusevm::op::redirect_op::CLOBBER {
7296            if let Some(val) = with_executor(|exec| exec.scalar(&varid)) {
7297                if let Ok(fd) = val.parse::<i32>() {
7298                    if fd >= 0
7299                        && fd <= crate::ported::utils::MAX_ZSH_FD.load(Ordering::Relaxed)
7300                        && crate::ported::utils::fdtable_get(fd)
7301                            == crate::ported::zsh_h::FDT_EXTERNAL
7302                    {
7303                        crate::ported::utils::zwarn(&format!(
7304                            "can't clobber parameter {} containing file descriptor {}",
7305                            varid, fd
7306                        ));
7307                        with_executor(|exec| exec.redirect_failed = true);
7308                        return Value::Status(1);
7309                    }
7310                }
7311            }
7312        }
7313        let path_c = match CString::new(path.clone()) {
7314            Ok(c) => c,
7315            Err(_) => return Value::Status(1),
7316        };
7317        let flags = match op_byte {
7318            b if b == fusevm::op::redirect_op::READ => libc::O_RDONLY,
7319            b if b == fusevm::op::redirect_op::WRITE || b == fusevm::op::redirect_op::CLOBBER => {
7320                libc::O_WRONLY | libc::O_CREAT | libc::O_TRUNC
7321            }
7322            b if b == fusevm::op::redirect_op::APPEND => {
7323                libc::O_WRONLY | libc::O_CREAT | libc::O_APPEND
7324            }
7325            b if b == fusevm::op::redirect_op::READ_WRITE => libc::O_RDWR | libc::O_CREAT,
7326            _ => return Value::Status(1),
7327        };
7328        let fd = unsafe { libc::open(path_c.as_ptr(), flags, 0o666) };
7329        if fd < 0 {
7330            // c:Src/exec.c:3790-3795 — report the open failure and mark the
7331            // redirect failed so the command is SKIPPED, matching the numeric-fd
7332            // (`3< file`) and non-varid (`< file`) paths. Previously this
7333            // silently returned Status(1) with no diagnostic and no
7334            // redirect_failed flag, so `{fd}< /nonexistent` ran the command
7335            // anyway with exit 0 (zsh errors "no such file or directory" +
7336            // skips the command). `%e: %s` = strerror(errno) : filename.
7337            let e = std::io::Error::last_os_error();
7338            let msg = redir_errno_msg(&e);
7339            crate::ported::utils::zwarn(&format!("{}: {}", msg, path));
7340            with_executor(|exec| exec.redirect_failed = true);
7341            return Value::Status(1);
7342        }
7343        // c:2404-2412 addfd varid arm — `fd1 = movefd(fd2);
7344        // fdtable[fd1] = FDT_EXTERNAL; setiparam(varid, fd1);`.
7345        // FDT_EXTERNAL (not INTERNAL): the user owns this fd — the
7346        // NO_CLOBBER gate above and `{fd}>&-` close both key off it.
7347        let final_fd = crate::ported::utils::movefd(fd);
7348        if final_fd < 0 {
7349            crate::ported::utils::zerr(&format!(
7350                "cannot move fd {}: {}",
7351                fd,
7352                std::io::Error::last_os_error()
7353            ));
7354            return Value::Status(1);
7355        }
7356        crate::ported::utils::fdtable_set(final_fd, crate::ported::zsh_h::FDT_EXTERNAL);
7357        let _ = Ordering::Relaxed;
7358        with_executor(|exec| {
7359            exec.set_scalar(varid, final_fd.to_string());
7360        });
7361        Value::Status(0)
7362    });
7363
7364    // BUILTIN_SET_TRY_BLOCK_ERROR — capture the try-block's exit
7365    // status into `__zshrs_try_block_saved_status` (a scratch
7366    // scalar) so the always-arm can later restore it. Also set
7367    // `TRY_BLOCK_ERROR` per zsh semantics: it stays at -1 unless
7368    // the try-block fired an explicit error (errflag), per
7369    // c:Src/exec.c execlist's WC_TRYBLOCK arm.
7370    vm.register_builtin(BUILTIN_SET_TRY_BLOCK_ERROR, |vm, _argc| {
7371        use std::sync::atomic::Ordering;
7372        let vm_status = vm.last_status;
7373        // c:Src/exec.c WC_TRYBLOCK — the always-arm runs with a
7374        // clean escape state. Snapshot RETFLAG / BREAKS / CONTFLAG /
7375        // EXIT_PENDING here and clear them; RESTORE_TRY_BLOCK_STATUS
7376        // re-applies them at always-arm exit so the propagation jump
7377        // emitted by compile_zsh fires correctly.
7378        let ret_save = crate::ported::builtin::RETFLAG.swap(0, Ordering::Relaxed); // c:769-770
7379        let brk_save = crate::ported::builtin::BREAKS.swap(0, Ordering::Relaxed); // c:771-772
7380        let cont_save = crate::ported::builtin::CONTFLAG.swap(0, Ordering::Relaxed); // c:773-774
7381        let exit_save = crate::ported::builtin::EXIT_PENDING.swap(0, Ordering::Relaxed);
7382        // c:Src/loop.c:762-763 — `save_try_errflag = try_errflag;
7383        // save_try_interrupt = try_interrupt;`. Restored at c:778-779
7384        // by RESTORE_TRY_BLOCK_STATUS so a nested try block doesn't
7385        // clobber the enclosing one's `$TRY_BLOCK_ERROR`.
7386        let try_err_save = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:762
7387        let try_int_save = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:763
7388        TRY_ESCAPE_SAVE.with(|s| {
7389            s.borrow_mut().push((
7390                ret_save,
7391                brk_save,
7392                cont_save,
7393                exit_save,
7394                try_err_save,
7395                try_int_save,
7396            ));
7397        });
7398        // c:Src/loop.c:764-766 — `try_errflag = (zlong)(errflag &
7399        // ERRFLAG_ERROR); try_interrupt = (zlong)((errflag &
7400        // ERRFLAG_INT) ? 1 : 0);`. Both are the RAW FLAG BITS, not the
7401        // try-list's exit status: ERRFLAG_ERROR is 1 (zsh.h:2972), so
7402        // `$TRY_BLOCK_ERROR` is 1-or-0 in zsh regardless of what the
7403        // failing command's `$?` was. The try-list's status is carried
7404        // separately in `__zshrs_try_block_saved_status`.
7405        let live_errflag = crate::ported::utils::errflag.load(Ordering::Relaxed);
7406        let try_err = (live_errflag & crate::ported::zsh_h::ERRFLAG_ERROR) as i64; // c:765
7407        let try_int = if (live_errflag & crate::ported::zsh_h::ERRFLAG_INT) != 0 {
7408            1i64
7409        } else {
7410            0i64
7411        }; // c:766
7412        crate::ported::r#loop::try_errflag.store(try_err, Ordering::Relaxed); // c:765
7413        crate::ported::r#loop::try_interrupt.store(try_int, Ordering::Relaxed); // c:766
7414                                                                                // c:Src/loop.c:755 — `endval = lastval ? lastval : errflag;`.
7415                                                                                // The status of the WHOLE `{…} always {…}` construct, captured
7416                                                                                // BEFORE the always-list runs (exectry returns it at c:801) and
7417                                                                                // deliberately including the errflag fallback: a try-list that
7418                                                                                // failed with `lastval == 0` but raised errflag still reports 1.
7419        let endval = if vm_status != 0 {
7420            vm_status
7421        } else {
7422            live_errflag
7423        }; // c:755
7424        with_executor(|exec| {
7425            // flags=0 (not setsparam's ASSPM_WARN): VM-internal scratch —
7426            // must never surface as a WARN_CREATE_GLOBAL diagnostic inside
7427            // a user function running `{...} always {...}` (f-sy-h's
7428            // `_zsh_highlight` does exactly that under warncreateglobal).
7429            crate::ported::params::assignsparam(
7430                "__zshrs_try_block_saved_status",
7431                &endval.to_string(),
7432                0,
7433            );
7434            let _ = exec;
7435            // Mirror into paramtab so `${parameters[TRY_BLOCK_ERROR]}`
7436            // and the PM_INTEGER `u_val` shadow agree with the atomic
7437            // the special-var getter reads. (setsparam → intsetfn's
7438            // TRY_BLOCK_ERROR arm re-stores the same value.)
7439            exec.set_scalar("TRY_BLOCK_ERROR".to_string(), try_err.to_string());
7440            exec.set_scalar("TRY_BLOCK_INTERRUPT".to_string(), try_int.to_string());
7441        });
7442        // c:Src/loop.c:768 — `errflag = 0;` ("We need to reset all
7443        // errors to allow the block to execute"). C clears the WHOLE
7444        // word, not just ERRFLAG_ERROR.
7445        crate::ported::utils::errflag.store(0, Ordering::Relaxed); // c:768
7446        Value::Status(0)
7447    });
7448
7449    // BUILTIN_BEGIN_INLINE_ENV / END_INLINE_ENV — wrap an
7450    // inline-assignment-prefixed command (`X=foo Y=bar cmd`):
7451    // BEGIN pushes a save frame; SET_VAR fires for each assign and
7452    // ALSO env::set_var's the value (visible to cmd's child); the
7453    // command runs; END pops the frame and restores both shell-var
7454    // and process-env state. Direct port of zsh's addvars() →
7455    // execute_simple → restore-after-exec contract.
7456    vm.register_builtin(BUILTIN_BEGIN_INLINE_ENV, |vm, argc| {
7457        // c:Src/exec.c:4114-4126 — whether the frame RECORDS anything is
7458        // `do_save`:
7459        //     if (isset(POSIXBUILTINS)) {
7460        //         if (is_shfunc || (hn->flags & (BINF_PSPECIAL|BINF_ASSIGN)))
7461        //             do_save = (orig_cflags & BINF_COMMAND);
7462        //         else
7463        //             do_save = 1;
7464        //     } else { ... }
7465        //     if (do_save && varspc) save_params(...);
7466        // A frame is pushed either way so BEGIN/END stay balanced; an
7467        // empty save list simply restores nothing, which IS the
7468        // assignment persisting. POSIX.1-2017 XCU 2.9.1: "If the command
7469        // name is a special built-in utility, variable assignments shall
7470        // affect the current execution environment." Verified:
7471        // `dash|ksh|mksh -c 'v=0; v=1 :; printf "[%s]\n" "$v"'` → `[1]`,
7472        // and `v=2 true` → `[1]` because `true` is NOT special.
7473        //
7474        // The name arrives as a compile-time constant (empty when the
7475        // command word is an expansion, which takes the save arm).
7476        let name = if argc >= 1 {
7477            vm.pop().to_str()
7478        } else {
7479            String::new()
7480        };
7481        let mut frame = crate::vm_helper::InlineEnvFrame::new();
7482        // !!! bash EXCEPTION — bash(1), "POSIX Mode": "Assignment
7483        // statements preceding POSIX special builtins persist in the shell
7484        // environment after the builtin completes." bash does this ONLY in
7485        // posix mode, so default `--bash` keeps zsh's save/restore
7486        // (`bash -c 'v=0; v=1 :; printf "[%s]\n" "$v"'` → `[0]`, while
7487        // dash / ksh93 / mksh / bash-as-sh all print `[1]`). zshrs tracks
7488        // bash's `set -o posix` in dash_mode::BASH_ONLY_OPTS, so honor it.
7489        let bash_suppresses =
7490            crate::dash_mode::bash_mode() && !crate::dash_mode::bash_set_o_get("posix");
7491        if crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXBUILTINS)
7492            && !name.is_empty()
7493            && !bash_suppresses
7494        {
7495            // c:4123 `do_save = (orig_cflags & BINF_COMMAND)` — the
7496            // `command` prefix is BINF_COMMAND (c:Src/builtin.c:44
7497            // `BIN_PREFIX("command", BINF_COMMAND)`) and resets the
7498            // behavior, so it keeps the save.
7499            let is_command_prefix = name == "command";
7500            // !!! dash / pdksh EXCEPTION — C's `is_shfunc` leg is not
7501            // universal. `f(){ :; }; v=0; v=4 f` leaves `v` at 0 in dash,
7502            // ash AND mksh, while ksh93 and bash-as-sh (the `--sh`
7503            // reference) leave it at 4. Only the builtin legs are shared.
7504            let is_shfunc = !crate::dash_mode::dash_strict()
7505                && !crate::dash_mode::pdksh_family()
7506                && crate::ported::hashtable::shfunctab_lock()
7507                    .read()
7508                    .map(|t| t.get(&name).is_some())
7509                    .unwrap_or(false);
7510            if !is_command_prefix
7511                && (is_shfunc || builtin_is_pspecial(&name) || builtin_is_assign_family(&name))
7512            {
7513                frame.recording = false; // c:4122-4123 do_save = 0
7514            }
7515        }
7516        with_executor(|exec| {
7517            exec.inline_env_stack.push(frame);
7518        });
7519        Value::Status(0)
7520    });
7521    // Closes the frame's save list — see BUILTIN_SEAL_INLINE_ENV.
7522    vm.register_builtin(BUILTIN_SEAL_INLINE_ENV, |_vm, _argc| {
7523        with_executor(|exec| {
7524            if let Some(frame) = exec.inline_env_stack.last_mut() {
7525                frame.recording = false;
7526            }
7527        });
7528        Value::Status(0)
7529    });
7530    vm.register_builtin(BUILTIN_END_INLINE_ENV, |_vm, _argc| {
7531        with_executor(|exec| {
7532            if let Some(frame) = exec.inline_env_stack.pop() {
7533                for (name, prev_var, prev_env) in frame.saved.into_iter().rev() {
7534                    match prev_var {
7535                        Some(v) => {
7536                            exec.set_scalar(name.clone(), v);
7537                        }
7538                        None => {
7539                            exec.unset_scalar(&name);
7540                        }
7541                    }
7542                    match prev_env {
7543                        Some(v) => env::set_var(&name, &v),
7544                        None => env::remove_var(&name),
7545                    }
7546                }
7547            }
7548        });
7549        Value::Status(0)
7550    });
7551    // c:Src/exec.c:3969-3976 — bare-exec assignment epilogue: see the
7552    // const's doc block. POSIX_BUILTINS → assignments persist (pop the
7553    // frame, discard the saved state); otherwise → restore_params
7554    // (same walk as END_INLINE_ENV).
7555    vm.register_builtin(BUILTIN_EXEC_INLINE_ENV_DONE, |_vm, _argc| {
7556        let persist = isset(crate::ported::zsh_h::POSIXBUILTINS);
7557        with_executor(|exec| {
7558            if let Some(frame) = exec.inline_env_stack.pop() {
7559                if persist {
7560                    return; // c:3971 — no save/restore under POSIX_BUILTINS
7561                }
7562                for (name, prev_var, prev_env) in frame.saved.into_iter().rev() {
7563                    match prev_var {
7564                        Some(v) => {
7565                            exec.set_scalar(name.clone(), v);
7566                        }
7567                        None => {
7568                            exec.unset_scalar(&name);
7569                        }
7570                    }
7571                    match prev_env {
7572                        Some(v) => env::set_var(&name, &v),
7573                        None => env::remove_var(&name),
7574                    }
7575                }
7576            }
7577        });
7578        Value::Status(0)
7579    });
7580
7581    // BUILTIN_RESTORE_TRY_BLOCK_STATUS — emitted at the end of an
7582    // `always` arm. Per zshmisc, the exit status of the entire
7583    // `{ try } always { finally }` construct is the try-list's
7584    // status, regardless of what happens in the always-list (the
7585    // exception is `return`/`exit` inside always, which short-
7586    // circuits and the cleanup is the only thing that runs). So
7587    // restore TRY_BLOCK_ERROR unconditionally — the always-list's
7588    // exit status is discarded for the construct.
7589    vm.register_builtin(BUILTIN_RESTORE_TRY_BLOCK_STATUS, |_vm, _argc| {
7590        use std::sync::atomic::Ordering;
7591        // c:Src/loop.c:801 — `return endval;`. The construct's exit
7592        // status is the try-list's (captured at c:755 by
7593        // SET_TRY_BLOCK_ERROR), never the always-list's.
7594        let saved = with_executor(|exec| {
7595            exec.scalar("__zshrs_try_block_saved_status")
7596                .and_then(|s| s.parse::<i32>().ok())
7597                .unwrap_or(0)
7598        });
7599        // c:Src/exec.c:1375 — `lastval = lv;` on the exectry return.
7600        // The always-list's own commands left their status in LASTVAL
7601        // (`always { : }` → 0); without this store the errflag re-raise
7602        // below aborts the shell with the always-list's 0 instead of
7603        // the try-list's failure status.
7604        crate::ported::builtin::LASTVAL.store(saved, Ordering::Relaxed); // c:1375
7605                                                                         // c:Src/loop.c:774-777 — the error RE-RAISE. This is the
7606                                                                         // whole point of TRY_BLOCK_ERROR being writable:
7607                                                                         //
7608                                                                         //     if (try_errflag)  errflag |= ERRFLAG_ERROR;
7609                                                                         //     else              errflag &= ~ERRFLAG_ERROR;
7610                                                                         //     if (try_interrupt) errflag |= ERRFLAG_INT;
7611                                                                         //     else               errflag &= ~ERRFLAG_INT;
7612                                                                         //
7613                                                                         // SET_TRY_BLOCK_ERROR cleared errflag (c:768) so the always-arm
7614                                                                         // could run; the try-block's error is PARKED in `try_errflag`
7615                                                                         // and re-raised HERE unless the always-arm zeroed it
7616                                                                         // (`TRY_BLOCK_ERROR=0`, the documented swallow idiom — routed
7617                                                                         // to the atomic by intsetfn's IPDEF6 arm, params.rs).
7618                                                                         //
7619                                                                         // zshrs used to just drop the parked error, so
7620                                                                         // `f() { { typeset -r ro=1; ro=2 } always { … }; print reached }`
7621                                                                         // kept running and exited 0, where zsh aborts f with status 1.
7622        let te = crate::ported::r#loop::try_errflag.load(Ordering::Relaxed); // c:774
7623        let ti = crate::ported::r#loop::try_interrupt.load(Ordering::Relaxed); // c:776
7624        if te != 0 {
7625            crate::ported::utils::errflag
7626                .fetch_or(crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
7627        // c:775
7628        } else {
7629            crate::ported::utils::errflag
7630                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
7631            // c:777
7632        }
7633        if ti != 0 {
7634            crate::ported::utils::errflag
7635                .fetch_or(crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed);
7636        // c:779
7637        } else {
7638            crate::ported::utils::errflag
7639                .fetch_and(!crate::ported::zsh_h::ERRFLAG_INT, Ordering::Relaxed);
7640            // c:781
7641        }
7642        // Re-apply the escape flags captured by SET_TRY_BLOCK_ERROR.
7643        // If the always-arm itself fired return/break/continue/exit,
7644        // its handler already overwrote the canonical atomics; let
7645        // those win — the always-arm's own escape always takes
7646        // priority over the try-block's deferred one.
7647        if let Some((ret, brk, cont, exit_p, try_err_save, try_int_save)) =
7648            TRY_ESCAPE_SAVE.with(|s| s.borrow_mut().pop())
7649        {
7650            // c:Src/loop.c:782-783 — `try_errflag = save_try_errflag;
7651            // try_interrupt = save_try_interrupt;`
7652            crate::ported::r#loop::try_errflag.store(try_err_save, Ordering::Relaxed); // c:782
7653            crate::ported::r#loop::try_interrupt.store(try_int_save, Ordering::Relaxed);
7654            // c:783
7655            if crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) == 0 {
7656                crate::ported::builtin::RETFLAG.store(ret, Ordering::Relaxed);
7657            }
7658            if crate::ported::builtin::BREAKS.load(Ordering::Relaxed) == 0 {
7659                crate::ported::builtin::BREAKS.store(brk, Ordering::Relaxed);
7660            }
7661            if crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed) == 0 {
7662                crate::ported::builtin::CONTFLAG.store(cont, Ordering::Relaxed);
7663            }
7664            if crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed) == 0 {
7665                crate::ported::builtin::EXIT_PENDING.store(exit_p, Ordering::Relaxed);
7666            }
7667        }
7668        Value::Status(saved)
7669    });
7670
7671    // `[[ -r/-w/-x file ]]` — the cond path must use access(2) (the
7672    // C-faithful doaccess), NOT fusevm's generic Op::TestFile which only
7673    // checks existence for -r/-w (so a `chmod 000` file read as readable;
7674    // C02cond.ztst:13). Stack: [path, mode]; mode is the access(2) bit
7675    // (R_OK=4, W_OK=2, X_OK=1). Mirrors cond.rs:232/238/267 `doaccess`.
7676    vm.register_builtin(BUILTIN_COND_ACCESS, |vm, _argc| {
7677        let mode = vm.pop().to_int() as i32;
7678        let path = vm.pop().to_str();
7679        Value::Bool(crate::ported::cond::doaccess(&path, mode) != 0)
7680    });
7681
7682    // `[[ -prefix PAT ]]` / `-suffix` / `-after` / `-between` module condition.
7683    // Stack (pushed by the ModCond compile arm): arg0 … argN-1, then the
7684    // operator word last. argc = N+1.
7685    // c:Src/subst.c:4419-4420 `if (globsubst) shtokenize(y)` — see the
7686    // BUILTIN_COND_SHTOKENIZE doc for why a module condition needs it.
7687    vm.register_builtin(BUILTIN_COND_SHTOKENIZE, |vm, _argc| {
7688        let mut s = vm.pop().to_str();
7689        crate::ported::glob::shtokenize(&mut s);
7690        Value::str(s)
7691    });
7692
7693    vm.register_builtin(BUILTIN_COND_MOD, |vm, argc| {
7694        use crate::ported::zle::complete::{cond_psfix, cond_range, CVT_PREPAT, CVT_SUFPAT};
7695        let op = vm.pop().to_str(); // operator word (pushed last → popped first)
7696        let n = (argc as usize).saturating_sub(1);
7697        let mut args: Vec<String> = Vec::with_capacity(n);
7698        for _ in 0..n {
7699            args.push(vm.pop().to_str());
7700        }
7701        args.reverse(); // restore arg0 … argN-1 order
7702                        // Dispatch the module/completion condition (C evalcond COND_MOD path:
7703                        // condtab lookup + arity check, cond.c:149-185, over the four cotab[]
7704                        // entries at complete.c:1697-1702). Handlers return 1=match/true.
7705        let name: String = op
7706            .trim_start_matches(|c: char| c == '-' || c == '\u{9b}')
7707            .to_string();
7708        // c:Src/cond.c:149-150 — `cd = getconddef((ctype == COND_MODI),
7709        // name + 1, 1)`. The `autol = 1` argument is what makes an
7710        // autoloadable condition LOAD its module: `getconddef`
7711        // (Src/module.c:647) sees the `c:`-stub's `p->module` and calls
7712        // `ensurefeature(p->module, "c:", name)`, then re-looks-up the
7713        // now-real definition that `zsh/complete`'s cotab installed.
7714        // Without this call zshrs answered `[[ -prefix … ]]` straight
7715        // from the compiled-in handler table and left `zsh/complete`
7716        // unloaded, where `zsh -f` reports it loaded after the first use.
7717        // `try_lock`: every other MODULESTAB caller holds the same mutex
7718        // for a moment and the load chain re-enters it; falling through
7719        // to the compiled-in table is the safe outcome, not a deadlock.
7720        let cd = match crate::ported::module::MODULESTAB.try_lock() {
7721            Ok(mut tab) => crate::ported::module::getconddef(0, &name, 1, &mut tab), // c:150
7722            Err(_) => None,
7723        };
7724        // c:151-155 — arity check against the conddef's own min/max
7725        // (`if (l < cd->min || (cd->max >= 0 && l > cd->max))`). The
7726        // fallback pins the same numbers the cotab rows carry
7727        // (complete.c:1698-1701) for the window before zsh/complete is
7728        // loaded, when `getconddef` has only the module-less stub.
7729        let (min, max): (usize, usize) = match cd.as_ref() {
7730            Some(c) if c.max >= 0 => (c.min.max(0) as usize, c.max as usize), // c:152
7731            _ => match name.as_str() {
7732                "prefix" | "suffix" => (1, 2), // c:1700-1701
7733                "after" => (1, 1),             // c:1698
7734                "between" => (2, 2),           // c:1699
7735                _ => {
7736                    // c:Src/cond.c:186-193 — no conddef matched, so C falls out
7737                    // of both `getconddef` arms to `zwarnnam(fromtest, "unknown
7738                    // condition: %s", errname)` and then `return 2;` (the
7739                    // "module not found, error" exit). Status 2 — not 1 — is
7740                    // what `evalcond` hands back, and c:Src/exec.c:5216-5221
7741                    // turns a 2 into a shell error. Arm the same carrier
7742                    // BUILTIN_COND_UNKNOWN uses so the shared
7743                    // BUILTIN_COND_STATUS_FROM_BOOL tail emits 2 and aborts;
7744                    // returning a bare Bool(false) collapsed it to 1, so
7745                    // `[[ -zz a ]]` exited 1 where zsh exits 2.
7746                    COND_BAD_PATTERN.with(|c| c.set(true)); // c:193
7747                    crate::ported::utils::zerr(&format!(
7748                        "unknown condition: {}",
7749                        op.replace('\u{9b}', "-")
7750                    ));
7751                    return Value::Bool(false);
7752                }
7753            },
7754        };
7755        if args.len() < min || args.len() > max {
7756            // c:Src/cond.c:177-181 — `if (l < cd->min || (cd->max >= 0 &&
7757            // l > cd->max)) { zwarnnam(fromtest, "unknown condition: %s",
7758            // errname); return 2; }`. Status 2, same as the module-not-found
7759            // arm above.
7760            //
7761            // This arm previously refused to arm the carrier because the
7762            // PARSER turned a zero-operand `-word` into a ModCond, so
7763            // `[[ -prefix ]]` would have exited 2 where zsh exits 0. That
7764            // parser bug is fixed (src/ported/parse.rs par_cond_2: a
7765            // multi-char `-word` with no operand now takes c:2590's
7766            // `par_cond_double("-n", s1)` string-test arm, and a two-char
7767            // one takes c:2592's `par_cond_multi(s1, newlinklist())`), so
7768            // the only way to reach here is a genuine arity violation —
7769            // `[[ -between a ]]`, which zsh answers 2.
7770            COND_BAD_PATTERN.with(|c| c.set(true)); // c:180
7771            crate::ported::utils::zerr(&format!(
7772                "unknown condition: {}",
7773                op.replace('\u{9b}', "-")
7774            ));
7775            return Value::Bool(false);
7776        }
7777        // c:158 — `return !cd->handler(strs, cd->condid);`
7778        let r = match cd.as_ref().and_then(|c| c.handler.map(|h| (h, c.condid))) {
7779            Some((handler, condid)) => handler(&args, condid),
7780            // Pre-load window: zsh/complete's cotab is not installed yet,
7781            // so dispatch through the compiled-in handlers directly.
7782            None => match name.as_str() {
7783                "prefix" => cond_psfix(&args, CVT_PREPAT),
7784                "suffix" => cond_psfix(&args, CVT_SUFPAT),
7785                "after" => cond_range(&args, 0),
7786                "between" => cond_range(&args, 1),
7787                _ => 0,
7788            },
7789        };
7790        Value::Bool(r == 1)
7791    });
7792
7793    vm.register_builtin(BUILTIN_IS_TTY, |vm, _argc| {
7794        let fd_str = vm.pop().to_str();
7795        let fd: i32 = fd_str.trim().parse().unwrap_or(-1);
7796        let is_tty = if fd < 0 {
7797            false
7798        } else {
7799            unsafe { libc::isatty(fd) != 0 }
7800        };
7801        Value::Bool(is_tty)
7802    });
7803
7804    // c:Src/exec.c:4918/5040/5069 — a process substitution used inside a
7805    // `[[ … ]]` cond operand errors "process substitution %s cannot be
7806    // used here" (getoutputfile/getproc run with thisjob == -1). Emitted
7807    // by the compiler in place of ProcessSubIn/Out when in_cond_operand.
7808    vm.register_builtin(BUILTIN_PROCSUB_COND_ERROR, |_vm, _argc| {
7809        let cmd = _vm.pop().to_str();
7810        crate::ported::utils::zerr(&format!("process substitution {} cannot be used here", cmd));
7811        // c:getoutputfile returns NULL with errflag set → the enclosing
7812        // statement aborts (empty stdout, exit 1), rather than the cond
7813        // merely evaluating false.
7814        crate::ported::utils::errflag.fetch_or(
7815            crate::ported::zsh_h::ERRFLAG_ERROR,
7816            std::sync::atomic::Ordering::Relaxed,
7817        );
7818        with_executor(|exec| exec.set_last_status(1));
7819        _vm.last_status = 1;
7820        Value::str("")
7821    });
7822
7823    // Set $LINENO before executing the next statement. Direct
7824    // port of zsh's `lineno` global tracking from Src/input.c
7825    // (`if ((inbufflags & INP_LINENO) || !strin) && c == '\n')
7826    // lineno++;`). The compiler emits one of these before each
7827    // top-level pipe in `compile_sublist`, carrying the line
7828    // number captured by the parser at `ZshPipe.lineno`. Pops
7829    // [n], updates `$LINENO` in the variable table.
7830    vm.register_builtin(BUILTIN_SET_LINENO, |vm, _argc| {
7831        let n = vm.pop().to_int();
7832        // c:Src/exec.c:1355 — `/* In evaluated traps, don't modify the
7833        // line number. */  if (!IN_EVAL_TRAP() && !ineval && code)
7834        // lineno = code - 1;` (same gate at c:1451 and c:2056).
7835        // `ineval` is set by `eval()` to `!isset(EVALLINENO)`
7836        // (Src/builtin.c:6155), so under NO_EVAL_LINENO the eval body
7837        // must NOT renumber $LINENO — the caller's line stands.
7838        //
7839        // c:1354 — `/* In evaluated traps, don't modify the line number. */`
7840        // The `IN_EVAL_TRAP()` half of the same gate was missing, so an
7841        // eval-form trap body (`trap 'print $LINENO' DEBUG`) renumbered
7842        // $LINENO to its own line 1 and — because nothing restores
7843        // `lineno` on the way out (C does, via execlist's oldlineno
7844        // save/restore at c:1429/1696) — every later statement in the
7845        // trapped scope reported line 1 as well.
7846        if crate::ported::zsh_h::IN_EVAL_TRAP()
7847            || crate::ported::builtin::ineval.load(std::sync::atomic::Ordering::Relaxed) != 0
7848        {
7849            return Value::Status(0);
7850        }
7851        // Provenance: mirror the line into the lineage ledger's own
7852        // counter. The param-write hooks run inside the parameter
7853        // table's lock, so they cannot read `$LINENO` back out of it.
7854        if crate::provenance::active() {
7855            crate::provenance::note_line(n.max(0) as usize);
7856        }
7857        // c:Src/exec.c:lineno = N — direct write to the param's
7858        // u_val. Cannot go through setsparam because LINENO carries
7859        // PM_READONLY (so `(t)LINENO` reads `integer-readonly-special`
7860        // per zsh); setsparam → assignstrvalue's PM_READONLY guard
7861        // would reject the internal write. C zsh handles this via the
7862        // PM_SPECIAL GSU vtable's setfn callback which bypasses the
7863        // generic readonly check; the Rust port writes the canonical
7864        // field directly instead.
7865        if let Ok(mut tab) = crate::ported::params::paramtab().write() {
7866            if let Some(pm) = tab.get_mut("LINENO") {
7867                // c:Src/utils.c:121 `zlong lineno` — the value lives in the C
7868                // GLOBAL, reached through LINENO's GSU. A `typeset -h +g LINENO`
7869                // local shadow has no PM_SPECIAL and no GSU, so C's `lineno = N`
7870                // never touches it; skip the paramtab mirror for the same reason.
7871                if (pm.node.flags & crate::ported::zsh_h::PM_SPECIAL as i32) != 0 {
7872                    pm.u_val = n;
7873                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
7874                }
7875            }
7876        }
7877        // Mirror to the file-static `lineno` (utils.c:121) that
7878        // zerrmsg reads at utils.c:301 for the `:N: msg` prefix.
7879        crate::ported::utils::set_lineno(n as i32);
7880        // Also drive lex::LEX_LINENO — zerrmsg (utils.rs:376) reads
7881        // THAT counter for the `name:N:` prefix. C zsh interleaves
7882        // parse and execute per top-level list, so its single
7883        // `lineno` global serves both; zshrs compiles the whole
7884        // script before running, leaving LEX_LINENO parked at EOF.
7885        // Without this write, every runtime zwarn/zerr reported the
7886        // script's LAST line instead of the failing statement's.
7887        crate::ported::lex::set_lineno(n as u64);
7888        // DAP hook — checks breakpoints / step mode / pause-request
7889        // for the line we just landed on. O(1) no-op when DAP is off
7890        // (single atomic load on a OnceLock). Inside `--dap` mode
7891        // this is the call that blocks the executor on a Condvar
7892        // until the IDE sends `continue`. Mirrors strykelang's
7893        // `debugger.should_stop(line) → debugger.prompt(...)` flow.
7894        crate::extensions::dap::check_line(n as u32);
7895        Value::Status(0)
7896    });
7897
7898    // Direct port of Src/prompt.c:1623 cmdpush. Token is a `CS_*`
7899    // value (zsh.h:2775-2806) emitted by compile_zsh around each
7900    // compound command (if/while/[[…]]/((…))/$(…)) and consumed by
7901    // `%_` in PS4 / prompt expansion.
7902    vm.register_builtin(BUILTIN_CMD_PUSH, |vm, _argc| {
7903        let token = vm.pop().to_int() as u8;
7904        // Route through canonical cmdpush (Src/prompt.c:1623). The
7905        // prompt expander reads from the file-static `CMDSTACK` at
7906        // `prompt.rs:2006`, not `exec.cmd_stack` — without this,
7907        // `%_` in PS4 saw an empty stack during xtrace.
7908        if (token as i32) < crate::ported::zsh_h::CS_COUNT {
7909            crate::ported::prompt::cmdpush(token);
7910        }
7911        Value::Status(0)
7912    });
7913
7914    // Direct port of Src/prompt.c:1631 cmdpop.
7915    vm.register_builtin(BUILTIN_CMD_POP, |_vm, _argc| {
7916        crate::ported::prompt::cmdpop();
7917        Value::Status(0)
7918    });
7919
7920    vm.register_builtin(BUILTIN_OPTION_SET, |vm, _argc| {
7921        let name = vm.pop().to_str();
7922        // Direct port of `optison(char *name, char *s)` at Src/cond.c:502 — `[[ -o NAME ]]`
7923        // reads through the same `opts[]` array that `setopt NAME`
7924        // writes via `dosetopt`. Earlier code read a duplicate Executor
7925        // HashMap which never saw `bin_setopt`'s writes (those land in
7926        // `OPTS_LIVE` via `opt_state_set`). Routing through the canonical
7927        // C port restores the single-store invariant: one `opts[]`,
7928        // shared between setopt/unsetopt and `[[ -o ]]`.
7929        let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
7930        match r {
7931            0 => Value::Bool(true),  // c:cond.c:520 set
7932            1 => Value::Bool(false), // c:cond.c:518/520 unset
7933            _ => {
7934                // c:cond.c:514 — unknown option. optison already emitted the
7935                // diagnostic (via zwarn, now that fromtest=NULL for
7936                // `[[ -o ]]`); re-printing here double-emitted for
7937                // `[[ ! -o bad ]]` / `[[ -o a || -o b ]]`.
7938                Value::Bool(false)
7939            }
7940        }
7941    });
7942    // Tri-state `-o` for compile_cond's direct status path. Returns
7943    // 0 / 1 / 3 as a Value::Int that compile_cond consumes via
7944    // Op::SetStatus. Mirrors zsh's `[[ -o invalid ]]` returning $?=3.
7945    vm.register_builtin(BUILTIN_OPTION_CHECK_TRISTATE, |vm, _argc| {
7946        let name = vm.pop().to_str();
7947        let r = crate::ported::cond::optison(None, &name); // c:cond.c:502 (fromtest=NULL for [[ -o ]])
7948                                                           // optison itself prints the diagnostic via zwarnnam when r=3
7949                                                           // and POSIXBUILTINS is unset (the canonical path). Don't
7950                                                           // double-emit here. r is already 0/1/3.
7951        Value::Int(r as i64)
7952    });
7953
7954    // BUILTIN_PARAM_FILTER — `${var:#pat}` / `${var:|name}` etc.
7955    // PURE PASSTHRU: rebuild `${name:#pat}` and route to paramsubst.
7956    vm.register_builtin(BUILTIN_PARAM_FILTER, |vm, _argc| {
7957        let pattern = vm.pop().to_str();
7958        let name = vm.pop().to_str();
7959        let body = format!("${{{}:#{}}}", name, pattern);
7960        paramsubst_to_value(&body)
7961    });
7962
7963    // `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()`
7964    // — subscripted-array assign with array RHS. Stack pushed by
7965    // compile_assign as: [elem0, elem1, …, elemN-1, name, key].
7966    vm.register_builtin(BUILTIN_SET_SUBSCRIPT_RANGE, |vm, argc| {
7967        let n = argc as usize;
7968        let mut popped: Vec<Value> = Vec::with_capacity(n);
7969        for _ in 0..n {
7970            popped.push(vm.pop());
7971        }
7972        popped.reverse();
7973        if popped.len() < 3 {
7974            return Value::Status(1);
7975        }
7976        // c:Src/params.c:3511-3526 — trailing append flag. The ARRAY
7977        // subscript path (`a[N]+=(v)` / `a[lo,hi]+=(v)`) sets it so the
7978        // AUGMENT transform below collapses the range to an empty range
7979        // after the slice end and inserts ONLY the new value. The scalar
7980        // path pre-concats the old slice (ARRAY_INDEX+Concat) and passes
7981        // 0, so it keeps plain-replace semantics.
7982        // The trailing marker is 0/1 for the ARRAY-RHS emitter and 2 for
7983        // the SCALAR-RHS comma emitter (compile_zsh::compile_assign),
7984        // which also pushes the SOURCE subscript just below it. The
7985        // scalar form is the one C does NOT necessarily treat as a
7986        // range: c:Src/params.c:1515 `(c != Outbrack && (ishash || c !=
7987        // ','))` stops the comma from separating subscripts when the
7988        // parameter is a hash, so `h[1,2]=Z` is the ordinary key `1,2`.
7989        // The compile-time split cannot know the type, so it defers here.
7990        let marker = popped.pop().map_or(String::new(), |v| v.to_str());
7991        let scalar_rhs = marker == "2"; // c:1515 deferral
7992        let key_src = if scalar_rhs {
7993            popped.pop().map(|v| v.to_str())
7994        } else {
7995            None
7996        };
7997        let append = marker == "1";
7998        let key = popped.pop().unwrap().to_str();
7999        let name = popped.pop().unwrap().to_str();
8000        // c:Src/params.c:1585-1592 — `if (needtok) { parsestr(&s);
8001        // singsub(&s); }`: the subscript body is parameter-substituted
8002        // BEFORE it is read, whether it goes on to `mathevalarg`
8003        // (c:1601, the array/scalar range bounds) or straight into the
8004        // hash as a key (c:1596-1616). The ARRAY-RHS emitter pre-expands
8005        // its subscript at word-compile time, but the SCALAR-RHS comma
8006        // emitter hands over the raw source, so this round has to happen
8007        // here: `mathevali("$n")` is 0, which silently turned
8008        // `a=abcdef; n=3; a[$n,-1]=X` into a whole-string overwrite
8009        // (`X` instead of `abX`), and it is why the fzf-tab
8010        // `t[$#MATCH/2+1,-1]=""` form still lost its bound.
8011        //
8012        // Only for a source-level LIVE expansion: an escaped `\$`
8013        // reached `parsestr` as the Bnull marker and `singsub` leaves it
8014        // alone, so `h[\$x,y]` keys on the literal `$x,y`.
8015        let key = if scalar_rhs
8016            && key_src.as_deref().is_some_and(|src| {
8017                let b = src.as_bytes();
8018                (0..b.len())
8019                    .any(|i| (b[i] == b'$' || b[i] == b'`') && (i == 0 || b[i - 1] != b'\\'))
8020            }) {
8021            crate::ported::subst::singsub(&key) // c:1592
8022        } else {
8023            key
8024        };
8025        let mut values: Vec<String> = Vec::new();
8026        for v in popped {
8027            match v {
8028                Value::Array(items) => {
8029                    for it in items.iter() {
8030                        values.push(it.to_str());
8031                    }
8032                }
8033                other => values.push(other.to_str()),
8034            }
8035        }
8036        // Bash sparse-array tracking: a single-index `a[i]=v` that pads the
8037        // dense Vec past its old end leaves indices old_len..i as HOLES (not
8038        // real elements), so `${#a[@]}`/`${!a[@]}` skip them like bash. Only
8039        // in bash mode, only for a plain non-append single index (0-based
8040        // under ksharrays). Captured before the assign; applied after.
8041        let sparse_track: Option<(String, usize, usize)> =
8042            if crate::dash_mode::sparse_arrays() && !append && !key.contains(',') {
8043                key.trim().parse::<usize>().ok().map(|i| {
8044                    let old_len =
8045                        with_executor(|exec| exec.array(&name).map(|a| a.len()).unwrap_or(0));
8046                    (name.clone(), old_len, i)
8047                })
8048            } else {
8049                None
8050            };
8051        // c:Src/params.c:3383-3389 — a subscripted ARRAY assignment to an
8052        // associative array is an error, whatever the subscript looks like:
8053        //     if (v && PM_TYPE(v->pm->node.flags) == PM_HASHED) {
8054        //         unqueue_signals();
8055        //         zerr("%s: attempt to set slice of associative array",
8056        //              v->pm->node.nam);
8057        //         freearray(val);
8058        //         errflag |= ERRFLAG_ERROR;
8059        //         return NULL;
8060        //     }
8061        // assignaparam (params.rs) ports this, but a single-key `h[k]=(1 2)`
8062        // never reaches it: the VM lowers subscripted assignment to this
8063        // builtin instead. The comma form `h[a,b]=(1 2)` DID error — it takes a
8064        // different route — so the gap looked like a subscript-parsing quirk
8065        // when it was really "the check lives on a path this form doesn't
8066        // take". Untreated, the assignment was SILENTLY DISCARDED: rc=0 and
8067        // `${h[k]}` still read its old value.
8068        //
8069        // Only array-valued assignment is rejected; `h[k]=x` is a scalar
8070        // element store and stays legal.
8071        {
8072            let is_hashed = crate::ported::params::paramtab()
8073                .read()
8074                .ok()
8075                .and_then(|t| {
8076                    t.get(&name).map(|pm| {
8077                        crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
8078                            == crate::ported::zsh_h::PM_HASHED
8079                    })
8080                })
8081                .unwrap_or(false);
8082            if is_hashed {
8083                if scalar_rhs {
8084                    // c:1515 — for a hash the comma is not a subscript
8085                    // separator, so this was never a range: hand the
8086                    // WHOLE subscript to the element path. `h[1,2]=Z`
8087                    // keys on `1,2` in zsh; rejecting it here was the
8088                    // compile-time range split leaking through.
8089                    let src = key_src.unwrap_or_else(|| key.clone());
8090                    let val = values.first().cloned().unwrap_or_default();
8091                    return Value::Status(assign_hash_element(&name, &key, &src, &val));
8092                }
8093                crate::ported::utils::zerr(&format!(
8094                    "{name}: attempt to set slice of associative array" // c:3385
8095                ));
8096                crate::ported::utils::errflag.fetch_or(
8097                    crate::ported::zsh_h::ERRFLAG_ERROR,
8098                    std::sync::atomic::Ordering::Relaxed,
8099                ); // c:3387
8100                return Value::Status(1); // c:3388
8101            }
8102        }
8103
8104        with_executor(|exec| {
8105            // Parse subscript: slice `lo,hi` or single index `i`.
8106            // setarrvalue (Src/params.c:2895) expects 1-based start/
8107            // end inclusive where start==end means replace one
8108            // element. Negative bounds translate to len+n+1 (1-based).
8109            //
8110            // c:Src/params.c — the END side accepts 0 as a valid value
8111            // that signals "insert BEFORE start position" (the canonical
8112            // `a[N,N-1]=val` prepend / mid-insert idiom). Bug #275 in
8113            // docs/BUGS.md: the previous Rust port clamped end up to 1,
8114            // collapsing `a[1,0]=(X Y)` into `a[1,1]=(X Y)` which
8115            // OVERWRITES position 1 instead of prepending. Provide two
8116            // translators — start_translate clamps to 1 (1-based);
8117            // end_translate keeps 0 intact so the splice in
8118            // setarrvalue (start_idx=0..end_idx=0) inserts at the front.
8119            // Bug #589: for scalars (no array), use the scalar's char
8120            // count as `len` so negative-index translation (`a[2,-1]`)
8121            // computes against the actual string length, not 0.
8122            let len = exec
8123                .array(&name)
8124                .map(|a| a.len() as i64)
8125                .or_else(|| {
8126                    crate::ported::params::paramtab().read().ok().and_then(|t| {
8127                        t.get(&name).and_then(|pm| {
8128                            if crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
8129                                == crate::ported::zsh_h::PM_SCALAR
8130                            {
8131                                pm.u_str.as_ref().map(|s| s.chars().count() as i64)
8132                            } else {
8133                                None
8134                            }
8135                        })
8136                    })
8137                })
8138                .unwrap_or(0);
8139            let start_translate = |raw: i64| -> i32 {
8140                if raw < 0 {
8141                    (len + raw + 1).max(1) as i32
8142                } else {
8143                    raw.max(1) as i32
8144                }
8145            };
8146            let end_translate = |raw: i64| -> i32 {
8147                if raw < 0 {
8148                    (len + raw + 1).max(0) as i32
8149                } else {
8150                    raw.max(0) as i32
8151                }
8152            };
8153            // c:Src/params.c — KSH_ARRAYS option flips array subscripts
8154            // from 1-based to 0-based. setarrvalue expects 1-based
8155            // inclusive bounds, so under KSH_ARRAYS we shift positive
8156            // inputs by +1 before translation. Negative bounds left
8157            // alone (count from end). Sibling of #610/#611/#612.
8158            // Bug #613.
8159            let ksh_arrays = crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHARRAYS);
8160            let ksh_shift = |raw: i64| -> i64 {
8161                if ksh_arrays && raw >= 0 {
8162                    raw + 1
8163                } else {
8164                    raw
8165                }
8166            };
8167            // c:Src/params.c getindex — subscript bounds are MATH
8168            // expressions, not bare integers: `a[(( ${#a}+1 ))]=(x)`,
8169            // `a[n+1]=(x)`. Plain `parse::<i64>()` returned 0 on any
8170            // arithmetic subscript, which the `i == 0` guard turned into
8171            // a silent no-op (computed-index append never landed). Parse
8172            // the literal fast-path first, then fall back to mathevali
8173            // (which handles `(( ))` grouping, var refs, and operators).
8174            // c:Src/params.c:2036 + c:2118 — getindex resolves EACH range
8175            // bound with `getarg`, not with mathevalarg directly, so a bound
8176            // may be a `(r)`/`(R)`/`(i)`/`(I)`/`(k)`/`(K)` SEARCH rather than
8177            // an arithmetic expression. On an array getarg's search arm
8178            // (c:1672-1719) returns the 1-BASED INDEX `r` of the match — the
8179            // value-vs-index distinction only sets `*inv`, which getindex
8180            // rejects for a range START (c:2121) and otherwise ignores. So map
8181            // the VALUE-returning direction letters onto their INDEX twins and
8182            // read the position back. Without this both bounds evaluated to 0
8183            // through mathevali and `array[(R)nomatch,(r)nomatch]=(…)` became
8184            // a front INSERT instead of the whole-array replace zsh performs.
8185            let cur_arr: Vec<String> = exec.array(&name).unwrap_or_default();
8186            let eval_bound = |s: &str| -> i64 {
8187                let t = s.trim();
8188                if let Some(rest) = t.strip_prefix('(') {
8189                    if let Some(close) = rest.find(')') {
8190                        let grp = &rest[..close];
8191                        if !grp.is_empty()
8192                            && grp
8193                                .chars()
8194                                .all(|c| matches!(c, 'r' | 'R' | 'i' | 'I' | 'k' | 'K' | 'e'))
8195                        {
8196                            // c:1394-1410 — r/k select the FIRST match, R/K the
8197                            // LAST; i/I are the same searches returning the index.
8198                            let mapped: String = grp
8199                                .chars()
8200                                .map(|c| match c {
8201                                    'r' | 'k' => 'i',
8202                                    'R' | 'K' => 'I',
8203                                    other => other,
8204                                })
8205                                .collect();
8206                            let expr = format!("({}){}", mapped, &rest[close + 1..]);
8207                            if let Some(crate::ported::params::getarg_out::Value(v)) =
8208                                crate::ported::params::getarg(&expr, Some(&cur_arr), None, None)
8209                            {
8210                                return v.to_str().trim().parse::<i64>().unwrap_or(0);
8211                            }
8212                        }
8213                    }
8214                }
8215                t.parse::<i64>()
8216                    .ok()
8217                    .or_else(|| crate::ported::math::mathevali(t).ok())
8218                    .unwrap_or(0)
8219            };
8220            let (raw_start, raw_end) = if let Some((s_str, e_str)) = key.split_once(',') {
8221                (ksh_shift(eval_bound(s_str)), ksh_shift(eval_bound(e_str)))
8222            } else {
8223                let i = ksh_shift(eval_bound(&key));
8224                (i, i)
8225            };
8226            // c:Src/params.c:2124-2151 (getindex) — `start == 0 && end == 0`
8227            // is the range entirely off the START of the index range. With
8228            // KSH_ZERO_SUBSCRIPT it degrades to "the first element"
8229            // (`end = startnextlen;` c:2140, i.e. the 0-based range [0,1)).
8230            // Without it the range is flagged VALFLAG_EMPTY (c:2148) and
8231            // setarrvalue (c:2910) rejects the assignment with
8232            // "assignment to invalid subscript range". zshrs silently
8233            // returned for `a[0]=(x)` and front-INSERTED for `a[0,0]=(x)`.
8234            let mut valflags = 0i32;
8235            let (raw_start, raw_end) = if raw_start == 0 && raw_end == 0 {
8236                if crate::ported::zsh_h::isset(crate::ported::zsh_h::KSHZEROSUBSCRIPT) {
8237                    (1, 1) // c:2140 — first element, in the 1-based convention below
8238                } else {
8239                    valflags |= crate::ported::zsh_h::VALFLAG_EMPTY; // c:2148
8240                    (-1, 0) // c:2149 — bounds unused; setarrvalue errors first
8241                }
8242            } else {
8243                (raw_start, raw_end)
8244            };
8245            // c:Src/params.c:2114 (getindex) — a SINGLE subscript is the
8246            // range `[i, i]`: `end = we ? we : start;` sets BOTH bounds to
8247            // the same RAW user index, and only the START is then shifted
8248            // down by one (`if (start > 0) start -= startprevlen;` c:2120).
8249            // setarrvalue (c:2944-2953) afterwards resolves each bound
8250            // INDEPENDENTLY — `start += len` clamped at 0, `end += len + 1`
8251            // clamped at 0 — so an out-of-range negative index such as
8252            // `a[-10]` on a 3-element array yields start==end==0, an EMPTY
8253            // range at the front that INSERTS. Running the end bound of a
8254            // single subscript through `start_translate` (which floors at 1)
8255            // instead collapsed that to [0,1) and OVERWROTE element 1:
8256            // `a=(some sunny day); a[-10]=(we'll meet again)` dropped "some"
8257            // (Test/D04parameter.ztst:1420 "Out of range negative array
8258            // subscripts"). Both forms therefore share one translator pair.
8259            let (start, end) = (start_translate(raw_start), end_translate(raw_end));
8260            // c:Src/params.c:3518-3520 (assignaparam ASSPM_AUGMENT) — a
8261            // subscripted `+=` to an array does NOT prepend the old slice;
8262            // it collapses the range to an EMPTY range positioned right
8263            // AFTER the slice end (`v->start = v->end--`) and splices in
8264            // ONLY the new value: `a[2]+=(d)` on (a b c) → (a b d c);
8265            // `a[2,3]+=(x)` on (1 2 3 4) → (1 2 3 x 4). In setarrvalue's
8266            // 1-based convention here that means start = end+1 (so
8267            // start_idx == end_idx == end → splice arr[end..end]).
8268            let (start, end) = if append && end > 0 {
8269                (end + 1, end)
8270            } else {
8271                (start, end)
8272            };
8273            // c:Src/params.c:392-430 IPDEF9("argv"/"@"/"*", &pparams) —
8274            // the positional parameters live in the `pparams` vector, NOT
8275            // paramtab, so a subscript splice (`argv[2]=(X Y Z)`,
8276            // `2=(X Y Z)`) must read/write pparams. Splice a synthetic
8277            // array param holding the current positionals via the
8278            // canonical setarrvalue, then store the result back to
8279            // pparams — mirroring assignaparam's argv/@/* special-case
8280            // (params.rs:6937) for the whole-array form.
8281            if name == "argv" || name == "@" || name == "*" {
8282                let mut pm = {
8283                    crate::ported::params::createparam(
8284                        &name,
8285                        crate::ported::zsh_h::PM_ARRAY as i32,
8286                    );
8287                    crate::ported::params::paramtab()
8288                        .write()
8289                        .ok()
8290                        .and_then(|mut t| t.remove(&name))
8291                };
8292                if let Some(ref mut p) = pm {
8293                    p.u_arr = Some(exec.pparams());
8294                }
8295                let mut v = crate::ported::zsh_h::value {
8296                    pm,
8297                    arr: Vec::new(),
8298                    scanflags: 0,
8299                    valflags,
8300                    start,
8301                    end,
8302                };
8303                crate::ported::params::setarrvalue(&mut v, values);
8304                let result = v.pm.and_then(|p| p.u_arr).unwrap_or_default();
8305                exec.set_pparams(result);
8306                return;
8307            }
8308            // Route through canonical setarrvalue (Src/params.c:2895).
8309            // It handles PM_READONLY rejection, PM_HASHED slice-error,
8310            // PM_ARRAY splice + bounds clamp + padding (c:2980+).
8311            let taken = match crate::ported::params::paramtab().write() {
8312                Ok(mut tab) => tab.remove(&name),
8313                Err(_) => None,
8314            };
8315            // c:Src/exec.c:2640 / getvalue(…, 1) — a subscript assignment to
8316            // a NONEXISTENT parameter auto-creates it. getindex/fetchvalue
8317            // with the create flag calls createparam(name, PM_ARRAY) so the
8318            // splice has an array to write into; `unset u; u[1,2]=(a z)`
8319            // then yields the array (a z). Without this, setarrvalue saw
8320            // v.pm == None and silently stored nothing. The single-index
8321            // scalar-value path (SET_ASSOC/SET_ARRAY_AT) already vivifies;
8322            // this brings the range/array-value path to parity.
8323            let taken = taken.or_else(|| {
8324                crate::ported::params::createparam(&name, crate::ported::zsh_h::PM_ARRAY as i32);
8325                crate::ported::params::paramtab()
8326                    .write()
8327                    .ok()
8328                    .and_then(|mut t| t.remove(&name))
8329            });
8330            // c:Src/params.c:2748+ — PM_SCALAR with subscript range
8331            // SPLICES the value into the scalar's char string. Bug
8332            // #589: zshrs's slice handler always called setarrvalue,
8333            // erroring "attempt to assign array value to non-array"
8334            // for `a=hello; a[2,3]=XYZ`. Detect PM_SCALAR and route
8335            // through assignstrvalue (which does scalar splice via
8336            // the PM_SCALAR arm at params.rs:3709-3789).
8337            let is_scalar = taken.as_ref().map_or(false, |pm| {
8338                crate::ported::zsh_h::PM_TYPE(pm.node.flags as u32)
8339                    == crate::ported::zsh_h::PM_SCALAR
8340            });
8341            let mut v = crate::ported::zsh_h::value {
8342                pm: taken,
8343                arr: Vec::new(),
8344                scanflags: 0,
8345                valflags,
8346                start,
8347                end,
8348            };
8349            if is_scalar {
8350                // Scalar splice — concat values, route through
8351                // assignstrvalue which dispatches by PM_TYPE.
8352                // start_translate returns 1-based positions; assignstrvalue's
8353                // PM_SCALAR arm at params.rs:3735+ expects 0-based start
8354                // (chars before start are kept) and 0-based end-exclusive
8355                // (chars from end are kept). Convert: start-=1.
8356                if v.start > 0 {
8357                    v.start -= 1;
8358                }
8359                let val: String = values.join("");
8360                crate::ported::params::assignstrvalue(Some(&mut v), Some(val), 0);
8361            } else {
8362                crate::ported::params::setarrvalue(&mut v, values);
8363            }
8364            // Write the mutated Param back to paramtab — setarrvalue
8365            // mutated v.pm in-place; the prior `tab.remove(&name)` at
8366            // the top of this handler took ownership, so we re-insert
8367            // here. setarrvalue + this re-insert IS the canonical
8368            // store (Src/params.c:2895). No further mirror needed.
8369            if let Some(pm) = v.pm {
8370                if let Ok(mut tab) = crate::ported::params::paramtab().write() {
8371                    tab.insert(name, pm);
8372                }
8373            }
8374        });
8375        if let Some((nm, old_len, i)) = sparse_track {
8376            crate::bash_arrays::note_subscript_set(&nm, old_len, i);
8377        }
8378        Value::Status(0)
8379    });
8380
8381    // BUILTIN_CONCAT_SPLICE — word-segment concat for an expansion whose
8382    // ARRAY shape survives into the word (`${arr[@]}`, `$@`, `${(@)a}`,
8383    // `${=v}`, slices). c:Src/subst.c:4245 `if (isarr)` gates the two
8384    // emit shapes and c:1663 `int plan9 = isset(RCEXPANDPARAM);` picks
8385    // between them at RUNTIME — so the option, not the compile-time
8386    // segment shape, decides splice-vs-cross-product here.
8387    vm.register_builtin(BUILTIN_CONCAT_SPLICE, |vm, _argc| {
8388        let rhs = vm.pop();
8389        let lhs = vm.pop();
8390        if plan9_active() {
8391            return concat_plan9_prov(lhs, rhs);
8392        }
8393        concat_splice_prov(lhs, rhs)
8394    });
8395
8396    // BUILTIN_CONCAT_DISTRIBUTE — word-segment concat. With
8397    // rcexpandparam (zsh option), distributes element-wise (cartesian
8398    // product). Default mode: joins arrays with IFS first char to a
8399    // single scalar before concat, matching zsh's default unquoted
8400    // and DQ semantics. Direct port of Src/subst.c sepjoin path
8401    // (line ~1813) which gates element-vs-join on the rc_expand_param
8402    // option, defaulting to join.
8403    // BUILTIN_CONCAT_DISTRIBUTE_FORCED — same shape as
8404    // CONCAT_DISTRIBUTE, but always cartesian-distributes when one
8405    // side is Array. Used for compile-time-detected explicit
8406    // distribution forms (`${^arr}` etc.) where the source flag
8407    // overrides the rcexpandparam option default.
8408    // `${^arr}` — RC_EXPAND_PARAM forced on by the flag. concat_plan9 carries
8409    // both halves of C's plan9 block: the c:4316-4350 cartesian emit AND the
8410    // c:4362 `uremnode` word deletion for an empty array. The DISTRIBUTE_FORCED
8411    // handler below cannot be reused: it is shared with `${(@)a}` / `${(f)v}` /
8412    // `${a[@]}`, which KEEP the word on empty (`x${(@)a}y` → `xy`).
8413    vm.register_builtin(BUILTIN_CONCAT_PLAN9, |vm, _argc| {
8414        let rhs = vm.pop();
8415        let lhs = vm.pop();
8416        concat_plan9_prov(lhs, rhs)
8417    });
8418
8419    // `${^^arr}` — RC_EXPAND_PARAM forced OFF (c:2553-2555 `plan9 = 0`). Every
8420    // other concat builtin re-checks plan9_active(), which is the OPTION, so
8421    // under `setopt rcexpandparam` they cross-product regardless of the flag.
8422    // Go straight to concat_splice — C's non-plan9 path (c:4366-4437).
8423    vm.register_builtin(BUILTIN_CONCAT_SPLICE_NOPLAN9, |vm, _argc| {
8424        let rhs = vm.pop();
8425        let lhs = vm.pop();
8426        concat_splice_prov(lhs, rhs)
8427    });
8428
8429    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE_FORCED, |vm, _argc| {
8430        let rhs = vm.pop();
8431        let lhs = vm.pop();
8432        match (lhs, rhs) {
8433            (Value::Array(la), Value::Array(ra)) => {
8434                if ra.is_empty() {
8435                    return Value::Array(la);
8436                }
8437                if la.is_empty() {
8438                    return Value::Array(ra);
8439                }
8440                let mut out = Vec::with_capacity(la.len() * ra.len());
8441                for a in la.iter() {
8442                    let a_s = a.as_str_cow();
8443                    for b in ra.iter() {
8444                        let b_s = b.as_str_cow();
8445                        let mut s = String::with_capacity(a_s.len() + b_s.len());
8446                        s.push_str(&a_s);
8447                        s.push_str(&b_s);
8448                        out.push(Value::str(s));
8449                    }
8450                }
8451                Value::array(out)
8452            }
8453            (Value::Array(la), rhs_scalar) => {
8454                // An EMPTY array contributes nothing to a concatenated
8455                // word — the surrounding scalar text survives. zsh:
8456                // `x${^a}y` (a=()) / `x${(P)scalar-empty}y` → "xy", NOT
8457                // a dropped word. Without this, a `(P)` indirect to an
8458                // unset/empty scalar (which nodes_to_value collapses to
8459                // Value::Array([]) for standalone-removal semantics)
8460                // cartesian-dropped the whole word — p10k's
8461                // `typeset -g _$2=${(P)2}` then arrived as a bare
8462                // `typeset` and dumped every parameter (~217× → 19 MB
8463                // terminal flood → startup hang).
8464                if la.is_empty() {
8465                    return rhs_scalar;
8466                }
8467                let r = rhs_scalar.as_str_cow();
8468                let out: Vec<Value> = la
8469                    .iter()
8470                    .map(|a| {
8471                        let a_s = a.as_str_cow();
8472                        let mut s = String::with_capacity(a_s.len() + r.len());
8473                        s.push_str(&a_s);
8474                        s.push_str(&r);
8475                        Value::str(s)
8476                    })
8477                    .collect();
8478                Value::array(out)
8479            }
8480            (lhs_scalar, Value::Array(ra)) => {
8481                // Symmetric empty-array-contributes-nothing rule; see
8482                // the (Array, scalar) arm above.
8483                if ra.is_empty() {
8484                    return lhs_scalar;
8485                }
8486                let l = lhs_scalar.as_str_cow();
8487                let out: Vec<Value> = ra
8488                    .iter()
8489                    .map(|b| {
8490                        let b_s = b.as_str_cow();
8491                        let mut s = String::with_capacity(l.len() + b_s.len());
8492                        s.push_str(&l);
8493                        s.push_str(&b_s);
8494                        Value::str(s)
8495                    })
8496                    .collect();
8497                Value::array(out)
8498            }
8499            (lhs_s, rhs_s) => {
8500                let l = lhs_s.as_str_cow();
8501                let r = rhs_s.as_str_cow();
8502                let mut s = String::with_capacity(l.len() + r.len());
8503                s.push_str(&l);
8504                s.push_str(&r);
8505                Value::str(s)
8506            }
8507        }
8508    });
8509
8510    vm.register_builtin(BUILTIN_CONCAT_DISTRIBUTE, |vm, argc| {
8511        let rhs = vm.pop();
8512        let lhs = vm.pop();
8513        // c:Src/subst.c:4245 `if (isarr)` — an unquoted array embedded
8514        // in a word ALWAYS emits one word per element, never a scalar
8515        // join. The shape (splice vs plan9 cross-product) is chosen at
8516        // RUNTIME by c:1663 `int plan9 = isset(RCEXPANDPARAM);`, exactly
8517        // as BUILTIN_CONCAT_SPLICE does. The only extra case DISTRIBUTE
8518        // handles is the DQ context: the compiler emits
8519        // CallBuiltin(BUILTIN_CONCAT_DISTRIBUTE, 1) when the parent word
8520        // is DQ-wrapped (compile_zsh.rs parent_is_dq), and inside DQ
8521        // `"pre${arr}post"` joins via $IFS[0] to a single scalar
8522        // regardless of the option (c:Src/subst.c:1650-1656 isarr
8523        // comment). The default UNQUOTED path emits argc=2 (lhs + rhs).
8524        // Bug #246 in docs/BUGS.md.
8525        if argc == 1 {
8526            // DQ context: join any Array side to scalar via sepjoin's
8527            // IFS default. c:Src/utils.c:3936-3945 — set-but-empty IFS
8528            // joins with "" (`IFS=""; echo "x$*y"` → `xabcy`); only
8529            // unset / space-leading IFS yields " ".
8530            let join_arr = |arr: &[Value]| -> String {
8531                let strs: Vec<String> = arr.iter().map(|v| v.as_str_cow().into_owned()).collect();
8532                crate::ported::utils::sepjoin(&strs, None)
8533            };
8534            // Provenance: the DQ-join arm consumes both operands, so
8535            // capture them first (Arc clones, only while armed).
8536            let prov_operands = crate::provenance::active().then(|| (lhs.clone(), rhs.clone()));
8537            let l = match lhs {
8538                Value::Array(a) => join_arr(&a),
8539                other => other.as_str_cow().into_owned(),
8540            };
8541            let r = match rhs {
8542                Value::Array(a) => join_arr(&a),
8543                other => other.as_str_cow().into_owned(),
8544            };
8545            let mut s = String::with_capacity(l.len() + r.len());
8546            s.push_str(&l);
8547            s.push_str(&r);
8548            let out = Value::str(s);
8549            if let Some((pl, pr)) = prov_operands {
8550                crate::provenance::on_concat(&pl, &pr, &out);
8551            }
8552            return out;
8553        }
8554        // Unquoted plain `${arr}`: same runtime dispatch as
8555        // BUILTIN_CONCAT_SPLICE — c:4245 `if (isarr)` always distributes
8556        // one word per element; c:1663 picks splice (default, first/last
8557        // sticking, c:4366-4437) vs plan9 cross-product (c:4316-4365).
8558        // concat_splice / concat_plan9 both honor EMPTY_EXPANSION_IS_SCALAR
8559        // so the p10k `${(P)2}` empty-array word-removal semantics survive.
8560        if plan9_active() {
8561            return concat_plan9_prov(lhs, rhs);
8562        }
8563        concat_splice_prov(lhs, rhs)
8564    });
8565
8566    // See BUILTIN_WORD_ASSEMBLE_PLAN9's doc comment for the stack contract.
8567    vm.register_builtin(BUILTIN_WORD_ASSEMBLE_PLAN9, |vm, argc| {
8568        // Pop argc values: descriptor was pushed FIRST (bottom), segments
8569        // after it, so popping top-first then reversing yields
8570        // [descriptor, seg0, …, seg(n-1)].
8571        let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
8572        for _ in 0..argc {
8573            popped.push(vm.pop());
8574        }
8575        popped.reverse();
8576        let mut it = popped.into_iter();
8577        let descriptor = it.next().map(|v| v.to_str()).unwrap_or_default();
8578        let plan9_flags: Vec<bool> = descriptor.chars().map(|c| c == '1').collect();
8579        let segments: Vec<Value> = it.collect();
8580        word_assemble_plan9(&segments, &plan9_flags)
8581    });
8582
8583    // `[[ a -ef b ]]` — same-inode test. Resolves both paths via fs::metadata
8584    // (follows symlinks the way zsh's -ef does) and compares (dev, inode).
8585    // Returns false on any I/O error (path missing, permission denied, etc.).
8586    vm.register_builtin(BUILTIN_SAME_FILE, |vm, _argc| {
8587        let b = vm.pop().to_str();
8588        let a = vm.pop().to_str();
8589        let same = match (fs::metadata(&a), fs::metadata(&b)) {
8590            (Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
8591            _ => false,
8592        };
8593        Value::Bool(same)
8594    });
8595
8596    // `[[ -c path ]]` — character device.
8597    vm.register_builtin(BUILTIN_IS_CHARDEV, |vm, _argc| {
8598        let path = vm.pop().to_str();
8599        let result = fs::metadata(&path)
8600            .map(|m| m.file_type().is_char_device())
8601            .unwrap_or(false);
8602        Value::Bool(result)
8603    });
8604    // `[[ -b path ]]` — block device.
8605    vm.register_builtin(BUILTIN_IS_BLOCKDEV, |vm, _argc| {
8606        let path = vm.pop().to_str();
8607        let result = fs::metadata(&path)
8608            .map(|m| m.file_type().is_block_device())
8609            .unwrap_or(false);
8610        Value::Bool(result)
8611    });
8612    // `[[ -p path ]]` — FIFO (named pipe).
8613    vm.register_builtin(BUILTIN_IS_FIFO, |vm, _argc| {
8614        let path = vm.pop().to_str();
8615        let result = fs::metadata(&path)
8616            .map(|m| m.file_type().is_fifo())
8617            .unwrap_or(false);
8618        Value::Bool(result)
8619    });
8620    // `[[ -S path ]]` — socket.
8621    vm.register_builtin(BUILTIN_IS_SOCKET, |vm, _argc| {
8622        let path = vm.pop().to_str();
8623        let result = fs::symlink_metadata(&path)
8624            .map(|m| m.file_type().is_socket())
8625            .unwrap_or(false);
8626        Value::Bool(result)
8627    });
8628
8629    // `[[ -k path ]]` / `-u` / `-g` — sticky / setuid / setgid bit.
8630    vm.register_builtin(BUILTIN_HAS_STICKY, |vm, _argc| {
8631        let path = vm.pop().to_str();
8632        let result = fs::metadata(&path)
8633            .map(|m| m.permissions().mode() & libc::S_ISVTX as u32 != 0)
8634            .unwrap_or(false);
8635        Value::Bool(result)
8636    });
8637    vm.register_builtin(BUILTIN_HAS_SETUID, |vm, _argc| {
8638        let path = vm.pop().to_str();
8639        let result = fs::metadata(&path)
8640            .map(|m| m.permissions().mode() & libc::S_ISUID as u32 != 0)
8641            .unwrap_or(false);
8642        Value::Bool(result)
8643    });
8644    vm.register_builtin(BUILTIN_HAS_SETGID, |vm, _argc| {
8645        let path = vm.pop().to_str();
8646        let result = fs::metadata(&path)
8647            .map(|m| m.permissions().mode() & libc::S_ISGID as u32 != 0)
8648            .unwrap_or(false);
8649        Value::Bool(result)
8650    });
8651    vm.register_builtin(BUILTIN_OWNED_BY_USER, |vm, _argc| {
8652        let path = vm.pop().to_str();
8653        let euid = unsafe { libc::geteuid() };
8654        let result = fs::metadata(&path)
8655            .map(|m| m.uid() == euid)
8656            .unwrap_or(false);
8657        Value::Bool(result)
8658    });
8659    vm.register_builtin(BUILTIN_OWNED_BY_GROUP, |vm, _argc| {
8660        let path = vm.pop().to_str();
8661        let egid = unsafe { libc::getegid() };
8662        let result = fs::metadata(&path)
8663            .map(|m| m.gid() == egid)
8664            .unwrap_or(false);
8665        Value::Bool(result)
8666    });
8667
8668    // `[[ -N path ]]` — file's access time is NOT newer than its
8669    // modification time (zsh man: "true if file exists and its
8670    // access time is not newer than its modification time"). Used
8671    // by zsh's mailbox-watching code. The semantic is `atime <=
8672    // mtime` (equivalent to `mtime >= atime`) — equal counts as
8673    // true, which a strict `mtime > atime` check missed for newly
8674    // created files where both stamps are identical.
8675    vm.register_builtin(BUILTIN_FILE_MODIFIED_SINCE_ACCESS, |vm, _argc| {
8676        let path = vm.pop().to_str();
8677        let result = fs::metadata(&path)
8678            .map(|m| m.atime() <= m.mtime())
8679            .unwrap_or(false);
8680        Value::Bool(result)
8681    });
8682
8683    // `[[ a -nt b ]]` — true if `a`'s mtime is strictly later than `b`'s.
8684    // BOTH files must exist; if either is missing the result is false.
8685    // (Earlier behavior was bash's "missing == infinitely-old"; zsh
8686    // strictly requires both files to exist.)
8687    vm.register_builtin(BUILTIN_FILE_NEWER, |vm, _argc| {
8688        let b = vm.pop().to_str();
8689        let a = vm.pop().to_str();
8690        // Use SystemTime modified() for nanosecond precision —
8691        // MetadataExt::mtime() returns seconds only, so two files
8692        // touched within the same second compared equal even when
8693        // 500ms apart. zsh tracks ns and uses `>=` for ties (touching
8694        // a then b in quick succession should still report b newer).
8695        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
8696        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
8697        let result = match (ta, tb) {
8698            (Some(ta), Some(tb)) => ta > tb,
8699            _ => false,
8700        };
8701        Value::Bool(result)
8702    });
8703
8704    // `[[ a -ot b ]]` — mirror of -nt. Same both-must-exist contract.
8705    vm.register_builtin(BUILTIN_FILE_OLDER, |vm, _argc| {
8706        let b = vm.pop().to_str();
8707        let a = vm.pop().to_str();
8708        let ta = fs::metadata(&a).and_then(|m| m.modified()).ok();
8709        let tb = fs::metadata(&b).and_then(|m| m.modified()).ok();
8710        let result = match (ta, tb) {
8711            (Some(ta), Some(tb)) => ta < tb,
8712            _ => false,
8713        };
8714        Value::Bool(result)
8715    });
8716
8717    // `set -e` / `setopt errexit` post-command check. Compiler emits
8718    // this after each top-level command's SetStatus (skipped inside
8719    // conditionals/pipelines/&&||/`!`). If errexit is on AND the last
8720    // command exited non-zero AND it's not a `return` from a function,
8721    // exit the shell with that status.
8722    // `set -x` / `setopt xtrace` — print each command before it runs.
8723    // The compiler emits this BEFORE the actual builtin/external call
8724    // with the command's literal text as a single string arg. We
8725    // print to stderr if xtrace is on. Honors `$PS4` (default `+ `).
8726    //
8727    // ── XTRACE flow control ────────────────────────────────────────
8728    // Mirror of C zsh's `doneps4` flag in execcmd_exec (Src/exec.c).
8729    // When an assignment trace fires (XTRACE_ASSIGN), it emits PS4
8730    // and sets this flag so the subsequent XTRACE_ARGS skips its own
8731    // PS4 emission — the assignment + command end up on the SAME
8732    // line: `<PS4>a=1 echo hello\n`. XTRACE_ARGS / XTRACE_NEWLINE
8733    // reset the flag after emitting the trailing `\n`.
8734    vm.register_builtin(BUILTIN_XTRACE_IS_ON, |_vm, _argc| {
8735        // Push live xtrace state. Caller pairs this with JumpIfFalse
8736        // to skip the trace-string-building block when xtrace is off,
8737        // avoiding side-effectful operand re-evaluation. Bug #159 in
8738        // docs/BUGS.md.
8739        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
8740        Value::Int(if on { 1 } else { 0 })
8741    });
8742
8743    vm.register_builtin(BUILTIN_XTRACE_LINE, |vm, _argc| {
8744        // Keep the Value; `to_str()` allocates a String, and this handler
8745        // runs on EVERY `(( … ))` / `[[ … ]]` / loop-head evaluation, where
8746        // xtrace is off essentially always. Defer the allocation to the
8747        // `on` branch below.
8748        let cmd_val = vm.pop();
8749        // Sync exec.last_status with the live vm.last_status BEFORE
8750        // the next command runs. Direct port of the zsh exec.c
8751        // contract — `$?` reads the exit status of the *most recent*
8752        // command. XTRACE_LINE is emitted by the compiler BEFORE
8753        // every simple command, so it's the natural sync point.
8754        let live = vm.last_status;
8755        with_executor(|exec| {
8756            exec.set_last_status(live);
8757        });
8758        // C zsh emits xtrace for `(( … ))` / `[[ … ]]` / `case` /
8759        // `if/while/until/for/repeat` head expressions via
8760        // `printprompt4(); fprintf(xtrerr, "%s\n", expr)` at
8761        // Src/exec.c:5240 (math), c:5286 (cond), c:4117 (for), etc.
8762        // The compiler emits BUILTIN_XTRACE_LINE only at those
8763        // construct boundaries (compile_arith / compile_cond /
8764        // compile_if / compile_while / compile_for / compile_case);
8765        // simple commands route to BUILTIN_XTRACE_ARGS instead. So
8766        // this handler always emits when xtrace is on — no prefix-
8767        // string heuristic.
8768        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
8769        if on {
8770            let already = XTRACE_DONE_PS4.with(|f| f.get());
8771            if !already {
8772                printprompt4();
8773            }
8774            // c:exec.c:5240/5286 — `fprintf(xtrerr, "%s\n", expr)`. Buffer
8775            // the line + newline, flush once (single write).
8776            xtrerr_fputs(&cmd_val.to_str());
8777            xtrerr_fputs("\n");
8778            xtrerr_flush();
8779            XTRACE_DONE_PS4.with(|f| f.set(false));
8780        }
8781        Value::Status(0)
8782    });
8783
8784    // BUILTIN_XTRACE_ARRAY_LINE — xtrace line for an `arr=(...)` / `arr+=(...)`
8785    // assignment. Stack on entry: [array, prefix] (argc = 2); pops prefix
8786    // ("name=( " / "name+=( "), then the whole assembled Value::Array. Direct
8787    // port of c:Src/exec.c::addvars:2624-2632, guarded on the live xtrace
8788    // state like C's `if (xtr)`: prints `prefix qz(e0) qz(e1) … ) ` with each
8789    // element shell-quoted (quotedzputs). Replaces the former one-VM-slot-per-
8790    // element trace, which overflowed next_slot (u16) on large literals.
8791    vm.register_builtin(BUILTIN_XTRACE_ARRAY_LINE, |vm, _argc| {
8792        let prefix = vm.pop().to_str();
8793        let arr = vm.pop();
8794        let live = vm.last_status;
8795        with_executor(|exec| {
8796            exec.set_last_status(live);
8797        });
8798        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
8799        if on {
8800            let already = XTRACE_DONE_PS4.with(|f| f.get());
8801            if !already {
8802                printprompt4();
8803            }
8804            let mut line = String::with_capacity(prefix.len() + 16);
8805            line.push_str(&prefix);
8806            if let Value::Array(items) = arr {
8807                for it in items.iter() {
8808                    line.push_str(&crate::ported::utils::quotedzputs(&it.to_str()));
8809                    line.push(' ');
8810                }
8811            }
8812            line.push_str(") ");
8813            line.push('\n');
8814            xtrerr_fputs(&line);
8815            xtrerr_flush();
8816            XTRACE_DONE_PS4.with(|f| f.set(false));
8817        }
8818        Value::Status(0)
8819    });
8820
8821    // BUILTIN_MAKE_ARRAY_COUNTED — pop a count Int (top), then pop that many
8822    // values below it, and push them as one Value::Array (bottom-of-group
8823    // first). Same result as Op::MakeArray(N) but N comes from the stack as an
8824    // i64, dodging MakeArray's u16 operand cap. The compiler emits this only
8825    // when a literal `arr=(...)` has more than u16::MAX elements.
8826    vm.register_builtin(BUILTIN_MAKE_ARRAY_COUNTED, |vm, _argc| {
8827        let count = vm.pop().to_int().max(0) as usize;
8828        let mut items: Vec<Value> = Vec::with_capacity(count);
8829        for _ in 0..count {
8830            items.push(vm.pop());
8831        }
8832        items.reverse();
8833        Value::array(items)
8834    });
8835
8836    // BUILTIN_ARGV_RFLATTEN — recursively flatten a MakeArray-packed argv
8837    // bundle so a >255-arg Call/CallFunction/CallBuiltin (dispatched with
8838    // argc=1 over the single packed Array) recovers every positional arg. The
8839    // call ops flatten only one level; a brace/glob/`$arr` word contributes a
8840    // nested Array that would otherwise stringify. See the const doc.
8841    vm.register_builtin(BUILTIN_ARGV_RFLATTEN, |vm, _argc| {
8842        let v = vm.pop();
8843        let mut out: Vec<String> = Vec::new();
8844        flatten_array_value(v, &mut out);
8845        Value::array(out.into_iter().map(Value::str).collect())
8846    });
8847
8848    // Like XTRACE_LINE but reads the top `argc - 1` values from the
8849    // VM stack WITHOUT consuming them (peek), then pops a prefix
8850    // string at the top. Joins prefix + peeked args with spaces using
8851    // zsh's quotedzputs-equivalent quoting. Direct port of
8852    // Src/exec.c:2055-2066 — emit AFTER expansion, with each arg
8853    // shell-quoted, so `for i in a b; echo for $i` traces as
8854    // `echo for a` / `echo for b`, not `echo for $i`.
8855    //
8856    // Stack contract on entry: [arg1, arg2, ..., argN, prefix].
8857    // Pops prefix; peeks argN..arg1 below. argc = N + 1.
8858    vm.register_builtin(BUILTIN_XTRACE_ARGS, |vm, argc| {
8859        let prefix = vm.pop().to_str();
8860        let live = vm.last_status;
8861        with_executor(|exec| {
8862            exec.set_last_status(live);
8863        });
8864        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
8865        if on {
8866            let n_args = argc.saturating_sub(1) as usize;
8867            let len = vm.stack.len();
8868            // c:Src/exec.c:2055 — argv is the POST-expansion word
8869            // list, so an arg that expanded to multiple words splats
8870            // into multiple trace tokens AND an arg that expanded to
8871            // zero words (empty unquoted `${UNSET}`) emits nothing.
8872            // pop_args (line 6243) already does this splat for the
8873            // real handler; mirror the same Array → splat / empty →
8874            // drop logic here so xtrace renders `echo ${UNSET}` as
8875            // `echo` (zsh) instead of `echo ''` (the previous
8876            // single-arg stringify path returned "" and then
8877            // quotedzputs wrapped it in `''`).
8878            let arg_strs: Vec<String> = if n_args > 0 && len >= n_args {
8879                let mut out = Vec::new();
8880                for v in &vm.stack[len - n_args..] {
8881                    match v {
8882                        Value::Array(items) => {
8883                            for item in items.iter() {
8884                                out.push(quotedzputs(&item.to_str()));
8885                            }
8886                        }
8887                        other => out.push(quotedzputs(&other.to_str())),
8888                    }
8889                }
8890                out
8891            } else {
8892                Vec::new()
8893            };
8894            // Builtins dispatch through `execbuiltin` (Src/builtin.c:442)
8895            // which emits its own PS4 + name + args xtrace. To avoid
8896            // double-emission, skip our emission here when the first
8897            // arg is a known builtin with a registered HandlerFunc —
8898            // those go through execbuiltin and will trace themselves.
8899            // Externals + builtins-not-yet-routed-through-execbuiltin
8900            // keep our emission as a stand-in.
8901            let goes_through_execbuiltin = crate::ported::builtin::BUILTINS
8902                .iter()
8903                .any(|b| b.node.nam == prefix && b.handlerfunc.is_some());
8904            if !goes_through_execbuiltin {
8905                let line = if arg_strs.is_empty() {
8906                    prefix
8907                } else {
8908                    format!("{} {}", prefix, arg_strs.join(" "))
8909                };
8910                // Mirrors Src/exec.c:2055 xtrace emission. C does:
8911                //   if (!doneps4) printprompt4();
8912                //   ... emit args + spaces ...
8913                //   fputc('\n', xtrerr); fflush(xtrerr);
8914                // printprompt4 + the args + `\n` all land in the xtrerr
8915                // buffer; the single fflush below writes the whole line in
8916                // one syscall so concurrent pipeline stages never
8917                // interleave (c:makecline:2122-2123).
8918                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
8919                if !already_ps4 {
8920                    printprompt4();
8921                }
8922                xtrerr_fputs(&line);
8923                xtrerr_fputs("\n"); // c:2122 fputc('\n', xtrerr)
8924                xtrerr_flush(); // c:2123 fflush(xtrerr)
8925            }
8926            XTRACE_DONE_PS4.with(|f| f.set(false));
8927        }
8928        Value::Status(0)
8929    });
8930
8931    // BUILTIN_XTRACE_ASSIGN — direct port of the per-assignment
8932    // trace block at Src/exec.c:2517-2582. C body excerpt:
8933    //   xtr = isset(XTRACE);
8934    //   if (xtr) { printprompt4(); doneps4 = 1; }
8935    //   while (assign) {
8936    //       if (xtr) fprintf(xtrerr, "%s+=" or "%s=", name);
8937    //       ... eval value into `val` ...
8938    //       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
8939    //       ...
8940    //   }
8941    //
8942    // Stack on entry: [..., name, value]. PEEKS both (they're left
8943    // on stack for SET_VAR to pop). Emits `name=<quoted-val> ` with
8944    // no newline; trailing `\n` comes from XTRACE_ARGS (cmd path)
8945    // or XTRACE_NEWLINE (assignment-only path).
8946    vm.register_builtin(BUILTIN_XTRACE_ASSIGN, |vm, _argc| {
8947        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
8948        if on {
8949            // PEEK [..., name, value] — argc==2 by contract.
8950            let len = vm.stack.len();
8951            if len >= 2 {
8952                let name = vm.stack[len - 2].to_str();
8953                let value = vm.stack[len - 1].to_str();
8954                let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
8955                if !already_ps4 {
8956                    printprompt4();
8957                    XTRACE_DONE_PS4.with(|f| f.set(true));
8958                }
8959                // C: `fprintf(xtrerr, "%s=", name)` then `quotedzputs
8960                // (val); fputc(' ', xtrerr);`. Append to the xtrerr buffer
8961                // (no newline / no flush — the line continues with the
8962                // command via XTRACE_ARGS, or ends at XTRACE_NEWLINE).
8963                xtrerr_fputs(&format!("{}={} ", name, quotedzputs(&value)));
8964            }
8965        }
8966        Value::Status(0)
8967    });
8968
8969    // BUILTIN_XTRACE_NEWLINE — emit trailing `\n` + flush iff a
8970    // prior XTRACE_ASSIGN this line already emitted PS4. Mirrors
8971    // C's `fputc('\n', xtrerr); fflush(xtrerr);` at exec.c:3398
8972    // (the assignment-only path through execcmd_exec).
8973    vm.register_builtin(BUILTIN_XTRACE_NEWLINE, |_vm, _argc| {
8974        let on = crate::ported::zsh_h::isset(crate::ported::zsh_h::XTRACE);
8975        if on {
8976            let already_ps4 = XTRACE_DONE_PS4.with(|f| f.get());
8977            if already_ps4 {
8978                xtrerr_fputs("\n"); // c:3398 fputc('\n', xtrerr)
8979                xtrerr_flush(); // c:3398 fflush(xtrerr)
8980                XTRACE_DONE_PS4.with(|f| f.set(false));
8981            }
8982        }
8983        Value::Status(0)
8984    });
8985
8986    // c:Src/exec.c WC_TRYBLOCK — post-always re-jump probes. Each
8987    // returns 1 + consumes the atomic when the corresponding
8988    // escape flag is set; the try-block compile pairs each with
8989    // a JumpIfFalse + Jump → outer scope's return / break /
8990    // continue patches.
8991    vm.register_builtin(BUILTIN_RETFLAG_CHECK, |_vm, _argc| {
8992        use std::sync::atomic::Ordering;
8993        let r = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
8994        if r != 0 {
8995            // Don't clear here — doshfunc owns the clear at c:6047
8996            // when the function unwinds. Leaving it set propagates
8997            // through nested `eval`/`source` callers correctly.
8998            Value::Int(1)
8999        } else {
9000            Value::Int(0)
9001        }
9002    });
9003    vm.register_builtin(BUILTIN_BREAKS_CHECK, |_vm, _argc| {
9004        use std::sync::atomic::Ordering;
9005        let b = crate::ported::builtin::BREAKS.load(Ordering::Relaxed);
9006        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
9007        // `break` sets BREAKS but NOT CONTFLAG; `continue` sets both.
9008        // Filter out the continue path here so the two checks are
9009        // mutually exclusive.
9010        if b != 0 && c == 0 {
9011            // Consume BREAKS so the outer loop's break_patches
9012            // landing doesn't double-decrement.
9013            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
9014            Value::Int(1)
9015        } else {
9016            Value::Int(0)
9017        }
9018    });
9019    vm.register_builtin(BUILTIN_CONTFLAG_CHECK, |_vm, _argc| {
9020        use std::sync::atomic::Ordering;
9021        let c = crate::ported::builtin::CONTFLAG.load(Ordering::Relaxed);
9022        if c != 0 {
9023            crate::ported::builtin::CONTFLAG.store(0, Ordering::Relaxed);
9024            crate::ported::builtin::BREAKS.store(0, Ordering::Relaxed);
9025            Value::Int(1)
9026        } else {
9027            Value::Int(0)
9028        }
9029    });
9030    // c:Src/loop.c — `loops++` / `loops--` bracket every iterative
9031    // construct: execfor c:114/188, execwhile c:427/491, execrepeat
9032    // c:523/546. `loops` is a GLOBAL, not a per-frame counter, and
9033    // `bin_break` reads it (`if (!loops)`) to decide whether `break` /
9034    // `continue` is legal. Because doshfunc does NOT reset it (only
9035    // restores it under LOCAL_LOOPS, c:6104-6112), a function called
9036    // from inside a loop sees the CALLER's count and its `break` ends
9037    // the caller's loop. zshrs's compiled for/while/until/repeat lower
9038    // to raw jumps, so without these two ops the counter stayed 0 and
9039    // every such `break` errored out instead.
9040    vm.register_builtin(BUILTIN_LOOP_ENTER, |_vm, _argc| {
9041        crate::ported::builtin::LOOPS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); // c:114
9042        Value::Int(0)
9043    });
9044    // c:Src/loop.c:141-145 + :199-203 (execfor), :478-481 (execwhile),
9045    // :534-537 (execrepeat) — every loop that abandons its body because
9046    // `errflag` is set FORCES the escaping status first:
9047    //     if (errflag) { if (breaks) breaks--; lastval = 1; break; }
9048    // so a fatal error inside a loop leaves 1, not whatever the failing
9049    // command set. `setopt extendedglob; for i in 1 2; do [[ abc == [ ]]; done`
9050    // exits 1 in zsh while the bare `[[ abc == [ ]]` exits 2; zshrs's compiled
9051    // loops jumped straight to the chunk-end landing and carried the cond's 2
9052    // out. `execselect` has no such assignment (c:217+), which is why
9053    // `compile_select` does not bump `open_loop_depth` and never emits this.
9054    vm.register_builtin(BUILTIN_LOOP_ERRFLAG_STATUS, |vm, _argc| {
9055        // The `if (errflag)` half of the C guard is re-tested HERE, not at
9056        // compile time: the same abort edge also carries an ERREXIT
9057        // (`set -e`) exit, which in C leaves execlist via `zexit(lastval)`
9058        // and never reaches the loop's `if (errflag)` arm — so that status
9059        // must survive untouched.
9060        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
9061            & crate::ported::zsh_h::ERRFLAG_ERROR)
9062            != 0
9063        {
9064            vm.last_status = 1; // c:144/201/480/536
9065            with_executor(|exec| exec.set_last_status(1)); // c:144/201/480/536
9066        }
9067        Value::Int(0)
9068    });
9069    vm.register_builtin(BUILTIN_LOOP_EXIT, |_vm, _argc| {
9070        use std::sync::atomic::Ordering::SeqCst;
9071        // Saturating: a chunk aborted mid-loop (errflag, `return`)
9072        // unwinds through `run_chunk`'s restore rather than this op, so
9073        // never let a stray decrement drive the count negative.
9074        let _ = crate::ported::builtin::LOOPS
9075            .fetch_update(SeqCst, SeqCst, |n| Some(if n > 0 { n - 1 } else { 0 })); // c:188
9076        Value::Int(0)
9077    });
9078
9079    // c:Src/loop.c:529-534 (execwhile), :180-185 (execfor), :540-545
9080    // (execrepeat) — the identical post-body drain every loop runs:
9081    //     if (breaks) {
9082    //         breaks--;
9083    //         if (breaks || !contflag) break;
9084    //         contflag = 0;
9085    //     }
9086    // Returns Int(1) when this loop must terminate, Int(0) when it
9087    // should proceed to the next iteration. Only a `break`/`continue`
9088    // executed in a DIFFERENT chunk (a called function, `eval`, a
9089    // sourced file) reaches here — an in-chunk `break` compiles to a
9090    // direct jump and never touches the counter.
9091    vm.register_builtin(BUILTIN_LOOP_BREAK_DRAIN, |_vm, _argc| {
9092        use std::sync::atomic::Ordering::SeqCst;
9093        let breaks = crate::ported::builtin::BREAKS.load(SeqCst);
9094        if breaks == 0 {
9095            return Value::Int(0);
9096        }
9097        let remaining = breaks - 1;
9098        crate::ported::builtin::BREAKS.store(remaining, SeqCst); // c:530
9099        let contflag = crate::ported::builtin::CONTFLAG.load(SeqCst);
9100        if remaining != 0 || contflag == 0 {
9101            return Value::Int(1); // c:532 — `break`
9102        }
9103        crate::ported::builtin::CONTFLAG.store(0, SeqCst); // c:533
9104        Value::Int(0)
9105    });
9106
9107    // c:Src/exec.c:1370 execlist — `while (wc_code(code) == WC_LIST &&
9108    // !breaks && !retflag && !errflag)`. A pending `breaks` stops the
9109    // CURRENT list at the next statement boundary WITHOUT consuming it,
9110    // so the flag keeps travelling outward until a loop's drain eats it.
9111    // Non-consuming by design: the drain above is the only consumer.
9112    vm.register_builtin(BUILTIN_BREAKS_PENDING, |_vm, _argc| {
9113        let b = crate::ported::builtin::BREAKS.load(std::sync::atomic::Ordering::SeqCst);
9114        Value::Int(if b != 0 { 1 } else { 0 })
9115    });
9116
9117    vm.register_builtin(BUILTIN_NOEXEC_CHECK, |_vm, _argc| {
9118        // c:Src/exec.c:1390 — `set -n` / `noexec` option: parse but
9119        // don't execute. Returns Int(1) when noexec is set so the
9120        // emit-side JumpIfTrue skips the statement body.
9121        if opt_state_get("noexec").unwrap_or(false) {
9122            return Value::Int(1);
9123        }
9124        // c:Src/exec.c:1390 — execlist's list-loop gate:
9125        //   `while (wc_code(code) == WC_LIST && !breaks && !retflag
9126        //          && !errflag)`
9127        // — once errflag is set, the NEXT sublist never starts, so
9128        // lastval survives untouched to the shell exit. Without this
9129        // prologue gate the follow-up statement RAN, its dispatch
9130        // saw errflag, returned 1, and SetStatus clobbered lastval —
9131        // `[[ x == [a- ]]; print rc=$?` exited 1 instead of zsh's 2
9132        // (the cond syntax error set lastval=2 per exec.c:5216-5221).
9133        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed)
9134            & crate::ported::zsh_h::ERRFLAG_ERROR)
9135            != 0
9136        {
9137            return Value::Int(1);
9138        }
9139        Value::Int(0)
9140    });
9141    // c:Src/exec.c:1536-1538 —
9142    //     /* suppress errexit for commands before && and || and after ! */
9143    //     if (isandor || isnot)
9144    //         noerrexit |= NOERREXIT_EXIT | NOERREXIT_RETURN;
9145    // The bits live on the PROCESS-GLOBAL `noerrexit`, so they are still
9146    // in force inside a shell function called from that position (doshfunc
9147    // clears only NOERREXIT_RETURN, c:5930). zshrs suppressed the check
9148    // purely at COMPILE time (`errexit_suppress_depth`), which cannot
9149    // reach a separately-compiled function body — so
9150    //   TRAPZERR(){ print E }; f(){ print f; false; }; f && t
9151    // fired the ZERR trap from inside `f` where zsh stays silent
9152    // (C03traps:14, E01options:18,19,21).
9153    vm.register_builtin(BUILTIN_NOERREXIT_SUPPRESS, |_vm, _argc| {
9154        use std::sync::atomic::Ordering;
9155        let old = crate::ported::exec::noerrexit.load(Ordering::Relaxed); // c:1417
9156        NOERREXIT_SAVES.with(|st| st.borrow_mut().push(old));
9157        crate::ported::exec::noerrexit.store(
9158            old | crate::ported::zsh_h::NOERREXIT_EXIT | crate::ported::zsh_h::NOERREXIT_RETURN,
9159            Ordering::Relaxed,
9160        ); // c:1538
9161        Value::Int(0)
9162    });
9163    // c:Src/exec.c:1621 / c:1626 — `noerrexit = oldnoerrexit;`
9164    vm.register_builtin(BUILTIN_NOERREXIT_RESTORE, |_vm, _argc| {
9165        use std::sync::atomic::Ordering;
9166        if let Some(old) = NOERREXIT_SAVES.with(|st| st.borrow_mut().pop()) {
9167            crate::ported::exec::noerrexit.store(old, Ordering::Relaxed); // c:1621
9168        }
9169        Value::Int(0)
9170    });
9171    vm.register_builtin(BUILTIN_DONETRAP_RESET, |_vm, _argc| {
9172        // c:Src/exec.c:1455 — `donetrap = 0;` at sublist start.
9173        // Reset before each top-level statement so the next
9174        // sublist's ERREXIT_CHECK fires the ZERR trap on its FIRST
9175        // non-zero command. Carries the "already fired" state
9176        // across function-call returns within the SAME outer
9177        // sublist (per C semantics — donetrap is process-global).
9178        // Bug #303 in docs/BUGS.md.
9179        crate::ported::exec::DONETRAP.store(0, std::sync::atomic::Ordering::Relaxed);
9180        // `${~spec}` carrier: C's `globsubst` is a paramsubst-LOCAL
9181        // int (c:Src/subst.c:1671 `int globsubst = isset(GLOBSUBST);`,
9182        // set to 2 by `${~}` at c:2597-2603) whose only effect is the
9183        // `shtokenize()` of THAT substitution's own result
9184        // (c:4419-4420). It can therefore never be observed by a later
9185        // statement. zshrs carries the flag on the global option table
9186        // (subst.rs:5125-5136) so the compile-emitted glob ops in the
9187        // same word pipeline can see it, and restores it at
9188        // command-dispatch boundaries — but a `${~}` sitting in a word
9189        // that dispatches NO command (a `for`/`select` word list, a
9190        // loop/`case` header) had no such boundary before the NEXT
9191        // statement's words were expanded, so GLOB_SUBST leaked into
9192        // them. This op is emitted exactly once per sublist, in
9193        // compile_list's prologue (compile_zsh.rs:557) — i.e. BEFORE
9194        // the sublist's words expand — which is the same "state is
9195        // gone by the next statement" guarantee C gets for free.
9196        // Without it, `_parameters`' `for i in ${…:#${~pfilt}*}` loop
9197        // globbed its `ary+=($i:"$val")` body word and died with
9198        // "bad pattern: HISTCHARS:!^#", killing `-<TAB>` completion.
9199        consume_tilde_globsubst_carrier();
9200        Value::Status(0)
9201    });
9202
9203    vm.register_builtin(BUILTIN_SUBLIST_FINISH, |vm, _argc| {
9204        // c:Src/jobs.c:1754 — `pipestats[0] = lastval;`. C has ONE
9205        // `lastval` global (c:Src/exec.c:120), so `waitonejob` reads
9206        // exactly the status the finished sublist just produced.
9207        //
9208        // zshrs splits that global in two: the fusevm status cell that
9209        // `Op::SetStatus`/`Op::GetStatus` and therefore `$?` use, and
9210        // the `builtin::LASTVAL` mirror that the ported `waitonejob`
9211        // reads. The compound-command compilers (compile_if,
9212        // compile_while, compile_for, compile_case, …) settle their
9213        // result with `Op::SetStatus` alone, so LASTVAL still holds
9214        // whatever the last dispatched BUILTIN returned — the loop
9215        // condition, typically. `if [[ -z x ]]; then :; fi` and
9216        // `while false; do :; done` both end with `$? == 0` and a
9217        // stale LASTVAL of 1.
9218        //
9219        // compile_sublist pushes `Op::GetStatus` ahead of this call so
9220        // the authoritative status arrives as an argument; republish it
9221        // through LASTVAL to reunify the two before the ported
9222        // waitonejob reads it, exactly as the single-command dispatch
9223        // sites at c:Src/exec.c:4367 do.
9224        let status = vm.pop().to_int() as i32;
9225        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
9226        // c:Src/jobs.c:1750-1756 — `compile_sublist` only emits this
9227        // marker for a cmplx sublist element that is not a multi-stage
9228        // pipeline, i.e. exactly the case where C's job carries no
9229        // procs, so drive the canonical port with a procs-less job the
9230        // same way the single-command sites do.
9231        let mut synth = crate::ported::zsh_h::job::default();
9232        crate::ported::jobs::waitonejob(&mut synth);
9233        Value::Status(0)
9234    });
9235
9236    // `[[ -z X ]]` / `[[ -n X ]]` — pop one Value, route through
9237    // canonical `src/ported/cond.rs::evalcond` so the actual
9238    // empty/non-empty test reuses the C-port at `cond.rs:270-271`
9239    // (`'n' => !arg.is_empty()`, `'z' => arg.is_empty()`).
9240    //
9241    // The Array→args conversion lives at the bridge because cond.rs
9242    // expects `&[&str]` (C `cond_str` signature equivalent). For
9243    // `"${arr[@]}"` in DQ context the splice yields `Value::Array`
9244    // — an empty array still expands to one implicit empty word
9245    // (per zsh's "${arr[@]}" splat preserving at least one slot
9246    // in cond context), so:
9247    //   - Array(0)   → ["-z", ""]            → evalcond → 0 (true)
9248    //   - Array(1)   → ["-z", word]          → evalcond → 0/1
9249    //   - Array(2+)  → ["-z", w1, w2, ...]   → evalcond → 2 (parse
9250    //                                          error: too many ops)
9251    //                                          → coerced to false
9252    //   - Str(s)     → ["-z", s]             → evalcond → 0/1
9253    //
9254    // Bug #185 in docs/BUGS.md.
9255    fn run_cond_str_empty(v: Value, op: &str) -> Value {
9256        let words: Vec<String> = match v {
9257            Value::Array(arr) => arr.iter().map(|x| x.to_str()).collect(),
9258            Value::Str(s) => vec![s.to_string()],
9259            other => vec![other.to_str()],
9260        };
9261        let mut args: Vec<&str> = vec![op];
9262        if words.is_empty() {
9263            args.push("");
9264        } else {
9265            args.extend(words.iter().map(|s| s.as_str()));
9266        }
9267        let opts: std::collections::HashMap<String, bool> = std::collections::HashMap::new();
9268        let vars: std::collections::HashMap<String, String> = std::collections::HashMap::new();
9269        // c:Src/cond.c:62-66 — `evalcond` returns 0=true, 1=false,
9270        // 2=syntax-error. Coerce error to false (observable behavior
9271        // in zsh: `[[ -z a b ]]` errors and the test as a whole
9272        // returns non-zero).
9273        // `[[ ]]` dispatch — C's `evalcond(state, NULL)` calling convention.
9274        // `None` for from_test → mathevali integer-compare coercion path.
9275        let ret = crate::ported::cond::evalcond(&args, &opts, &vars, false, None);
9276        Value::Int(if ret == 0 { 1 } else { 0 })
9277    }
9278    vm.register_builtin(BUILTIN_COND_STR_EMPTY, |vm, _argc| {
9279        let v = vm.pop();
9280        run_cond_str_empty(v, "-z")
9281    });
9282    vm.register_builtin(BUILTIN_COND_STR_NONEMPTY, |vm, _argc| {
9283        let v = vm.pop();
9284        run_cond_str_empty(v, "-n")
9285    });
9286
9287    // `exec N<<<"str"` — herestring redirect to explicit fd, applied
9288    // permanently. Direct port of `Src/exec.c:4655 getherestr` +
9289    // `addfd(forked, save, mfds, fn->fd1, fil, 0, ...)` at c:3766-
9290    // 3780 for the nullexec=1 bare-exec-redir path. Bug #205 in
9291    // docs/BUGS.md.
9292    // c:Src/exec.c:4671-4672 — a here-string DERIVED FROM a here-doc
9293    // gets no appended newline. See BUILTIN_HEREDOC_BODY_SINK.
9294    vm.register_builtin(BUILTIN_HEREDOC_BODY_SINK, |vm, _argc| {
9295        let body = vm.pop().to_str();
9296        if crate::provenance::active() {
9297            crate::provenance::on_heredoc("heredoc", &body);
9298        }
9299        with_executor(|exec| exec.host_set_pending_stdin(body));
9300        Value::Int(0)
9301    });
9302
9303    vm.register_builtin(BUILTIN_EXEC_HERESTR_FD, |vm, _argc| {
9304        // Stack (pushed by compile_redir): [content, fd, from_heredoc].
9305        let from_heredoc = vm.pop().to_int() != 0;
9306        let fd = vm.pop().to_int() as i32;
9307        let content = vm.pop().to_str();
9308        // c:Src/exec.c:4671-4672 — `getherestr` appends the newline
9309        // only for a REAL here-string:
9310        //     if (!(fn->flags & REDIRF_FROM_HEREDOC))
9311        //         t[len++] = '\n';
9312        // The `REDIRF_FROM_HEREDOC` flag is set by
9313        // c:Src/parse.c:2970-2971 when the redirection was written as
9314        // `<<WORD` and the parser turned the collected body into a
9315        // here-string. This helper serves BOTH spellings — `exec
9316        // 3<<<str` (append) and `exec 3<<EOF` (verbatim) — so the flag
9317        // has to be threaded in rather than assumed. It used to
9318        // unconditionally append, and the here-doc call site
9319        // compensated with `trim_end_matches('\n')`, which collapsed
9320        // N trailing blank lines to one and added a newline to a body
9321        // that ended without one.
9322        let body = if from_heredoc {
9323            content
9324        } else {
9325            format!("{}\n", content)
9326        };
9327        // c:Src/exec.c:3779 `addfd(forked, save, mfds, fn->fd1, fil, 0,
9328        // NULL)` → c:2421-2443: park the OLD contents of fd1 so
9329        // `fixfds` (c:4530) can put them back at the end of the
9330        // command; a bare `exec` (nullexec==1, c:3978-3986) skips it
9331        // and the redirection persists. This runs BEFORE the temp
9332        // file is opened on purpose: C reads fd1's pre-redirect state
9333        // too, and taking it afterwards would dup the here-document
9334        // itself and "restore" fd N to the body at scope end.
9335        with_executor(|exec| exec.save_fd_for_scope(fd));
9336        // c:4673-4679 — gettempfile → write_loop → close → reopen
9337        // read-only → unlink. Rust equivalent via tempfile crate or
9338        // explicit O_TMPFILE; use mkstemp + unlink-immediately to
9339        // mirror C exactly.
9340        use std::ffi::CString;
9341        let mut tmpl: Vec<u8> = b"/tmp/zshrs_hs_XXXXXX\0".to_vec();
9342        let write_fd = unsafe { libc::mkstemp(tmpl.as_mut_ptr() as *mut libc::c_char) };
9343        if write_fd < 0 {
9344            crate::ported::utils::zwarn(&format!(
9345                "can't create temp file for here document: {}",
9346                std::io::Error::last_os_error()
9347            ));
9348            return Value::Status(1);
9349        }
9350        // c:4675 — write_loop(fd, t, len)
9351        let bytes = body.as_bytes();
9352        let mut off = 0;
9353        while off < bytes.len() {
9354            let n = unsafe {
9355                libc::write(
9356                    write_fd,
9357                    bytes[off..].as_ptr() as *const libc::c_void,
9358                    bytes.len() - off,
9359                )
9360            };
9361            if n <= 0 {
9362                unsafe { libc::close(write_fd) };
9363                return Value::Status(1);
9364            }
9365            off += n as usize;
9366        }
9367        unsafe { libc::close(write_fd) }; // c:4676
9368                                          // Path null-terminated by mkstemp; reopen for reading.
9369        let read_fd = unsafe { libc::open(tmpl.as_ptr() as *const libc::c_char, libc::O_RDONLY) };
9370        // c:4678 — unlink immediately so the file disappears on
9371        // close, leaving only the fd reference.
9372        unsafe { libc::unlink(tmpl.as_ptr() as *const libc::c_char) };
9373        if read_fd < 0 {
9374            return Value::Status(1);
9375        }
9376        // c:Src/utils.c:2047-2065 `redup(x, y)` — the `zclose(x)` lives
9377        // INSIDE the `else if (x != y)` arm:
9378        //     if(x < 0)        zclose(y);
9379        //     else if (x != y) { dup2(x, y); …; zclose(x); }
9380        // When x == y, redup is a NO-OP. This helper closed `read_fd`
9381        // unconditionally, and `read_fd == fd` is the COMMON case for
9382        // `exec 3<<…`: mkstemp takes the lowest free fd (3), closes it
9383        // at c:4676, then `open` reclaims the same 3, so `dup2(3, 3)`
9384        // returned 3 and the very next `close(3)` threw the redirect
9385        // away. Every `exec N<<…` / `exec N<<<…` left N closed, and
9386        // `cat <&N` reported "N: bad file descriptor".
9387        if read_fd != fd {
9388            let r = unsafe { libc::dup2(read_fd, fd) };
9389            unsafe { libc::close(read_fd) };
9390            if r < 0 {
9391                return Value::Status(1);
9392            }
9393        }
9394        Value::Status(0)
9395    });
9396    // c:Src/exec.c:2418 + addfd splice — MULTIOS fan-out. Stack
9397    // layout pushed by compile_zsh's coalescing pass:
9398    //   [target_1, op_byte_1, target_2, op_byte_2, …, target_N,
9399    //    op_byte_N, fd]
9400    // argc = 2N + 1. Pops, opens every target, sets up a pipe +
9401    // splitter thread that reads pipe → writes every chunk to
9402    // every opened target, dup2's pipe-write-end onto fd. The
9403    // splitter is closed + joined by host_redirect_scope_end.
9404    // Bug #36 in docs/BUGS.md.
9405    vm.register_builtin(BUILTIN_MULTIOS_REDIRECT, |vm, argc| {
9406        if argc < 3 || argc % 2 == 0 {
9407            // Bad shape — bail.
9408            return Value::Status(1);
9409        }
9410        // Pop fd first (top of stack).
9411        let fd = vm.pop().to_int() as i32;
9412        // Then pop (op, target) pairs in reverse compile order. Keep
9413        // targets as Values — a glob-bearing target arrives as a
9414        // Value::Array of matches.
9415        let n_targets = ((argc - 1) / 2) as usize;
9416        let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_targets);
9417        for _ in 0..n_targets {
9418            let op_byte = vm.pop().to_int() as u8;
9419            let target = vm.pop();
9420            pairs.push((op_byte, target));
9421        }
9422        // Restore compile order (target_1 first).
9423        pairs.reverse();
9424
9425        // c:Src/glob.c:2195-2203 xpandredir — "Loop over matches,
9426        // duplicating the redirection for each file found": a glob
9427        // target with N matches becomes N members of the same multio
9428        // (`echo hi > *.txt` with two matches writes both files).
9429        let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
9430        for (op_byte, target) in pairs {
9431            match target {
9432                Value::Array(items) => {
9433                    for item in items.iter() {
9434                        entries.push((op_byte, item.to_str()));
9435                    }
9436                }
9437                other => entries.push((op_byte, other.to_str())),
9438            }
9439        }
9440        if entries.is_empty() {
9441            return Value::Status(1);
9442        }
9443
9444        // c:Src/exec.c:2418 — `else if (!mfds[fd1] || unset(MULTIOS))`:
9445        // with MULTIOS unset every redirect takes the REPLACE path in
9446        // script order — each target is still opened (created /
9447        // truncated) and dup2'd over the fd, so the LAST one wins and
9448        // earlier files end up empty (`unsetopt multios; print x > a
9449        // > b` leaves `a` empty, `x` in `b`). host_apply_redirect is
9450        // exactly one replace step, noclobber gate included.
9451        let multios_on = opt_state_get("multios").unwrap_or(true);
9452        if !multios_on {
9453            with_executor(|exec| {
9454                for (op_byte, target) in &entries {
9455                    exec.host_apply_redirect(fd as u8, *op_byte, target);
9456                    if exec.redirect_failed {
9457                        // c:Src/exec.c execerr — abort the remaining
9458                        // redirect list on failure.
9459                        break;
9460                    }
9461                }
9462            });
9463            return Value::Status(0);
9464        }
9465
9466        if entries.len() == 1 {
9467            // Single member after splicing — a plain replace
9468            // (c:2418 new-multio arm). Route through
9469            // host_apply_redirect so the noclobber gate, the
9470            // pipeline-output split partial, and error handling all
9471            // apply exactly as for an un-bagged redirect.
9472            let (op_byte, target) = &entries[0];
9473            with_executor(|exec| {
9474                exec.host_apply_redirect(fd as u8, *op_byte, target);
9475            });
9476            return Value::Status(0);
9477        }
9478
9479        // c:Src/exec.c:3722-3724 — when this command's stdout IS the
9480        // pipeline output, C seeds mfds[1] with the pipe BEFORE
9481        // walking the redirect list, so the pipe is the multio's
9482        // first member (`print x >&1 > f | cat` sends `x` down the
9483        // pipe TWICE: once for the seed, once for the `>&1` dup).
9484        let pipe_seed = fd == 1
9485            && with_executor(|exec| {
9486                exec.pipe_output_scope
9487                    .is_some_and(|d| d + 1 == exec.redirect_scope_stack.len())
9488            });
9489
9490        // Save current fd state for scope-end restoration — BEFORE
9491        // the first member's replace dup2 below.
9492        // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
9493        // descriptor is shell state and must live above the script's fd range:
9494        // plain dup() returns the LOWEST free fd, which parked the saved stdout
9495        // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
9496        // saved descriptor and reported success where zsh says `bad file number`.
9497        // F_DUPFD with a floor of 10 is exactly what movefd does.
9498        let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
9499        if saved >= 0 {
9500            with_executor(|exec| {
9501                if let Some(top) = exec.redirect_scope_stack.last_mut() {
9502                    top.push((fd, saved));
9503                } else {
9504                    unsafe { libc::close(saved) };
9505                }
9506            });
9507        }
9508
9509        // Accumulate member fds in redirect order. c:Src/exec.c:
9510        // 2447-2480 addfd — the FIRST member REPLACES the fd
9511        // (c:2448-2450 `mfds[fd1]->ct=1; mfds[fd1]->fds[0]=fd1;`), so
9512        // a later numeric `>&N` self-dup resolves against the fd's
9513        // value at that point in the sequence: `print x > f >&1`
9514        // writes f TWICE; `print x >&1 > f` writes the ORIGINAL
9515        // stdout + f.
9516        let mut target_fds: Vec<i32> = Vec::with_capacity(entries.len() + 1);
9517        if pipe_seed {
9518            let p = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
9519            if p >= 0 {
9520                target_fds.push(p);
9521            }
9522        }
9523        let noclobber = opt_state_get("noclobber").unwrap_or(false)
9524            || !opt_state_get("clobber").unwrap_or(true);
9525        for (i, (op_byte, target)) in entries.iter().enumerate() {
9526            let open_result: std::io::Result<i32> = match *op_byte {
9527                r::DUP_WRITE | r::DUP_READ => {
9528                    // Numeric `>&N` — dup the LIVE fd N (after any
9529                    // earlier member's replace).
9530                    match target.trim_start_matches('&').parse::<i32>() {
9531                        Ok(src) => {
9532                            let d = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
9533                            if d >= 0 {
9534                                Ok(d)
9535                            } else {
9536                                Err(std::io::Error::last_os_error())
9537                            }
9538                        }
9539                        Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
9540                    }
9541                }
9542                r::WRITE => {
9543                    // c:Src/exec.c clobber_open — noclobber applies
9544                    // to multio file targets too; failure aborts the
9545                    // remaining redirect list (execerr), so `setopt
9546                    // noclobber; touch a; print x > a > b` errors on
9547                    // `a` and never creates `b`.
9548                    let target_meta = std::fs::metadata(target).ok();
9549                    let target_is_regular_file = target_meta
9550                        .as_ref()
9551                        .map(|m| m.file_type().is_file())
9552                        .unwrap_or(false);
9553                    // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY re-uses
9554                    // an empty regular file under noclobber (same allowance
9555                    // as the single-redirect path).
9556                    let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
9557                        && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
9558                    if noclobber && target_is_regular_file && !clobber_empty_ok {
9559                        eprintln!(
9560                            "{}:{}: file exists: {}",
9561                            shname(),
9562                            crate::ported::lex::lineno(),
9563                            target
9564                        );
9565                        for prev in &target_fds {
9566                            unsafe {
9567                                libc::close(*prev);
9568                            }
9569                        }
9570                        with_executor(|exec| {
9571                            exec.redirect_failed = true;
9572                        });
9573                        // Sink the upcoming command's output (mirrors
9574                        // the single-redirect noclobber arm in
9575                        // host_apply_redirect).
9576                        if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
9577                            let new_fd = file.into_raw_fd();
9578                            unsafe {
9579                                libc::dup2(new_fd, fd);
9580                                libc::close(new_fd);
9581                            }
9582                        }
9583                        return Value::Status(1);
9584                    }
9585                    fs::OpenOptions::new()
9586                        .write(true)
9587                        .create(true)
9588                        .truncate(true)
9589                        .open(target)
9590                        .map(|f| f.into_raw_fd())
9591                }
9592                r::APPEND => fs::OpenOptions::new()
9593                    .write(true)
9594                    .create(true)
9595                    .append(true)
9596                    .open(target)
9597                    .map(|f| f.into_raw_fd()),
9598                _ => fs::OpenOptions::new()
9599                    .write(true)
9600                    .create(true)
9601                    .truncate(true)
9602                    .open(target)
9603                    .map(|f| f.into_raw_fd()),
9604            };
9605            match open_result {
9606                Ok(tfd) => {
9607                    if i == 0 && !pipe_seed {
9608                        // c:2448-2450 — first member replaces the fd.
9609                        unsafe {
9610                            libc::dup2(tfd, fd);
9611                        }
9612                    }
9613                    target_fds.push(tfd);
9614                }
9615                Err(e) => {
9616                    // c:Src/exec.c:3741 — `zwarn("%e: %s", errno, fname)`:
9617                    // zwarning supplies the `name:LINE:` prefix with the
9618                    // REAL current lineno; redir_errno_msg builds the `%e`
9619                    // errno message (was a hardcoded ErrorKind match that
9620                    // showed generic "redirect failed" for EROFS/etc.).
9621                    let msg = redir_errno_msg(&e);
9622                    crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
9623                    // Close already-opened fds to avoid leaks.
9624                    for prev in &target_fds {
9625                        unsafe {
9626                            libc::close(*prev);
9627                        }
9628                    }
9629                    with_executor(|exec| {
9630                        exec.redirect_failed = true;
9631                    });
9632                    return Value::Status(1);
9633                }
9634            }
9635        }
9636
9637        // Create the splitter pipe.
9638        let (read_end, write_end) = match os_pipe::pipe() {
9639            Ok(p) => p,
9640            Err(_) => {
9641                for f in &target_fds {
9642                    unsafe {
9643                        libc::close(*f);
9644                    }
9645                }
9646                return Value::Status(1);
9647            }
9648        };
9649        // c:Src/exec.c:5222 — `pp[0] = movefd(pp[0]);` in `mpipe()`.
9650        // c:Src/utils.c:1990-2012 movefd — "if(fd != -1 && fd < 10)"
9651        // dup into the >=10 range and zclose the low copy, then mark
9652        // the result FDT_INTERNAL. Every shell-internal fd goes
9653        // through this so it can never share a number with the
9654        // user-visible `>&N` range that the redirect bookkeeping
9655        // opens/dups/closes. This splitter kept the raw (low) pipe
9656        // read end, so an unrelated close of that number shut it
9657        // under the splitter thread and dropping the owned
9658        // PipeReader aborted the process with std's "IO Safety
9659        // violation: owned file descriptor already closed"
9660        // (E01options.ztst:46 `( echo hello ) >a >b`, ~2 runs in 3).
9661        let read_end = unsafe {
9662            <os_pipe::PipeReader as std::os::unix::io::FromRawFd>::from_raw_fd(
9663                crate::extensions::fds::movefd(read_end.into_raw_fd()),
9664            )
9665        };
9666        let pipe_write_raw = AsRawFd::as_raw_fd(&write_end);
9667        // Spawn the splitter thread: read pipe → write every chunk
9668        // to every target fd. Each write inside the thread uses
9669        // libc::write directly on the raw fd (no Rust File ownership
9670        // so the splitter can close after EOF without racing main).
9671        let target_fds_for_thread = target_fds.clone();
9672        let handle = std::thread::spawn(move || {
9673            let mut r = read_end;
9674            let mut buf = [0u8; 8192];
9675            loop {
9676                match std::io::Read::read(&mut r, &mut buf) {
9677                    Ok(0) => break,
9678                    Ok(n) => {
9679                        for &tfd in &target_fds_for_thread {
9680                            let mut off = 0;
9681                            while off < n {
9682                                let w = unsafe {
9683                                    libc::write(
9684                                        tfd,
9685                                        buf[off..n].as_ptr() as *const libc::c_void,
9686                                        n - off,
9687                                    )
9688                                };
9689                                if w <= 0 {
9690                                    break;
9691                                }
9692                                off += w as usize;
9693                            }
9694                        }
9695                    }
9696                    Err(_) => break,
9697                }
9698            }
9699            // Close every target so file contents flush.
9700            for tfd in target_fds_for_thread {
9701                unsafe {
9702                    libc::close(tfd);
9703                }
9704            }
9705        });
9706
9707        // Dup the pipe write-end onto the target fd; close the
9708        // original write_end so EOF arrives when host_redirect_scope_end
9709        // closes our tracked pipe_write_fd.
9710        let write_dup = unsafe { libc::fcntl(pipe_write_raw, libc::F_DUPFD, 10) };
9711        drop(write_end);
9712        if write_dup < 0 {
9713            return Value::Status(1);
9714        }
9715        unsafe {
9716            libc::dup2(write_dup, fd);
9717            libc::close(write_dup);
9718        }
9719        // Track the running splitter so scope-end can drain + join.
9720        // The "write_fd" we store is the user-visible fd (e.g. 1).
9721        // Closing that fd at scope-end isn't quite right; we need a
9722        // way to send EOF. Solution: track the write_dup we just
9723        // closed; instead keep a second dup for the close-on-end.
9724        // Shell-internal bookkeeping fd — above the script's range (movefd).
9725        let close_on_end = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
9726        with_executor(|exec| {
9727            if let Some(top) = exec.multios_scope_stack.last_mut() {
9728                top.push((close_on_end, handle));
9729            } else {
9730                // No scope — leak the dup; thread will keep running
9731                // until process exit. Should not happen because
9732                // host_redirect_scope_begin pushed a frame.
9733                unsafe { libc::close(close_on_end) };
9734            }
9735        });
9736        Value::Status(0)
9737    });
9738    // c:Src/exec.c:2418 input-arm — MULTIOS read fan-in. Stack
9739    // layout pushed by compile_zsh (mirrors the write side):
9740    //   [source_1, op_1, source_2, op_2, …, source_N, op_N, fd]
9741    // argc = 2N + 1; op distinguishes file opens (READ) from numeric
9742    // `<&N` dups (DUP_READ); a glob source arrives as Value::Array
9743    // and splices into one member per match (c:Src/glob.c:2195-2203).
9744    // Opens every source, sets up a pipe + producer thread that
9745    // reads each source in order and writes to the pipe write-end,
9746    // then closes its write-end so the consumer gets EOF. dup2 the
9747    // pipe read-end onto fd. Bug #36 input side in docs/BUGS.md.
9748    vm.register_builtin(BUILTIN_MULTIOS_READ, |vm, argc| {
9749        if argc < 3 || argc % 2 == 0 {
9750            return Value::Status(1);
9751        }
9752        let fd = vm.pop().to_int() as i32;
9753        let n_sources = ((argc - 1) / 2) as usize;
9754        let mut pairs: Vec<(u8, Value)> = Vec::with_capacity(n_sources);
9755        for _ in 0..n_sources {
9756            let op_byte = vm.pop().to_int() as u8;
9757            let source = vm.pop();
9758            pairs.push((op_byte, source));
9759        }
9760        pairs.reverse();
9761
9762        // Splice glob match arrays (c:Src/glob.c:2195-2203).
9763        let mut entries: Vec<(u8, String)> = Vec::with_capacity(pairs.len());
9764        for (op_byte, source) in pairs {
9765            match source {
9766                Value::Array(items) => {
9767                    for item in items.iter() {
9768                        entries.push((op_byte, item.to_str()));
9769                    }
9770                }
9771                other => entries.push((op_byte, other.to_str())),
9772            }
9773        }
9774        if entries.is_empty() {
9775            return Value::Status(1);
9776        }
9777
9778        // c:Src/exec.c:2418 — `unset(MULTIOS)`: sequential replace,
9779        // last source wins (`unsetopt multios; cat < a < b` reads
9780        // only b; a is still opened — and errors still surface).
9781        let multios_on = opt_state_get("multios").unwrap_or(true);
9782        if !multios_on {
9783            with_executor(|exec| {
9784                for (op_byte, source) in &entries {
9785                    exec.host_apply_redirect(fd as u8, *op_byte, source);
9786                    if exec.redirect_failed {
9787                        break;
9788                    }
9789                }
9790            });
9791            return Value::Status(0);
9792        }
9793
9794        if entries.len() == 1 {
9795            // Single member after splicing — plain replace.
9796            let (op_byte, source) = &entries[0];
9797            with_executor(|exec| {
9798                exec.host_apply_redirect(fd as u8, *op_byte, source);
9799            });
9800            return Value::Status(0);
9801        }
9802
9803        // Save current fd state for scope-end restoration — BEFORE
9804        // the first member's replace dup2 below.
9805        // c:Src/exec.c:2425 — `int fdN = movefd(fd1); save[fd1] = fdN;`. A SAVED
9806        // descriptor is shell state and must live above the script's fd range:
9807        // plain dup() returns the LOWEST free fd, which parked the saved stdout
9808        // on fd 3, so `print -u 3 -r -- X 2>/dev/null` wrote into the shell's own
9809        // saved descriptor and reported success where zsh says `bad file number`.
9810        // F_DUPFD with a floor of 10 is exactly what movefd does.
9811        let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
9812        if saved >= 0 {
9813            with_executor(|exec| {
9814                if let Some(top) = exec.redirect_scope_stack.last_mut() {
9815                    top.push((fd, saved));
9816                } else {
9817                    unsafe { libc::close(saved) };
9818                }
9819            });
9820        }
9821
9822        // Open every source in redirect order; numeric `<&N` dups
9823        // resolve against the LIVE fd table. First member replaces
9824        // the fd (c:2448-2450) so later self-dups see it.
9825        let mut source_fds: Vec<i32> = Vec::with_capacity(entries.len());
9826        for (i, (op_byte, source)) in entries.iter().enumerate() {
9827            let open_result: std::io::Result<i32> = match *op_byte {
9828                r::DUP_READ | r::DUP_WRITE => match source.trim_start_matches('&').parse::<i32>() {
9829                    Ok(src) => {
9830                        let d = unsafe { libc::fcntl(src, libc::F_DUPFD, 10) };
9831                        if d >= 0 {
9832                            Ok(d)
9833                        } else {
9834                            Err(std::io::Error::last_os_error())
9835                        }
9836                    }
9837                    Err(_) => Err(std::io::Error::from_raw_os_error(libc::EBADF)),
9838                },
9839                _ => fs::File::open(source).map(|f| f.into_raw_fd()),
9840            };
9841            match open_result {
9842                Ok(tfd) => {
9843                    if i == 0 {
9844                        unsafe {
9845                            libc::dup2(tfd, fd);
9846                        }
9847                    }
9848                    source_fds.push(tfd);
9849                }
9850                Err(e) => {
9851                    let msg = match e.kind() {
9852                        std::io::ErrorKind::PermissionDenied => "permission denied",
9853                        std::io::ErrorKind::NotFound => "no such file or directory",
9854                        _ => "open failed",
9855                    };
9856                    // c:Src/exec.c:3741 — zwarn with real lineno prefix.
9857                    crate::ported::utils::zwarn(&format!("{}: {}", msg, source));
9858                    for prev in &source_fds {
9859                        unsafe {
9860                            libc::close(*prev);
9861                        }
9862                    }
9863                    with_executor(|exec| {
9864                        exec.redirect_failed = true;
9865                    });
9866                    return Value::Status(1);
9867                }
9868            }
9869        }
9870
9871        // Create the concatenator pipe.
9872        let (read_end, write_end) = match os_pipe::pipe() {
9873            Ok(p) => p,
9874            Err(_) => {
9875                for f in &source_fds {
9876                    unsafe {
9877                        libc::close(*f);
9878                    }
9879                }
9880                return Value::Status(1);
9881            }
9882        };
9883        // dup the pipe read-end onto fd before spawning the
9884        // producer; close the original read_end so the consumer
9885        // (reading via fd) is the sole reference until scope-end.
9886        let read_dup = unsafe { libc::dup(AsRawFd::as_raw_fd(&read_end)) };
9887        drop(read_end);
9888        if read_dup < 0 {
9889            for f in &source_fds {
9890                unsafe {
9891                    libc::close(*f);
9892                }
9893            }
9894            return Value::Status(1);
9895        }
9896        unsafe {
9897            libc::dup2(read_dup, fd);
9898            libc::close(read_dup);
9899        }
9900        // Spawn the producer.
9901        let source_fds_for_thread = source_fds.clone();
9902        let handle = std::thread::spawn(move || {
9903            let mut w = write_end;
9904            let mut buf = [0u8; 8192];
9905            for sfd in source_fds_for_thread {
9906                loop {
9907                    let n = unsafe {
9908                        libc::read(sfd, buf.as_mut_ptr() as *mut libc::c_void, buf.len())
9909                    };
9910                    if n <= 0 {
9911                        break;
9912                    }
9913                    let n = n as usize;
9914                    if std::io::Write::write_all(&mut w, &buf[..n]).is_err() {
9915                        break;
9916                    }
9917                }
9918                unsafe {
9919                    libc::close(sfd);
9920                }
9921            }
9922            // Closing w (the write_end) at scope drop signals EOF
9923            // to the consumer.
9924        });
9925        with_executor(|exec| {
9926            // Track using a closed-write sentinel — the producer
9927            // owns write_end so we just need to join. Use -1 fd
9928            // marker meaning "no fd to close".
9929            if let Some(top) = exec.multios_scope_stack.last_mut() {
9930                top.push((-1, handle));
9931            } else {
9932                let _ = handle.join();
9933            }
9934        });
9935        Value::Status(0)
9936    });
9937    // c:Src/exec.c:3978-3986 — nullexec==1 marker. See the const's
9938    // doc block. Arg: 1 = entering a bare-exec redirect, 0 = leaving.
9939    vm.register_builtin(BUILTIN_EXEC_PERM_REDIRS, |vm, _argc| {
9940        let on = vm.pop().to_int() != 0;
9941        with_executor(|exec| exec.exec_redirs_permanent = on);
9942        Value::Status(0)
9943    });
9944    // Bare-exec redirect epilogue — see the const's doc block.
9945    // c:Src/exec.c:252-259 (execerr) + c:4367-4386 (done: POSIX gate).
9946    vm.register_builtin(BUILTIN_EXEC_REDIR_DONE, |vm, _argc| {
9947        use std::sync::atomic::Ordering;
9948        let failed = with_executor(|exec| {
9949            let f = exec.redirect_failed;
9950            exec.redirect_failed = false;
9951            f
9952        });
9953        if !failed {
9954            return Value::Status(0);
9955        }
9956        // c:255 — `redir_err = lastval = 1`.
9957        vm.last_status = 1;
9958        if isset(crate::ported::zsh_h::POSIXBUILTINS) && !isset(crate::ported::zsh_h::INTERACTIVE) {
9959            // c:4379-4383 — non-interactive POSIX fatal: exit(1).
9960            // In-process equivalent: arm EXIT_PENDING/EXIT_VAL so the
9961            // next BUILTIN_ERREXIT_CHECK (trigger 2) unwinds the
9962            // script with status 1 — same deferred-exit shape the
9963            // `exit` builtin uses inside subshell contexts.
9964            crate::ported::builtin::EXIT_VAL.store(1, Ordering::Relaxed);
9965            crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
9966        }
9967        Value::Status(1)
9968    });
9969    // c:Src/exec.c:3722-3724 — see the const's doc block. No args.
9970    vm.register_builtin(BUILTIN_PIPE_OUTPUT_MARK, |_vm, _argc| {
9971        with_executor(|exec| exec.pipe_output_pending = true);
9972        Value::Status(0)
9973    });
9974    // c:Src/exec.c:3710-3724 — install this pipeline stage's fds.
9975    //     /* Make a copy of stderr for xtrace output before redirecting */
9976    //     fflush(xtrerr);
9977    //     ...
9978    //     /* Add pipeline input/output to mnodes */
9979    //     if (input)  addfd(forked, save, mfds, 0, input, 0, NULL);
9980    //     if (output) addfd(forked, save, mfds, 1, output, 1, NULL);
9981    // Emitted into the stage chunk by compile_zsh.rs (after the arg
9982    // words' expansion ops, before the redirect scope), and fed by
9983    // BUILTIN_RUN_PIPELINE via `stage_fds_park`. Doing the dup2 HERE
9984    // rather than before the chunk runs is what makes a `$(...)` in a
9985    // stage's arguments see the shell's fd 0 instead of the pipe.
9986    vm.register_builtin(BUILTIN_PIPE_FDS_INSTALL, |vm, argc| {
9987        // Arg: `|&` merge-stderr flag (compile_zsh always passes it).
9988        let merge_stderr = pop_args(vm, argc)
9989            .first()
9990            .map(|s| s != "0" && !s.is_empty())
9991            .unwrap_or(false);
9992        let (in_fd, out_fd) = stage_fds_take();
9993        if in_fd < 0 && out_fd < 0 {
9994            return Value::Status(0);
9995        }
9996        // c:3711 `fflush(xtrerr)` — flush before the fds move, so
9997        // anything buffered from the expansion phase lands on the
9998        // ORIGINAL fd, not on the pipe.
9999        let _ = std::io::stdout().flush();
10000        let _ = std::io::stderr().flush();
10001        unsafe {
10002            if in_fd >= 0 {
10003                libc::dup2(in_fd, libc::STDIN_FILENO);
10004                if in_fd != libc::STDIN_FILENO {
10005                    libc::close(in_fd);
10006                }
10007            }
10008            if out_fd >= 0 {
10009                libc::dup2(out_fd, libc::STDOUT_FILENO);
10010                if out_fd != libc::STDOUT_FILENO {
10011                    libc::close(out_fd);
10012                }
10013                // `cmd |& next`: the `2>&1` C appends to cmd's redirect
10014                // list (walked at c:3730+, i.e. after this addfd), so
10015                // stderr follows the pipe, not the shell's stdout.
10016                if merge_stderr {
10017                    libc::dup2(libc::STDOUT_FILENO, libc::STDERR_FILENO);
10018                }
10019            }
10020        }
10021        Value::Status(0)
10022    });
10023    // c:Src/exec.c — block-level redirect-failure gate. When a
10024    // compound command (`{ … } < file`, `( … ) > file`, etc.) has a
10025    // failing redirect (e.g. `< /nonexistent`), zsh skips the entire
10026    // body AND sets lastval to 1. The simple-command path's
10027    // redirect_failed check (line 215-221 above) only catches the
10028    // failure when a builtin dispatches and is consumed by that
10029    // single builtin call — so a multi-statement block kept running
10030    // its remaining statements after the redir error. Emit-side at
10031    // compile_zsh.rs::compile_command's Redirected arm pairs this
10032    // with a JumpIfTrue → WithRedirectsEnd to abandon the body.
10033    vm.register_builtin(BUILTIN_REDIRECT_FAILED_CHECK, |vm, _argc| {
10034        let failed = with_executor(|exec| {
10035            let f = exec.redirect_failed;
10036            exec.redirect_failed = false;
10037            f
10038        });
10039        if failed {
10040            vm.last_status = 1;
10041            Value::Int(1)
10042        } else {
10043            Value::Int(0)
10044        }
10045    });
10046    // c:Src/exec.c — drop-in replacement for fusevm's Op::Exec used by
10047    // the dynamic-first-word path (`$cmd`, `$(cmd)`, glob-named cmds).
10048    // fusevm's Op::Exec returns Value::Status(0) when post-expansion
10049    // argv is empty (vm.rs:1722) — that clobbers \$? for the
10050    // `\$(exit 1); echo \$?` case where the cmd-subst left
10051    // last_status = 1 but the empty expansion gets exec'd to 0.
10052    // Mirror C zsh: when the word list is empty after expansion,
10053    // \$? becomes whatever the inner cmd-subst's last_status is
10054    // (preserved here by returning Value::Status(last_status)).
10055    // c:Src/cond.c:308-316 — `if (!(pprog = patcompile(right, ...)))
10056    //   { zwarnnam(fromtest, "bad pattern: %s", right); return 2; }`.
10057    // The cond path must NOT use str_match/glob_match_static: the
10058    // case-statement consumer of those follows Src/loop.c:667 zerr
10059    // semantics (errflag abort), while cond is a zwarn + status-2
10060    // soft failure. COND_BAD_PATTERN carries the 2 across the
10061    // Bool-shaped stack contract (so `!=`'s LogNot can't lose it).
10062    thread_local! {
10063        static COND_BAD_PATTERN: std::cell::Cell<bool> =
10064            const { std::cell::Cell::new(false) };
10065    }
10066    vm.register_builtin(BUILTIN_COND_STRMATCH, |vm, _argc| {
10067        let pat = pattern_filesub(&vm.pop().to_str());
10068        let s = vm.pop().to_str();
10069        // bash `shopt -s nocasematch` → case-insensitive `[[ == ]]` / `[[ != ]]`.
10070        // Lowercase BOTH sides for the match decision (glob metacharacters are
10071        // not letters, so the pattern's `*`/`?`/`[…]` structure is preserved).
10072        // No-op unless the bash shopt is active. --zsh unaffected.
10073        let (s, pat) = if crate::dash_mode::nocasematch() {
10074            (s.to_lowercase(), pat.to_lowercase())
10075        } else {
10076            (s, pat)
10077        };
10078        let mut pat_tok = pat.clone();
10079        crate::ported::glob::tokenize(&mut pat_tok);
10080        if crate::ported::pattern::patcompile(
10081            &pat_tok,
10082            crate::ported::zsh_h::PAT_STATIC as i32,
10083            None,
10084        )
10085        .is_none()
10086        {
10087            // c:314 — zwarnnam(fromtest, "bad pattern: %s", right).
10088            crate::ported::utils::zwarn(&format!("bad pattern: {}", pat));
10089            COND_BAD_PATTERN.with(|c| c.set(true));
10090            return Value::Bool(false);
10091        }
10092        // Match via the shared engine so `(#b)`/`(#m)` backref and
10093        // MATCH-variable population stays in one place.
10094        Value::Bool(crate::vm_helper::glob_match_static(&s, &pat))
10095    });
10096    vm.register_builtin(BUILTIN_COND_UNKNOWN, |vm, _argc| {
10097        // c:Src/cond.c:150-188 — `zwarnnam(fromtest, "unknown condition: %s",
10098        // name)` for a `-X` op with no matching cond module. Like a cond
10099        // syntax error it yields status 2 and aborts: arm COND_BAD_PATTERN so
10100        // the downstream BUILTIN_COND_STATUS_FROM_BOOL carries the 2 across the
10101        // Bool-shaped stack and runs the shared errflag+set_last_status(2)+abort
10102        // path (c:Src/exec.c:5216-5221). Returns Bool(false) as the operand.
10103        let op = vm.pop().to_str();
10104        crate::ported::utils::zerr(&format!("unknown condition: {}", op));
10105        COND_BAD_PATTERN.with(|c| c.set(true));
10106        Value::Bool(false)
10107    });
10108    vm.register_builtin(BUILTIN_COND_STATUS_FROM_BOOL, |vm, _argc| {
10109        // `${~pat}` / `${(P)~pat}` inside a `[[ … ]]` operand flips
10110        // GLOB_SUBST on via the tilde carrier so the pattern match sees
10111        // active metacharacters. In C that flag is prefork-scoped and
10112        // gone once the operand is consumed; zshrs restores it at the
10113        // next command-dispatch boundary, but a bare `[[ … ]]` has no
10114        // trailing assignment to trigger that — so globsubst leaked ON
10115        // into the NEXT command's word expansion, filename-generating a
10116        // scalar value it should not (p10k `_p9k_set_prompt`: line 45
10117        // `[[ … != ${(P)~disabled} ]]` leaked into line 46's
10118        // `local val=$arr[idx]`, whose glob-char-laden value then hit
10119        // "no matches found" and aborted the whole prompt build →
10120        // garbled 25-line prompt / interactive hang). Consume the
10121        // carrier here: this builtin ends EVERY `[[ … ]]`, and runs
10122        // after the operands (and their pattern match) are done.
10123        consume_tilde_globsubst_carrier();
10124        let ok = vm.pop().to_int() != 0;
10125        let bad = COND_BAD_PATTERN.with(|c| {
10126            let b = c.get();
10127            c.set(false);
10128            b
10129        });
10130        if bad {
10131            // c:Src/exec.c:5216-5221 — `stat = evalcond(...);
10132            //   /* 2 indicates a syntax error. For compatibility,
10133            //      turn this into a shell error. */
10134            //   if (stat == 2) errflag |= ERRFLAG_ERROR;`
10135            // The errflag abort exits the script with lastval (2),
10136            // matching `zsh -fc '[[ x == [a- ]]; print rc=$?'`
10137            // printing nothing after the diagnostic and exiting 2.
10138            crate::ported::utils::errflag.fetch_or(
10139                crate::ported::zsh_h::ERRFLAG_ERROR,
10140                std::sync::atomic::Ordering::Relaxed,
10141            );
10142            with_executor(|exec| exec.set_last_status(2));
10143            return Value::Int(2); // c:Src/cond.c:316 `return 2;`
10144        }
10145        let status: i32 = if ok { 0 } else { 1 };
10146        // c:Src/exec.c:5216 — `lastval = evalcond(...)`: the conditional's
10147        // result IS the command's lastval, and c:Src/cond.c's evalcond
10148        // never inspects errflag while evaluating. So when a `[[ … ]]`
10149        // operand raised errflag (e.g. a nounset "parameter not set" zerr
10150        // on `${arr[99]}` under NO_UNSET), zsh STILL completes the test and
10151        // exits with the cond result; the errflag only aborts the FOLLOWING
10152        // commands. Sync the result to the executor's live lastval HERE —
10153        // the nounset site left it at a transient 1, and the next
10154        // BUILTIN_ERREXIT_CHECK reads the executor (not vm.last_status), so
10155        // without this sync `setopt NO_UNSET; [[ -z ${arr[99]} ]]` exited 1
10156        // instead of 0. The Op::SetStatus that follows sets vm.last_status;
10157        // this keeps the executor coherent with it before the abort check.
10158        with_executor(|exec| exec.set_last_status(status));
10159        Value::Int(status as i64)
10160    });
10161    vm.register_builtin(BUILTIN_USE_CMDOUTVAL_RESET, |_vm, _argc| {
10162        crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
10163        Value::Status(0)
10164    });
10165
10166    vm.register_builtin(BUILTIN_EXEC_DYNAMIC, |vm, argc| {
10167        let raw = pop_args(vm, argc);
10168        // Flatten Array entries into argv slots (matches fusevm
10169        // Op::Exec's flatten at vm.rs:1660-1665) so `${arr[@]}` /
10170        // splice expansions produce one argv slot per element.
10171        let args: Vec<String> = raw.into_iter().collect();
10172        // c:Src/subst.c paramsubst — when `${var:?msg}` or
10173        // `${var?msg}` set errflag, the expansion may produce empty
10174        // argv[0] which would fall into the EACCES/permission-denied
10175        // path below, masking the real paramsubst diagnostic with a
10176        // spurious "permission denied:" line and rc=126. Honour
10177        // errflag so the simple command ends with the paramsubst
10178        // error as the sole diagnostic, rc=1. Bug #86.
10179        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
10180            & crate::ported::zsh_h::ERRFLAG_ERROR)
10181            != 0
10182        {
10183            return Value::Status(1);
10184        }
10185        if args.is_empty() {
10186            // c:Src/exec.c:3442 — a command whose words expand to ZERO
10187            // words is a NULL command: `cmdoutval = use_cmdoutval ?
10188            // lastval : 0`. `use_cmdoutval` is set (below, in
10189            // BUILTIN_CMD_SUBST_TEXT) only when a command substitution
10190            // ran during this command's word expansion, so:
10191            //   `false; $(exit 5)`  → keep the subst status (5)
10192            //   `false; $nonexistent` → reset to 0 (null command).
10193            // The previous port unconditionally kept `$?`, so
10194            // `false; $unset` wrongly stayed 1 (A01grammar.ztst:5).
10195            let keep =
10196                crate::ported::exec::use_cmdoutval.load(std::sync::atomic::Ordering::Relaxed) != 0;
10197            let status = if keep { vm.last_status } else { 0 };
10198            crate::ported::exec::use_cmdoutval.store(0, std::sync::atomic::Ordering::Relaxed);
10199            return Value::Status(status);
10200        }
10201        if args[0].is_empty() {
10202            // Explicit empty command word — exec returns EACCES.
10203            let script_name =
10204                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
10205            let lineno: u64 = with_executor(|exec| {
10206                exec.scalar("LINENO")
10207                    .and_then(|s| s.parse::<u64>().ok())
10208                    .unwrap_or(1)
10209            });
10210            eprintln!("{}:{}: permission denied: ", script_name, lineno);
10211            return Value::Status(126);
10212        }
10213        // AOP intercepts (zshrs extension, no C counterpart) — same
10214        // gate as host_exec_external (the static-head path): dynamic
10215        // command names (`cmd=/bin/echo; $cmd payload`) must consult
10216        // registered intercepts before dispatch, else `intercept
10217        // before /bin/echo ...` fires for the literal spelling but
10218        // not the variable one. run_intercepts runs before-advice
10219        // in-place and returns None to continue; Some(status) means
10220        // an around/after advice fully handled the command.
10221        let intercepted = with_executor(|exec| {
10222            if exec.intercepts.is_empty() {
10223                return None;
10224            }
10225            let full_cmd = if args.len() == 1 {
10226                args[0].clone()
10227            } else {
10228                args.join(" ")
10229            };
10230            let rest: Vec<String> = args[1..].to_vec();
10231            exec.run_intercepts(&args[0], &full_cmd, &rest)
10232        });
10233        if let Some(result) = intercepted {
10234            return Value::Status(result.unwrap_or(127));
10235        }
10236        // zshrs-original opcode builtins (async, doctor, peach, …) reached
10237        // via a run-time-resolved head (`$var`): they are absent from the
10238        // static BUILTINS port table / builtintab, so execcmd_exec below would
10239        // treat the head as external and report "command not found" — even
10240        // though `whence` calls it a builtin and a literal head runs it via
10241        // CallBuiltin. Dispatch by name here, but ONLY when the head is neither
10242        // a user function nor a ported builtin, so the shell's
10243        // function -> builtin -> external order is preserved.
10244        if let Some(head) = args.first() {
10245            let is_fn = with_executor(|e| e.function_exists(head));
10246            let is_ported =
10247                crate::ported::builtin::createbuiltintable().contains_key(head.as_str());
10248            if !is_fn && !is_ported {
10249                if let Some(status) = try_run_registered_builtin(head, &args[1..]) {
10250                    crate::ported::builtin::LASTVAL
10251                        .store(status, std::sync::atomic::Ordering::Relaxed);
10252                    return Value::Status(status);
10253                }
10254            }
10255        }
10256        // c:Src/exec.c:2900 execcmd_exec — canonical simple-command
10257        // dispatcher. Runs precmd-modifier walk (c:3013-3091), then
10258        // dispatches to execbuiltin (c:4233) / runshfunc (c:3431+) /
10259        // execute (c:4314) per the resolved head. zshrs's bytecode VM
10260        // expanded the args before reaching here; we feed them in via
10261        // eparams.args and let execcmd_exec do the rest exactly as C
10262        // does for static heads. Without this, `c=builtin; $c source X`
10263        // skipped the precmd walk and emitted "command not found:
10264        // builtin".
10265        let mut state = crate::ported::zsh_h::estate {
10266            prog: Box::<crate::ported::zsh_h::eprog>::default(),
10267            pc: 0,
10268            strs: None,
10269            strs_offset: 0,
10270        };
10271        let mut eparams = crate::ported::zsh_h::execcmd_params {
10272            args: Some(args),
10273            redir: None,
10274            beg: 0,
10275            varspc: None,
10276            assignspc: None,
10277            typ: crate::ported::zsh_h::WC_SIMPLE as i32,
10278            postassigns: 0,
10279            htok: 0,
10280        };
10281        // input/output=0 → no pipe redirection (use shell stdio
10282        // directly); `output != 0` at c:2988 forks immediately. last1=2
10283        // (c:Src/exec.c:2014 `last1 ? 1 : 2`): terminal pipe stage but
10284        // the shell IS needed afterward — the VM keeps executing
10285        // bytecode after this op. last1=1 would arm the fake-exec
10286        // optimization (c:3646-3651, gate at c:3662 `last1 != 1`),
10287        // making `execute()` execve THIS process for external heads:
10288        // `p=/bin/echo; $p hi; echo after` replaced the shell and
10289        // `after` never ran (D04parameter chunk 11 shell-killer).
10290        // c:Src/exec.c:1690-1700 — execpline's job frame: save thisjob
10291        // (`pj = thisjob`) and allocate the jobtab slot that
10292        // execcmd_fork's addproc (c:2853) hangs the child pid off.
10293        // Without a live thisjob, the fork at c:3662 (last1 != 1 →
10294        // external must fork) registers no proc, nothing waits, and
10295        // the child races the rest of the script.
10296        let pj = {
10297            use crate::ported::jobs;
10298            *jobs::THISJOB
10299                .get_or_init(|| std::sync::Mutex::new(-1))
10300                .lock()
10301                .unwrap_or_else(|e| e.into_inner())
10302        };
10303        let newjob = {
10304            use crate::ported::jobs;
10305            let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
10306            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
10307            jobs::initjob(&mut tab) // c:1700 `thisjob = newjob = initjob()`
10308        };
10309        {
10310            use crate::ported::jobs;
10311            *jobs::THISJOB
10312                .get_or_init(|| std::sync::Mutex::new(-1))
10313                .lock()
10314                .unwrap_or_else(|e| e.into_inner()) = newjob as i32;
10315        }
10316        crate::ported::exec::execcmd_exec(
10317            &mut state,
10318            &mut eparams,
10319            0,                                   // input  (c:2989)
10320            0,                                   // output (c:2988)
10321            crate::ported::zsh_h::Z_SYNC as i32, // how
10322            2,                                   // last1=2 — shell continues (c:2014)
10323            -1,                                  // close_if_forked
10324        );
10325        // c:Src/exec.c:1828-1835 — execpline's Z_SYNC tail: waitjobs()
10326        // reaps the forked external. c:Src/jobs.c:487-495 + 551-552 —
10327        // the job's LAST proc sets lastval (0200|sig when signalled,
10328        // else WEXITSTATUS). Builtin/shfunc heads never forked (job
10329        // has no procs) — LASTVAL was already set by execbuiltin /
10330        // doshfunc inside execcmd_exec; skip the wait.
10331        {
10332            use crate::ported::jobs;
10333            let table = jobs::JOBTAB.get_or_init(|| std::sync::Mutex::new(Vec::new()));
10334            let mut tab = table.lock().unwrap_or_else(|e| e.into_inner());
10335            if jobs::hasprocs(&tab, newjob) {
10336                jobs::waitjobs(&mut tab, newjob); // c:1835
10337                if let Some(p) = tab[newjob].procs.last() {
10338                    let val = if p.is_signaled() {
10339                        0o200 | p.term_sig() // c:Src/jobs.c:489-490
10340                    } else {
10341                        p.exit_status() // c:Src/jobs.c:494
10342                    };
10343                    crate::ported::builtin::LASTVAL
10344                        .store(val, std::sync::atomic::Ordering::Relaxed);
10345                }
10346            }
10347            // c:1977-1979 — `deletejob(jn, 0)` once done; c:1981
10348            // `thisjob = pj` restores the caller's job.
10349            if newjob < tab.len() {
10350                jobs::deletejob(&mut tab[newjob], false);
10351            }
10352            *jobs::THISJOB
10353                .get_or_init(|| std::sync::Mutex::new(-1))
10354                .lock()
10355                .unwrap_or_else(|e| e.into_inner()) = pj;
10356        }
10357        let status = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
10358        let mut synth = crate::ported::zsh_h::job::default();
10359        crate::ported::jobs::waitonejob(&mut synth);
10360        Value::Status(status)
10361    });
10362    // c:Src/exec.c:3386-3419 — `< file` / `> file` with no command
10363    // word. Resolves NULLCMD/READNULLCMD at runtime, then dispatches the
10364    // resulting word the way execcmd's fall-through does (shell function →
10365    // builtin → external). Redirects are already applied by the surrounding
10366    // WithRedirectsBegin scope.
10367    vm.register_builtin(BUILTIN_NULLCMD_EXEC, |vm, argc| {
10368        let args = pop_args(vm, argc);
10369        let is_single_read = args
10370            .first()
10371            .map(|s| s != "0" && !s.is_empty())
10372            .unwrap_or(false);
10373        // c:Src/exec.c — when the surrounding redir-open failed
10374        // (e.g. `< /nonexistent`), zerr already printed the diag
10375        // and set redirect_failed. Don't invoke NULLCMD — return
10376        // status 1 like the wordcode path does.
10377        let redir_failed = with_executor(|exec| {
10378            let f = exec.redirect_failed;
10379            exec.redirect_failed = false;
10380            f
10381        });
10382        if redir_failed {
10383            crate::ported::builtin::LASTVAL.store(1, std::sync::atomic::Ordering::Relaxed);
10384            return Value::Status(1);
10385        }
10386        let nullcmd = crate::ported::params::getsparam("NULLCMD");
10387        let nc_str = nullcmd.as_deref().unwrap_or("");
10388        let nc_empty = nc_str.is_empty();
10389        // c:3340-3344 — CSHNULLCMD or no NULLCMD set → diagnostic.
10390        if nc_empty || crate::ported::zsh_h::isset(crate::ported::zsh_h::CSHNULLCMD) {
10391            let script_name =
10392                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
10393            let lineno: u64 = with_executor(|exec| {
10394                exec.scalar("LINENO")
10395                    .and_then(|s| s.parse::<u64>().ok())
10396                    .unwrap_or(1)
10397            });
10398            eprintln!("{}:{}: redirection with no command", script_name, lineno);
10399            return Value::Status(1);
10400        }
10401        // c:3350 — SHNULLCMD → run `:`.
10402        let cmd: String = if crate::ported::zsh_h::isset(crate::ported::zsh_h::SHNULLCMD) {
10403            ":".to_string()
10404        } else if is_single_read {
10405            // c:3354-3359 — single REDIR_READ + READNULLCMD set → readnullcmd.
10406            let rnc = crate::ported::params::getsparam("READNULLCMD");
10407            let rnc_str = rnc.as_deref().unwrap_or("");
10408            if !rnc_str.is_empty() {
10409                rnc_str.to_string()
10410            } else {
10411                nc_str.to_string() // c:3360-3363 fallback
10412            }
10413        } else {
10414            nc_str.to_string() // c:3360-3363
10415        };
10416        // c:Src/exec.c:3408/3414/3418 — C does not "run NULLCMD" as a special
10417        // case: it APPENDS the word to the command's arg list
10418        // (`addlinknode(args, dupstring(nullcmd))`) and falls through to
10419        // execcmd's ORDINARY dispatch, which resolves that word in this order:
10420        //   c:3484-3487 `shfunctab->getnode(shfunctab, cmdarg)` → shell function
10421        //   c:3489      `builtintab->getnode(builtintab, cmdarg)` → builtin
10422        //   otherwise   → external command (PATH lookup).
10423        // `host_exec_external` already implements the shell-function arm and
10424        // the external arm (plus the AOP intercepts and the module-builtin
10425        // name arms), so only the builtintab arm has to be decided here.
10426        //
10427        // The builtintab question must be asked of the TABLE.
10428        // `builtin_in_builtintab` alone is NOT a membership test — it is the
10429        // module *gate*, and `builtin_owning_module` returns None for any name
10430        // it does not know, whose `None => true` arm
10431        // (src/extensions/ext_builtins.rs:179-182) then reports EVERY string as
10432        // an available builtin. With that as the only predicate, the default
10433        // `READNULLCMD=more` (config.h DEFAULT_READNULLCMD), a user's
10434        // `READNULLCMD=less`, `NULLCMD=/bin/cat` and every other external name
10435        // were classified as core builtins and handed to
10436        // `dispatch_builtin_raw("more", vec![])`, which cannot work: plain
10437        // `< file` printed NOTHING and returned 1, and a missing NULLCMD
10438        // returned a silent 1 instead of `command not found` / 127.
10439        //
10440        // Membership first, gate second. The zshrs-original coreutils-shaped
10441        // builtins (`cat`, `basename`, … EXT_BUILTIN_NAMES) are not entries of
10442        // `createbuiltintable()` at all, so the documented `NULLCMD=cat` /
10443        // `READNULLCMD=cat` idioms keep reaching the real `/bin/cat` the way
10444        // `zsh -f` does without needing an explicit exclusion.
10445        //
10446        // `dispatch_builtin` (not `dispatch_builtin_raw`) is the correct entry:
10447        // C's `builtintab->getnode` filters DISABLED nodes, so `disable :;
10448        // NULLCMD=:; > f` must fall through to PATH — the raw dispatcher
10449        // deliberately bypasses that set (it is what `builtin NAME` uses).
10450        let is_shfunc = with_executor(|exec| exec.function_exists(&cmd)); // c:3485
10451        let is_builtin = crate::ported::builtin::createbuiltintable().contains_key(&cmd)
10452            && crate::extensions::ext_builtins::builtin_in_builtintab(&cmd); // c:3489
10453        let status = if !is_shfunc && is_builtin {
10454            dispatch_builtin(&cmd, Vec::new()) // c:3489-3504
10455        } else {
10456            with_executor(|exec| exec.host_exec_external(&[cmd]))
10457        };
10458        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
10459        Value::Status(status)
10460    });
10461    // c:Src/exec.c:3342 — `zerr("redirection with no command")`.
10462    // Bare prefix-keyword (`builtin`, `command`, `exec`, `noglob`,
10463    // `nocorrect`) with a redirect but no command word. Emits the
10464    // canonical diagnostic via zerr (which sets errflag) and
10465    // returns Status(1). Bug #534.
10466    vm.register_builtin(BUILTIN_REDIR_NO_CMD, |_vm, _argc| {
10467        crate::ported::utils::zerr("redirection with no command");
10468        Value::Status(1)
10469    });
10470    vm.register_builtin(BUILTIN_DEBUG_TRAP, |vm, _argc| {
10471        // c:Src/signals.c:1245 dotrap(SIGDEBUG) — fires the DEBUG
10472        // trap body once per statement. The body sees the parent
10473        // shell's $? (LASTVAL). Guard against re-entry: commands
10474        // inside the DEBUG trap body would otherwise trigger
10475        // DEBUG_TRAP recursively → stack overflow. zsh guards via
10476        // its in_trap counter; we mirror with a thread-local Cell.
10477        //
10478        // c:Src/exec.c::trapcmd — before dotrap, the C source sets
10479        // `ZSH_DEBUG_CMD` to the about-to-run command text via
10480        // `dupstring(text)`. The trap body reads the parameter;
10481        // C unsets it after the trap returns. compile_list emits
10482        // the rendered statement text as the single arg here so the
10483        // shell-visible parameter reflects the command. Bug #263 in
10484        // docs/BUGS.md.
10485        // Return value: `Int(1)` tells the emit-side JumpIfTrue to SKIP the
10486        // statement this trap ran in front of — C's `donedebug == 2`
10487        // (c:Src/exec.c:1499/1511/1519-1529) plus the forced-return case
10488        // where C's list loop (`while (… && !retflag …)`, c:1443) never
10489        // reaches the command. `Int(0)` = run it.
10490        //
10491        // Two call sites, distinguished by the `mode` operand:
10492        //   0 — c:1476-1502, the DEBUG_BEFORE_CMD block that runs BEFORE the
10493        //       sublist (only when the option is set),
10494        //   1 — c:1628-1644, the `sublist_done:` block that runs AFTER it
10495        //       (only when the option is NOT set).
10496        // Firing the pre-sublist arm in both modes made the default
10497        // (post-command) DEBUG trap observe the NEXT statement's `$LINENO`
10498        // — A05execution:19 saw "Line 2 / Line 3" for a trap zsh reports as
10499        // "Line 1 / Line 2".
10500        let mode = vm.pop().to_int();
10501        let cmd_text = vm.pop().to_str();
10502        let before = mode == 0;
10503        DEBUG_TRAP_REENTRY.with(|c| {
10504            if c.get() {
10505                return Value::Int(0);
10506            }
10507            // c:1476 `isset(DEBUGBEFORECMD)` / c:1628 `!isset(DEBUGBEFORECMD)`.
10508            if before != isset(crate::ported::zsh_h::DEBUGBEFORECMD) {
10509                return Value::Int(0);
10510            }
10511            // c:Src/exec.c:1423 — `if (sigtrapped[SIGDEBUG] &&
10512            // isset(DEBUGBEFORECMD) && !intrap)`. Bug #573: without
10513            // this gate, every sublist boundary called
10514            // setsparam("ZSH_DEBUG_CMD", ...) even when no DEBUG trap
10515            // was set, polluting the param table and (under
10516            // WARN_CREATE_GLOBAL) emitting a spurious
10517            // `scalar parameter ZSH_DEBUG_CMD created globally`
10518            // warning at every function call.
10519            //
10520            // Two trap registries exist (per signals.rs:1481-1511 dotrap):
10521            //   - settrap path → sigtrapped[SIGDEBUG] bits set
10522            //   - bin_trap path → traps_table["DEBUG"] populated, sigtrapped untouched
10523            // Mirror the dotrap dispatch decision: skip only when BOTH
10524            // are absent.
10525            let sig_debug = crate::ported::signals_h::SIGDEBUG as usize;
10526            let debug_trapped = crate::ported::signals::sigtrapped
10527                .lock()
10528                .map(|v| v.get(sig_debug).copied().unwrap_or(0))
10529                .unwrap_or(0);
10530            let debug_in_table = crate::ported::builtin::traps_table()
10531                .lock()
10532                .map(|t| t.contains_key("DEBUG"))
10533                .unwrap_or(false);
10534            if debug_trapped == 0 && !debug_in_table {
10535                return Value::Int(0);
10536            }
10537            c.set(true);
10538            // c:1478-1481 — `int oerrexit_opt = opts[ERREXIT]; Param pm;
10539            // opts[ERREXIT] = 0; noerrexit |= NOERREXIT_EXIT |
10540            // NOERREXIT_RETURN;`. ERREXIT is forced OFF across the trap
10541            // body so the option can be used as the "skip this command"
10542            // signal (c:1499) without the body's own failing commands
10543            // exiting the shell.
10544            let oerrexit_opt = isset(crate::ported::zsh_h::ERREXIT); // c:1478
10545            crate::ported::options::opt_state_set("errexit", false); // c:1480
10546            let oldnoerrexit =
10547                crate::ported::exec::noerrexit.load(std::sync::atomic::Ordering::Relaxed);
10548            crate::ported::exec::noerrexit.store(
10549                oldnoerrexit
10550                    | crate::ported::zsh_h::NOERREXIT_EXIT
10551                    | crate::ported::zsh_h::NOERREXIT_RETURN,
10552                std::sync::atomic::Ordering::Relaxed,
10553            ); // c:1481
10554               // c:Src/exec.c:1484 — set ZSH_DEBUG_CMD scalar (PM_READONLY
10555               // is NOT set on ZSH_DEBUG_CMD, so the canonical
10556               // setsparam path is fine here — no direct paramtab
10557               // mutation needed).
10558               // c:1636 — the post-sublist arm has no ZSH_DEBUG_CMD assignment;
10559               // the parameter is a DEBUG_BEFORE_CMD feature only.
10560            if before {
10561                crate::ported::params::setsparam("ZSH_DEBUG_CMD", &cmd_text);
10562            }
10563            // c:1488/1636 — `exiting = donetrap;` … c:1493/1641 `donetrap = exiting;`
10564            let exiting = crate::ported::exec::DONETRAP.load(std::sync::atomic::Ordering::Relaxed);
10565            let ret = crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed); // c:1489
10566            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGDEBUG);
10567            // c:1491-1492 — `if (!retflag) lastval = ret;`. A trap that
10568            // ran `return N` keeps the forced status; anything else
10569            // leaves the pre-trap `$?` alone.
10570            let retflag =
10571                crate::ported::builtin::RETFLAG.load(std::sync::atomic::Ordering::Relaxed) != 0;
10572            if !retflag {
10573                crate::ported::builtin::LASTVAL.store(ret, std::sync::atomic::Ordering::Relaxed);
10574                // c:1492
10575            }
10576            crate::ported::exec::noerrexit
10577                .store(oldnoerrexit, std::sync::atomic::Ordering::Relaxed); // c:1494
10578                                                                            // c:1499 — `donedebug = isset(ERREXIT) ? 2 : 1;`. The trap
10579                                                                            // setting ERREXIT is zsh's documented "skip this command"
10580                                                                            // signal (zshmisc(1), DEBUG trap).
10581            let donedebug2 = before && isset(crate::ported::zsh_h::ERREXIT); // c:1499
10582            crate::ported::options::opt_state_set("errexit", oerrexit_opt); // c:1500/1643
10583            crate::ported::exec::DONETRAP.store(exiting, std::sync::atomic::Ordering::Relaxed); // c:1493/1641
10584                                                                                                // c:Src/exec.c:1501-1502 — `if (pm) unsetparam_pm(pm, 0, 1);`
10585            if before {
10586                crate::ported::params::unsetparam("ZSH_DEBUG_CMD");
10587            }
10588            c.set(false);
10589            if retflag {
10590                // c:1443 — the enclosing list loop stops on retflag, so the
10591                // command never runs. Mirror the forced status into the VM's
10592                // own counter (the ported LASTVAL atomic is a separate store)
10593                // and report "skip" so the emit-side jump lands past the
10594                // statement, where the RETFLAG escape unwinds the function.
10595                let forced =
10596                    crate::ported::builtin::LASTVAL.load(std::sync::atomic::Ordering::Relaxed);
10597                vm.last_status = forced;
10598                with_executor(|exec| exec.set_last_status(forced));
10599                return Value::Int(1);
10600            }
10601            if donedebug2 {
10602                // c:1511 — `if (donedebug != 2) execsimple(state);` and
10603                // c:1519-1529 — the compound form skips the whole sublist and
10604                // sets `donetrap = 1`.
10605                crate::ported::exec::DONETRAP.store(1, std::sync::atomic::Ordering::Relaxed); // c:1527
10606                return Value::Int(1);
10607            }
10608            Value::Int(0)
10609        })
10610    });
10611
10612    // Fatal-only abort check emitted between the pipes of an `&&` / `||`
10613    // chain, where the full errexit check is suppressed. Mirrors ONLY the
10614    // errflag arm of BUILTIN_ERREXIT_CHECK below: an errflag abandons the
10615    // list in zsh, and no connector can consume it.
10616    vm.register_builtin(BUILTIN_FATAL_ABORT_CHECK, |vm, _argc| {
10617        use std::sync::atomic::Ordering;
10618        // c:Src/exec.c:1390 — `while (wc_code(code) == WC_LIST && !breaks &&
10619        // !retflag && !errflag)`: the enclosing list loops test the WHOLE
10620        // errflag. A user interrupt sets ERRFLAG_INT and never ERRFLAG_ERROR
10621        // (signals.c:457), so masking here let the rest of the list run after
10622        // an interrupt:
10623        //   TRAPINT() { print T; return 1 }
10624        //   f() { print A; kill -INT $$; print C }; f; print B
10625        //   zsh: A T      zshrs: A T B
10626        let errflag_set = crate::ported::utils::errflag.load(Ordering::Relaxed) != 0;
10627        if !errflag_set || isset(crate::ported::zsh_h::INTERACTIVE) {
10628            return Value::Int(0);
10629        }
10630        // CONTINUE_ON_ERROR: clear and keep going, as the full check does.
10631        if isset(crate::ported::zsh_h::CONTINUEONERROR) {
10632            crate::ported::utils::errflag
10633                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
10634            return Value::Int(0);
10635        }
10636        // Abort the chain with the failing command's own status intact —
10637        // a cond syntax error left lastval=2 (c:Src/exec.c:5216-5221), and
10638        // that 2 is what zsh exits with. Reading the executor's live
10639        // lastval (not forcing 1) is the same rule the full check uses.
10640        vm.last_status = with_executor(|exec| exec.last_status());
10641        Value::Int(1)
10642    });
10643    vm.register_builtin(BUILTIN_PRINT_EXIT_VALUE, |vm, argc| {
10644        // c:Src/exec.c:5498-5505 — the ANONYMOUS-FUNCTION report in
10645        // execfuncdef:
10646        //     execshfunc(shf, args);
10647        //     ret = lastval;
10648        //     if (isset(PRINTEXITVALUE) && isset(SHINSTDIN) && lastval) {
10649        //         fprintf(stderr, "zsh: exit %lld\n", lastval);
10650        // It has NO `!subsh` term, unlike execcmd_exec's site at
10651        // c:4308-4309. `argc == 1` marks that call site (the compiler
10652        // pushes a 1 before it); `argc == 0` is the per-command site.
10653        let anon_site = argc >= 1 && vm.pop().to_int() != 0;
10654        // c:Src/exec.c:4308-4316 — `if (isset(PRINTEXITVALUE) &&
10655        // isset(SHINSTDIN) && lastval && !subsh) fprintf(stderr,
10656        // "zsh: exit %lld\n", lastval);`
10657        //
10658        // SHINSTDIN keeps this to a shell reading its program from stdin
10659        // (`zsh -f < script`, the interactive shell) — `-c` and script-file
10660        // runs never report. `subsh` keeps it out of forked pipeline stages
10661        // and `(...)` subshells, which is why zsh prints nothing for
10662        // `false | true` or `(exit 3)`. A function BODY is silent for a
10663        // different reason: c:Src/exec.c:6037 `opts[PRINTEXITVALUE] = 0`
10664        // in doshfunc (ported at exec.rs), restored at c:6158.
10665        let lastval = vm.last_status; // c:4309 lastval
10666        if crate::ported::zsh_h::isset(crate::ported::zsh_h::PRINTEXITVALUE) // c:4308
10667            && crate::ported::zsh_h::isset(crate::ported::zsh_h::SHINSTDIN)  // c:4308
10668            && lastval != 0                                                  // c:4309
10669            && (anon_site
10670                || crate::ported::exec::subsh.load(std::sync::atomic::Ordering::Relaxed) == 0)
10671        // c:4309 (`!subsh`; absent at the c:5498 anon-function site)
10672        {
10673            eprintln!("zsh: exit {lastval}"); // c:4311/4313
10674            let _ = std::io::Write::flush(&mut std::io::stderr()); // c:4315 fflush(stderr)
10675        }
10676        Value::Status(0)
10677    });
10678    vm.register_builtin(BUILTIN_ERREXIT_CHECK, |vm, _argc| {
10679        // Returns Value::Int(1) when the caller should jump to the
10680        // current scope's return-patch landing (subshell-end / func-
10681        // end / chunk-end). Returns Value::Int(0) otherwise. Emit
10682        // side at `emit_errexit_check` pairs this with a JumpIfTrue
10683        // → return_patches pattern so the caller can short-circuit.
10684        //
10685        // Four triggers:
10686        //   1. RETFLAG set by a nested `return` / `exit` (eval,
10687        //      sourced file, called function). Unwind THIS scope so
10688        //      the flag propagates outward until something clears it.
10689        //   2. EXIT_PENDING set (mostly subshell-context exits). Same
10690        //      propagation logic.
10691        //   3. `set -e` + nonzero status — the classic errexit path.
10692        //   4. errflag set in non-interactive mode — readonly
10693        //      reassign, bad redirect, parse error mid-expansion etc.
10694        //      Aborts the script (c:Src/init.c loop()).
10695        use std::sync::atomic::Ordering;
10696        let retflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
10697        // c:Src/exec.c:6198-6201 — "If we are in an exit trap, finish it
10698        // first... we wouldn't set exit_pending if we were already in one."
10699        // C's list loop (c:1443) never consults exit_pending at all;
10700        // EXIT_PENDING is zshrs's own deferred-exit channel, and leaving it
10701        // armed while the EXIT trap body runs made every command after the
10702        // trap's FIRST one get skipped:
10703        //   h(){ echo a; echo b }; trap h EXIT; f(){ exit }; f
10704        //   zsh: a b      zshrs: a
10705        // `in_exit_trap` is the same counter C tests at c:6201.
10706        let exit_pending = if crate::ported::signals::in_exit_trap.load(Ordering::Relaxed) != 0 {
10707            0
10708        } else {
10709            crate::ported::builtin::EXIT_PENDING.load(Ordering::Relaxed)
10710        };
10711        // c:Src/exec.c:1571-1603 — `sublist_done:` runs the ZERR trap for
10712        // the sublist that just failed. It is NOT gated on retflag: C only
10713        // consults retflag at the TOP of the list loop (c:1370 `while
10714        // (wc_code(code) == WC_LIST && !breaks && !retflag && !errflag)`),
10715        // which stops the NEXT sublist — the current one still completes
10716        // its sublist_done. So `return 5` fires the ERR trap on its way out.
10717        //
10718        // zshrs's escape short-circuit below returns before ever reaching
10719        // the ZERR fire, so a `return N` inside a try-list skipped the trap:
10720        //   f() { { return 5 } always { print fin } }; f
10721        // printed `fin / err=5` where zsh prints `err=5 / fin / err=5`.
10722        // (Plain `f() { return 5 }` matched by luck — the inner fire was
10723        // missing but the OUTER sublist fired instead, since doshfunc had
10724        // cleared retflag by then and DONETRAP was still 0.)
10725        //
10726        // `exit` is deliberately excluded: C's `exit` goes zexit() →
10727        // realexit(), leaving the process without ever reaching
10728        // sublist_done. Verified: `zsh -fc 'trap "print err" ERR; f(){ exit
10729        // 5 }; f'` prints nothing.
10730        if retflag != 0 && exit_pending == 0 {
10731            let last = vm.last_status;
10732            // c:1598-1603 — same DONETRAP gate as the non-escape path below.
10733            if last != 0 && crate::ported::exec::DONETRAP.load(Ordering::Relaxed) == 0 {
10734                // c:Src/signals.c:1085-1087 — `int obreaks = breaks; int
10735                // oretflag = retflag; int olastval = lastval;` and c:1220-1222
10736                // — `breaks += obreaks; retflag = oretflag;`. dotrapargs
10737                // brackets EVERY trap dispatch with this save/restore because
10738                // the trap body runs as a normal list and would otherwise
10739                // consume the caller's control-flow flags. That matters
10740                // exactly here: we are firing ZERR while retflag is SET, and a
10741                // FUNCTION-form trap (`TRAPZERR() { … }`) goes through
10742                // doshfunc, whose epilogue eats retflag outright
10743                // (c:Src/exec.c:6047-6052 `if (retflag) { retflag = 0; breaks
10744                // = funcsave->breaks; }`). Without the bracket the pending
10745                // `return 5` was swallowed by its own ERR trap and the
10746                // function ran on:
10747                //   TRAPZERR() { print z }; f() { { return 2 } always { : }
10748                //                             print after }; f
10749                // printed `after`, where zsh returns from f.
10750                //
10751                // zshrs's `dotrap` inlines the dispatch and does not carry
10752                // dotrapargs' save/restore, so the bracket lives at this call
10753                // site. lastval is restored too (c:1087 / c:1213 `lastval =
10754                // olastval`) — the trap body's own commands must not become
10755                // the caller's `$?`.
10756                let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
10757                let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
10758                let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
10759                let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
10760                crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
10761                crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
10762                crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
10763                crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed);
10764                // c:1213
10765            }
10766        }
10767        if retflag != 0 || exit_pending != 0 {
10768            if exit_pending != 0 {
10769                // c:Src/builtin.c zexit — the deferred exit carries its
10770                // status in EXIT_VAL; sync it into the VM counter so
10771                // the top-level unwind reports it as the script's exit
10772                // (run_chunk returns vm.last_status). Without this, a
10773                // POSIX-fatal `.` failure exited 127 (bin_dot's return)
10774                // instead of C's exit(1) at Src/exec.c:4383.
10775                vm.last_status = crate::ported::builtin::EXIT_VAL.load(Ordering::Relaxed) & 0xFF;
10776            }
10777            return Value::Int(1);
10778        }
10779        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
10780            & crate::ported::zsh_h::ERRFLAG_ERROR)
10781            != 0;
10782        // c:Src/init.c:1931 — `if (errflag && !interact &&
10783        // !isset(CONTINUEONERROR)) { errexit = 1; break; }` — with
10784        // CONTINUE_ON_ERROR set, the top-level do-while re-enters
10785        // loop() and the NEXT list runs instead of the shell exiting.
10786        // Clear the flag so the next statement starts clean (the
10787        // failed statement's lastval is already in place).
10788        if errflag_set
10789            && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE)
10790            && crate::ported::zsh_h::isset(crate::ported::zsh_h::CONTINUEONERROR)
10791        {
10792            crate::ported::utils::errflag
10793                .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
10794            return Value::Int(0);
10795        }
10796        if errflag_set && !crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE) {
10797            // c:Src/exec.c execlist — every enclosing list loop runs
10798            // `while (... && !errflag)`, so a set errflag breaks the
10799            // CURRENT scope and the check in the enclosing scope
10800            // breaks THAT one, all the way out. Leave errflag SET —
10801            // do NOT convert it to EXIT_PENDING: a process-exit
10802            // signal tunnels through the containment boundaries C
10803            // has, namely eval (Src/builtin.c:6221 `errflag &=
10804            // ~ERRFLAG_ERROR`), source (Src/init.c:1663 same), fork
10805            // boundaries (subshell/cmdsubst — child's errflag dies
10806            // with the child), and the interactive toplevel
10807            // (Src/init.c:139). Those boundaries clear errflag
10808            // themselves and execution continues past them; with
10809            // EXIT_PENDING armed here, `eval 'assoc=(odd)'; echo
10810            // after` aborted the whole script where zsh 5.9 prints
10811            // `after` (eval status 1). Bug #74's function case
10812            // (`f() { local -r x=5; x=10; }; f; echo after`) still
10813            // aborts: the function scope unwinds on THIS check, and
10814            // the caller's next ERREXIT_CHECK sees the still-set
10815            // errflag and unwinds too — exactly C's propagation.
10816            //
10817            // c:Src/init.c:234 — loop() BREAKS on errflag and
10818            // zsh_main exits with the UNTOUCHED lastval, NOT a
10819            // forced 1: `typeset -i x=3#8` (math error during the
10820            // assignment, before typeset sets a status) exits 0 in
10821            // zsh; a cond syntax error set lastval=2 (exec.c:5216-
10822            // 5221) and zsh exits 2; the readonly-reassign case
10823            // exits 1 because ITS lastval is 1. Sync the VM counter
10824            // from the executor's live lastval instead of
10825            // overwriting.
10826            vm.last_status = with_executor(|exec| exec.last_status());
10827            // c:Src/exec.c:1598-1603 — `sublist_done:` runs the ZERR trap
10828            // for the failed sublist BEFORE the enclosing list loop breaks
10829            // on errflag (`while (... && !errflag)` at c:1370). So an
10830            // errflag-setting command (readonly reassign, bad redirect)
10831            // must fire ZERR on its way out, exactly like the retflag
10832            // escape above and the non-escape fall-through below. Without
10833            // this the errflag early-return pre-empted the ZERR block
10834            // further down, so `TRAPZERR() { … }; typeset -r ro=1; ro=2`
10835            // aborted the script (correct) but never fired the trap. Same
10836            // DONETRAP gate + dotrapargs save/restore bracket
10837            // (c:signals.c:1085-1087 / 1213-1222) as the retflag branch:
10838            // a function-form TRAPZERR runs through doshfunc and would
10839            // otherwise consume the caller's breaks/retflag/lastval.
10840            let last = with_executor(|exec| exec.last_status());
10841            if last != 0 && crate::ported::exec::DONETRAP.load(Ordering::Relaxed) == 0 {
10842                let obreaks = crate::ported::builtin::BREAKS.load(Ordering::Relaxed); // c:1085
10843                let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed); // c:1086
10844                let olastval = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1087
10845                                                                                        // c:Src/signals.c:1101 — dotrapargs returns early if errflag
10846                                                                                        // is set, and c:1174/1205-1218 brackets the dispatch with
10847                                                                                        // `traperr = errflag` … restore. The failing assignment left
10848                                                                                        // errflag SET, so the trap body (`print zerr`) would itself
10849                                                                                        // bail on the first op. Clear errflag across the dispatch so
10850                                                                                        // the body runs, then restore it so the script still aborts.
10851                let oerrflag = crate::ported::utils::errflag.load(Ordering::Relaxed); // c:1174
10852                crate::ported::utils::errflag.store(0, Ordering::Relaxed);
10853                let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR); // c:1601
10854                crate::ported::utils::errflag.store(oerrflag, Ordering::Relaxed); // c:1216
10855                crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed); // c:1602
10856                crate::ported::builtin::BREAKS.store(obreaks, Ordering::Relaxed); // c:1220
10857                crate::ported::builtin::RETFLAG.store(oretflag, Ordering::Relaxed); // c:1222
10858                crate::ported::builtin::LASTVAL.store(olastval, Ordering::Relaxed);
10859                // c:1213
10860            }
10861            return Value::Int(1);
10862        }
10863        let last = vm.last_status;
10864        if last == 0 {
10865            return Value::Int(0);
10866        }
10867        // c:Src/exec.c:1598 `if (!this_noerrexit && !donetrap &&
10868        // !this_donetrap)` — gate the ZERR trap fire on DONETRAP so
10869        // an inner sublist (e.g. `false` inside a function) that
10870        // already fired ZERR doesn't fire it AGAIN at the outer
10871        // sublist's post-command check (after the function
10872        // returned non-zero). Bug #303 in docs/BUGS.md. DONETRAP
10873        // is reset at top-level statement boundaries via
10874        // BUILTIN_DONETRAP_RESET (compile_list emit at
10875        // compile_zsh.rs).
10876        let already_done = crate::ported::exec::DONETRAP.load(Ordering::Relaxed) != 0;
10877        // c:Src/exec.c:1652-1653 —
10878        //     if (sigtrapped[SIGZERR] && lastval &&
10879        //         !(noerrexit & NOERREXIT_EXIT)) {
10880        // The ZERR half of the check is gated on the SAME runtime bit as
10881        // the errexit half below. Without this an `&&`/`||` operand — or
10882        // anything it calls — still fired ZERR, so
10883        //   TRAPZERR(){ print E }; f(){ print f; false; }; f && t
10884        // printed E where zsh is silent (C03traps:14, E01options:18).
10885        let zerr_suppressed = (crate::ported::exec::noerrexit.load(Ordering::Relaxed)
10886            & crate::ported::zsh_h::NOERREXIT_EXIT)
10887            != 0; // c:1653
10888        if !already_done && !zerr_suppressed {
10889            // c:Src/signals.c:1245 dotrap(SIGZERR) — canonical ZERR
10890            // trap dispatch. Fires whenever a command exits
10891            // non-zero.
10892            let oretflag = crate::ported::builtin::RETFLAG.load(Ordering::Relaxed);
10893            let _ = crate::ported::signals::dotrap(crate::ported::signals_h::SIGZERR);
10894            // c:1602 — `donetrap = 1;` after firing.
10895            crate::ported::exec::DONETRAP.store(1, Ordering::Relaxed);
10896            // c:Src/signals.c:1201-1203 — a trap body that ran `return N`
10897            // comes back with `lastval = new_trap_return` and `retflag =
10898            // 1`; C's enclosing `while (wc_code(code) == WC_LIST &&
10899            // !breaks && !retflag && !errflag)` (c:Src/exec.c:1443) then
10900            // abandons the rest of the list and the containing function
10901            // returns that status. zshrs's list loop is the VM, which
10902            // reads `vm.last_status` / the executor's counter rather than
10903            // the ported LASTVAL atomic — so mirror the forced status
10904            // across and report "abort" to the caller. Without this
10905            //     fn(){ trap 'print t; return 42' ZERR; false; print B }
10906            // ran `print B` and returned 0.
10907            if oretflag == 0 && crate::ported::builtin::RETFLAG.load(Ordering::Relaxed) != 0 {
10908                let forced = crate::ported::builtin::LASTVAL.load(Ordering::Relaxed); // c:1201
10909                vm.last_status = forced;
10910                with_executor(|exec| exec.set_last_status(forced));
10911                return Value::Int(1); // c:1443 — list loop stops on retflag
10912            }
10913        }
10914        // c:Src/exec.c:1605-1610 — compute errreturn / errexit.
10915        //   errreturn = ERRRETURN && (INTERACTIVE || locallevel || sourcelevel)
10916        //               && !(noerrexit & NOERREXIT_RETURN)
10917        //   errexit   = (ERREXIT || (ERRRETURN && !errreturn))
10918        //               && !(noerrexit & NOERREXIT_EXIT)
10919        let no_err = crate::ported::exec::noerrexit.load(Ordering::Relaxed);
10920        let locallvl = crate::ported::params::locallevel.load(Ordering::Relaxed);
10921        let sourcelvl = crate::ported::init::sourcelevel.load(Ordering::Relaxed);
10922        let errreturn_opt = isset(crate::ported::zsh_h::ERRRETURN);
10923        let in_unwindable_scope =
10924            isset(crate::ported::zsh_h::INTERACTIVE) || locallvl != 0 || sourcelvl != 0;
10925        let errreturn = errreturn_opt
10926            && in_unwindable_scope
10927            && (no_err & crate::ported::zsh_h::NOERREXIT_RETURN) == 0;
10928        if errreturn {
10929            // c:1620-1623 — `retflag = 1; breaks = loops;` — unwind to
10930            // function boundary without exiting the shell.
10931            crate::ported::builtin::RETFLAG.store(1, Ordering::Relaxed);
10932            let loops = crate::ported::builtin::LOOPS.load(Ordering::Relaxed);
10933            crate::ported::builtin::BREAKS.store(loops, Ordering::Relaxed);
10934            return Value::Int(1);
10935        }
10936        let (errexit_on, in_subshell) = with_executor(|exec| {
10937            let on_canonical = isset(ERREXIT) || (errreturn_opt && !errreturn); // c:1608-1609
10938            let on_legacy = opt_state_get("errexit").unwrap_or(false);
10939            (
10940                (on_canonical || on_legacy) && (no_err & crate::ported::zsh_h::NOERREXIT_EXIT) == 0,
10941                !exec.subshell_snapshots.is_empty(),
10942            )
10943        });
10944        if !errexit_on {
10945            return Value::Int(0);
10946        }
10947        // c:Src/exec.c:1611-1618 — under ERR_EXIT a failing command exits the
10948        // whole shell via realexit() FROM THE POINT OF FAILURE, before any
10949        // enclosing `always` arm can run. zsh 5.9.2 (the reference) has no
10950        // `this_noerrexit` deferral, so at top-level / function scope the
10951        // faithful behavior is to process-exit here (zexit fires the SIGEXIT
10952        // trap and exits). This bypasses the always arm, fixing
10953        // `setopt errexit; { false } always { print A }` which wrongly ran the
10954        // always body: the deferred EXIT_PENDING routed the unwind through
10955        // always_entry (compile_zsh.rs re-points it there) and
10956        // SET_TRY_BLOCK_ERROR then cleared the pending exit so the body ran.
10957        if crate::ported::builtin::SUBSHELL_DEPTH.load(Ordering::Relaxed) == 0 {
10958            crate::ported::builtin::zexit(last, crate::ported::zsh_h::ZEXIT_NORMAL);
10959            // c:1618 realexit
10960        }
10961        // Subshell: zshrs runs subshells in-process, so it cannot process-exit
10962        // the whole shell here — defer to the subshell-end unwind.
10963        crate::ported::builtin::EXIT_VAL.store(last, Ordering::Relaxed);
10964        crate::ported::builtin::EXIT_PENDING.store(1, Ordering::Relaxed);
10965        let _ = in_subshell;
10966        Value::Int(1)
10967    });
10968
10969    // BUILTIN_ASSIGN_ONLY_STATUS — status of an assignment-only
10970    // simple command. c:Src/exec.c:3393-3396 (execcmd_exec, no
10971    // command word + varspc): `if (errflag) lastval = 1; else
10972    // lastval = cmdoutval;`; same shape at c:1322 (execsimple
10973    // WC_ASSIGN: `lv = (errflag ? errflag : cmdoutval)`) and
10974    // c:3977 (nullexec=2 redir variant). cmdoutval is the exit of
10975    // a `$()` that ran in an RHS (already in vm.last_status via
10976    // compile_assign's per-assign SetStatus), 0 otherwise. The
10977    // store goes to the canonical LASTVAL too — that IS C's single
10978    // `lastval` global; without it the errflag-abort path
10979    // (BUILTIN_ERREXIT_CHECK trigger 4) syncs vm.last_status from
10980    // a stale LASTVAL and `readonly r=1; r=2` exited 0, not 1.
10981    vm.register_builtin(BUILTIN_ASSIGN_ONLY_STATUS, |vm, _argc| {
10982        use std::sync::atomic::Ordering;
10983        let had_cmd_subst = vm.pop().to_int() != 0;
10984        let errflag_set = (crate::ported::utils::errflag.load(Ordering::Relaxed)
10985            & crate::ported::zsh_h::ERRFLAG_ERROR)
10986            != 0;
10987        // c:Src/exec.c addvars — `if (!pm) { lastval = 1; if
10988        // (!cmdoutval) cmdoutval = 1; }` (assignment-failed cheat).
10989        let assign_failed = ASSIGN_FAILED_FLAG.swap(false, std::sync::atomic::Ordering::Relaxed);
10990        let status = if errflag_set || assign_failed {
10991            1 // c:Src/exec.c:3394 `lastval = 1` / addvars cmdoutval=1
10992        } else if had_cmd_subst {
10993            vm.last_status // c:3396 `lastval = cmdoutval` (subst exit)
10994        } else {
10995            0 // c:3396 `lastval = cmdoutval` (cmdoutval = 0)
10996        };
10997        with_executor(|exec| exec.set_last_status(status));
10998        // c:Src/jobs.c deletefilelist — a `=(cmd)` temp file is bound to the
10999        // JOB of the command that created it and unlinked when that command
11000        // completes (Src/exec.c:5588 for the shfunc case; the simple-command
11001        // job's filelist likewise). An assignment-only command like
11002        // `f==(cmd)` has no consuming builtin/exec, so the PsubFdGuard that
11003        // cleans consuming commands never fires — the temp leaked and a later
11004        // `$(<$f)` / `[[ -f $f ]]` still saw it, where zsh deletes it at the
11005        // end of the assignment (verified: even `f==(x) && cat $f` fails).
11006        // Clean here so the assignment command is the temp's job boundary.
11007        close_pending_psub_fds();
11008        Value::Status(status)
11009    });
11010
11011    // `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
11012    // Pops [name, op_byte, rhs] (rhs popped first). Returns the modified
11013    // value as Value::Str. Handles unset/empty distinction (`:-` etc.
11014    // treat empty same as unset, matching POSIX).
11015    // BUILTIN_PARAM_DEFAULT_FAMILY — `${var-x}` / `${var:-x}` / `${var=x}` /
11016    // `${var:=x}` / `${var?x}` / `${var:?x}` / `${var+x}` / `${var:+x}`.
11017    // PURE PASSTHRU: pop name + op + rhs, reconstruct the canonical
11018    // brace expression, hand to `subst::paramsubst` (C port of
11019    // `Src/subst.c::paramsubst`). All "missing vs empty" gating,
11020    // nounset suppression, default-evaluation, and elide-empty-words
11021    // semantics live inside paramsubst.
11022    vm.register_builtin(BUILTIN_PARAM_DEFAULT_FAMILY, |vm, _argc| {
11023        let rhs = vm.pop().to_str();
11024        let op = vm.pop().to_int() as u8;
11025        let name = vm.pop().to_str();
11026        // op=8 is the `${+name}` set-test prefix form (distinct from the
11027        // `${name+rhs}` substitute-if-set suffix form which is op=7).
11028        // Per compile_zsh.rs::parse_param_modifier: the `+` is emitted as
11029        // a leading sigil and `rhs` is empty.
11030        let body = if op == 8 {
11031            format!("${{+{}}}", name)
11032        } else {
11033            let op_str = match op {
11034                0 => ":-",
11035                1 => ":=",
11036                2 => ":?",
11037                3 => ":+",
11038                4 => "-",
11039                5 => "=",
11040                6 => "?",
11041                7 => "+",
11042                _ => "-",
11043            };
11044            format!("${{{}{}{}}}", name, op_str, rhs)
11045        };
11046        paramsubst_to_value(&body)
11047    });
11048
11049    // `${var:offset[:length]}` — substring. Pops [name, offset, length].
11050    // length == -1 means "rest of string". Negative offset counts from end.
11051    // BUILTIN_PARAM_SUBSTRING — `${var:offset:length}` literal-int form.
11052    // PURE PASSTHRU: reconstruct `${name:offset:length}` and route
11053    // through `subst::paramsubst`. Length sentinel `i64::MIN` =
11054    // "no length given" (omit the `:length` portion).
11055    //
11056    // c:Src/subst.c:1571,3781 — `${name:-N}` is the colon-default
11057    // operator, NOT a substring with negative offset. zsh's lexical
11058    // rule disambiguates via a literal space: `${name: -N}` (space
11059    // before `-`) is the substring form. The reconstructed body MUST
11060    // preserve that space when offset < 0; otherwise paramsubst's
11061    // `:-` dispatch fires on the synthesized `${name:-N}` body and
11062    // returns N as the unset-default instead of slicing the last N
11063    // chars. Length-form `${name:-N:M}` has the same trap.
11064    vm.register_builtin(BUILTIN_PARAM_SUBSTRING, |vm, _argc| {
11065        let length = vm.pop().to_int();
11066        let offset = vm.pop().to_int();
11067        let name = vm.pop().to_str();
11068        // !!! DASH-STRICT GATE !!! dash/ash have no `${var:offset:length}`
11069        // substring expansion (it is a "Bad substitution"); bash/ksh/sh do.
11070        if crate::dash_mode::dash_strict() {
11071            crate::ported::utils::zerr("bad substitution");
11072            crate::ported::utils::errflag.fetch_or(
11073                crate::ported::zsh_h::ERRFLAG_ERROR,
11074                std::sync::atomic::Ordering::Relaxed,
11075            );
11076            with_executor(|exec| exec.set_last_status(1));
11077            return Value::str("");
11078        }
11079        let off_sep = if offset < 0 { " " } else { "" };
11080        let body = if length == i64::MIN {
11081            format!("${{{}:{}{}}}", name, off_sep, offset)
11082        } else {
11083            format!("${{{}:{}{}:{}}}", name, off_sep, offset, length)
11084        };
11085        paramsubst_to_value(&body)
11086    });
11087
11088    // BUILTIN_PARAM_SUBSTRING_EXPR — `${var:offset_expr[:length_expr]}` form.
11089    // PURE PASSTHRU: rebuild `${name:offset:length}` using the
11090    // expression text verbatim (paramsubst's offset/length
11091    // parser evaluates arith / param refs itself).
11092    //
11093    // c:Src/subst.c:1571,3781 — same `:-` disambiguation trap as
11094    // BUILTIN_PARAM_SUBSTRING. The expression text may itself start
11095    // with `-` (e.g. `${VAR:$((-1))}` arith resolves at the body-
11096    // assembly layer in some upstream paths, leaving `-1` in
11097    // off_expr). Insert a leading space when off_expr starts with
11098    // `-` so paramsubst's check_colon_subscript (subst.c:1571)
11099    // accepts the operand as a math expression instead of the
11100    // `:-` operator catching it.
11101    vm.register_builtin(BUILTIN_PARAM_SUBSTRING_EXPR, |vm, _argc| {
11102        let has_len = vm.pop().to_int() != 0;
11103        let len_expr = vm.pop().to_str();
11104        let off_expr = vm.pop().to_str();
11105        let name = vm.pop().to_str();
11106        let off_sep = if off_expr.starts_with('-') { " " } else { "" };
11107        let body = if has_len {
11108            format!("${{{}:{}{}:{}}}", name, off_sep, off_expr, len_expr)
11109        } else {
11110            format!("${{{}:{}{}}}", name, off_sep, off_expr)
11111        };
11112        paramsubst_to_value(&body)
11113    });
11114
11115    // `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}`
11116    // Pops [name, pattern, op_byte]. op: 0=`#` short-prefix, 1=`##` long,
11117    // 2=`%` short-suffix, 3=`%%` long. Glob-pattern matching via the
11118    // existing glob_match_static helper.
11119    // BUILTIN_PARAM_STRIP — `${var#pat}` / `${var##pat}` / `${var%pat}` /
11120    // `${var%%pat}`. PURE PASSTHRU: reconstruct the brace expression
11121    // and route through `subst::paramsubst`. (M)/(S) flags arrive
11122    // through SUB_FLAGS (already inside paramsubst's scope), so we
11123    // just clear the bridge-side cached read.
11124    vm.register_builtin(BUILTIN_PARAM_STRIP, |vm, _argc| {
11125        let _dq_flag = vm.pop().to_int() != 0;
11126        let op = vm.pop().to_int() as u8;
11127        let pattern = vm.pop().to_str();
11128        let name = vm.pop().to_str();
11129        let op_str = match op {
11130            0 => "#",
11131            1 => "##",
11132            2 => "%",
11133            3 => "%%",
11134            _ => "#",
11135        };
11136        let body = format!("${{{}{}{}}}", name, op_str, pattern);
11137        paramsubst_to_value(&body)
11138    });
11139
11140    // `$((expr))` — pops [expr_string], evaluates via MathEval which
11141    // honors integer-vs-float distinction (zsh-compatible). Returns
11142    // the result as Value::Str so it can be Concat'd into surrounding
11143    // word context.
11144    vm.register_builtin(BUILTIN_ARITH_EVAL, |vm, _argc| {
11145        // Pure path: evaluate expr, return string. errflag may be
11146        // set by arithsubst on math error; the caller decides
11147        // whether to clear it. For `(( ... ))` (math command) the
11148        // compile_arith path clears via BUILTIN_ARITH_CMD_FINISH;
11149        // for `$((... ))` (substitution inside another command)
11150        // errflag stays set so the surrounding command aborts —
11151        // matches c:Src/math.c "math errors propagate as errflag
11152        // through the containing word expansion".
11153        let expr = vm.pop().to_str();
11154        let result = crate::ported::subst::arithsubst(&expr, "", "");
11155        let _ = vm; // silence unused warning when no math error path mutates
11156        Value::str(result)
11157    });
11158
11159    // After-call hook used by compile_arith's `(( ... ))` path: when
11160    // arithsubst set errflag (math error), clear it and signal
11161    // status=2 in vm.last_status — matches zsh's c:exec.c arith-
11162    // failure: the math command exits 2 and the script continues.
11163    vm.register_builtin(BUILTIN_ARITH_CMD_FINISH, |vm, _argc| {
11164        use std::sync::atomic::Ordering;
11165        let live = crate::ported::utils::errflag.load(Ordering::Relaxed);
11166        let err = live & crate::ported::zsh_h::ERRFLAG_ERROR;
11167        let hard = live & crate::ported::zsh_h::ERRFLAG_HARD;
11168        if err != 0 {
11169            // c:Src/subst.c:3344 — when `${var:?msg}` fires, errflag
11170            // is OR'd with ERRFLAG_HARD to signal a script-abort
11171            // error (vs a recoverable math error like `$((1/0))`).
11172            // Clear only the ERRFLAG_ERROR bit; preserve
11173            // ERRFLAG_HARD so the next ERREXIT_CHECK aborts the
11174            // script. Bug #193 in docs/BUGS.md.
11175            if hard != 0 {
11176                // Keep ERRFLAG_HARD AND ERRFLAG_ERROR set so the
11177                // script-abort gate downstream still fires.
11178                vm.last_status = 2;
11179                Value::Status(2)
11180            } else {
11181                crate::ported::utils::errflag
11182                    .fetch_and(!crate::ported::zsh_h::ERRFLAG_ERROR, Ordering::Relaxed);
11183                vm.last_status = 2;
11184                Value::Status(2)
11185            }
11186        } else {
11187            Value::Status(vm.last_status)
11188        }
11189    });
11190
11191    // `$(cmd)` — pops [cmd_string], routes through
11192    // run_command_substitution which performs an in-process pipe-capture.
11193    // Avoids the Op::CmdSubst sub-chunk word-emit bug
11194    // (`printf "a\nb"` produced "anb" via that path). Returns trimmed
11195    // output (trailing newlines stripped per POSIX cmd-sub semantics).
11196    vm.register_builtin(BUILTIN_CMD_SUBST_TEXT, |vm, _argc| {
11197        let cmd = vm.pop().to_str();
11198        // Inherit live $? into the inner shell so cmd-subst sees the
11199        // parent's most recent exit. Same rationale as the mode-3
11200        // backtick path above.
11201        let live_status = vm.last_status;
11202        let result = with_executor(|exec| {
11203            exec.set_last_status(live_status);
11204            exec.run_command_substitution(&cmd)
11205        });
11206        // Mirror run_command_substitution's exec.last_status side
11207        // effect into the VM's live counter so a containing
11208        // assignment's BUILTIN_SET_VAR — which reads vm.last_status
11209        // — sees the cmd-subst's exit. Without this, `a=$(false);
11210        // echo $?` reads stale 0 (vm.last_status was zeroed by
11211        // compile_assign's prelude SetStatus, and run_cmd_subst only
11212        // updated exec.last_status). Pull the value back through
11213        // exec since it owns the canonical post-subst record.
11214        let cs_status = with_executor(|exec| exec.last_status());
11215        vm.last_status = cs_status;
11216        // c:Src/exec.c — a command substitution running during a
11217        // command's word expansion makes its exit the status of an
11218        // otherwise-empty command (`$(exit 5)` → 5). Flag it so
11219        // BUILTIN_EXEC_DYNAMIC's null-command branch keeps `$?` instead
11220        // of resetting to 0.
11221        crate::ported::exec::use_cmdoutval.store(1, std::sync::atomic::Ordering::Relaxed);
11222        Value::str(result)
11223    });
11224
11225    // Text-based word expansion. Pops [preserved_text, mode_byte].
11226    // mode_byte:
11227    //   0 = Default — expand_string + xpandbraces + expand_glob
11228    //   1 = DoubleQuoted — strip outer `"…"`, expand_string only
11229    //         (no brace, no glob — DQ semantics)
11230    //   2 = SingleQuoted — strip outer `'…'`, no expansion
11231    //         (kept for symmetry; Snull early-return covers most SQ)
11232    //   3 = AltBackquote — strip backticks, run as cmd-sub
11233    //   7 = RedirTarget — same as Default but glob gated on MULTIOS
11234    //         (c:Src/glob.c:2161-2167 xpandredir)
11235    //   8 = unquoted assignment VALUE — same as 6 plus PREFORK_SINGLE
11236    //         (c:Src/exec.c:2603 / :4239-4241)
11237    // Single result → Value::str; multi → Value::Array.
11238    vm.register_builtin(BUILTIN_EXPAND_TEXT, |vm, _argc| {
11239        let mode = vm.pop().to_int() as u8;
11240        let text = vm.pop().to_str();
11241        // Sync vm.last_status → exec.last_status so cmd-subst (mode 3)
11242        // and any nested $? reads inside singsub see the live `$?`
11243        // from the most recent VM op. Without this, cmd-subst inside
11244        // arg-eval saw a stale exec.last_status that was zeroed at
11245        // the start of the current statement. Direct port of zsh's
11246        // pre-cmdsubst lastval propagation per Src/exec.c:4770.
11247        let live_status = vm.last_status;
11248        with_executor(|exec| exec.set_last_status(live_status));
11249        let result_value = with_executor(|exec| match mode {
11250            // Mode 1 = DoubleQuoted (argument context).
11251            // Mode 5 = DoubleQuoted in scalar-assignment context.
11252            // Both share the same DQ unescape pre-processing; mode 5
11253            // additionally bumps `in_scalar_assign` so subst_port's
11254            // paramsubst sees ssub=true and suppresses split flags
11255            // `(f)` / `(s:STR:)` / `(0)` per Src/subst.c:1759 +
11256            // Src/exec.c::addvars line 2546 (the PREFORK_SINGLE bit
11257            // C zsh sets when prefork-ing the assignment RHS).
11258            1 | 5 => {
11259                // DoubleQuoted: strip outer `"…"` if present. In DQ
11260                // context, `\` escapes the DQ-special chars `$`, `` ` ``,
11261                // `"`, `\`. zsh's expand_string expects the lexer's
11262                // `\0X` literal-marker for an already-escaped char, so
11263                // we pre-process: `\$` → `\0$`, `\\` → `\0\`, etc. Then
11264                // expand_string handles the rest.
11265                let inner = if text.len() >= 2 && text.starts_with('"') && text.ends_with('"') {
11266                    &text[1..text.len() - 1]
11267                } else {
11268                    text.as_str()
11269                };
11270                // The lexer's dquote_parse (Src/lex.c) already tokenized
11271                // DQ contents: `$` → Qstring (\u{8c}), `\$`/`\\`/`\"`/
11272                // `` \` `` → Bnull (\u{9f}) + literal. Stringsubst /
11273                // multsub recognize these markers natively. We pass
11274                // `inner` through verbatim — no re-tokenization needed.
11275                let prepped: String = inner.to_string();
11276                // Tell parameter-flag application that we're inside
11277                // double quotes — array-only flags ((o), (O), (n),
11278                // (i), (M), (u)) must be no-ops here per zsh.
11279                exec.in_dq_context += 1;
11280                if mode == 5 {
11281                    exec.in_scalar_assign += 1;
11282                }
11283                // Mode 1 = argv DQ word; mode 5 = scalar-assign RHS.
11284                // In C zsh, the corresponding prefork-on-list paths
11285                // are: argv → `prefork(argv_list, 0)` returns multi-
11286                // word LinkList (Src/exec.c::execcmd), assignment →
11287                // `prefork(rhs_list, PREFORK_SINGLE|PREFORK_ASSIGN)`
11288                // returns single-word (Src/exec.c::addvars line
11289                // 2546). zshrs's `multsub` (Src/subst.c:544) is the
11290                // multi-result variant; `singsub` (Src/subst.c:514)
11291                // asserts ≤1 node. Mode 5 keeps singsub; mode 1
11292                // switches to multsub so `"${(@)arr}"`/`"$@"`/
11293                // `"${arr[@]}"` in argv context emit multiple words
11294                // as the C path would.
11295                // c:Src/lex.c untokenize — the final argv pass C runs
11296                // on every expanded word (glob.c:1862 / exec.c) drops
11297                // the Nularg empty-word sentinel remnulargs left in
11298                // place and folds any remaining token chars. Without
11299                // it, quoted splits with empty pieces
11300                // ("${(s:|:)x}" on "|a|b|") leak U+00A1 into argv.
11301                let result_value = if mode == 5 {
11302                    let out = crate::ported::subst::singsub(&prepped);
11303                    Value::str(crate::ported::lex::untokenize(&out))
11304                } else {
11305                    let (_first, nodes, _ms_ws, _ret) = crate::ported::subst::multsub(&prepped, 0);
11306                    // c:Src/subst.c:655 — multsub returns Vec::new()
11307                    // for zero-word results (quoted array splat that
11308                    // resolved to empty array). Surface as
11309                    // Value::Array(vec![]) so the downstream array
11310                    // assignment / argv flattening sees ZERO args.
11311                    // Previous Rust port returned Value::str("") which
11312                    // surfaced as ONE empty arg. Bug #120 in
11313                    // docs/BUGS.md.
11314                    if nodes.is_empty() {
11315                        Value::array(Vec::new())
11316                    } else if nodes.len() == 1 {
11317                        Value::str(crate::ported::lex::untokenize(
11318                            &nodes.into_iter().next().unwrap(),
11319                        ))
11320                    } else {
11321                        Value::array(
11322                            nodes
11323                                .into_iter()
11324                                .map(|n| Value::str(crate::ported::lex::untokenize(&n)))
11325                                .collect(),
11326                        )
11327                    }
11328                };
11329                if mode == 5 {
11330                    exec.in_scalar_assign -= 1;
11331                }
11332                exec.in_dq_context -= 1;
11333                result_value
11334            }
11335            2 => {
11336                // SingleQuoted: pure literal, strip outer `'…'`.
11337                let inner = if text.len() >= 2 && text.starts_with('\'') && text.ends_with('\'') {
11338                    &text[1..text.len() - 1]
11339                } else {
11340                    text.as_str()
11341                };
11342                Value::str(inner.to_string())
11343            }
11344            3 => {
11345                // Backquote command sub: strip outer backticks.
11346                // Word-split the result on IFS when the surrounding
11347                // word is unquoted — zsh: `print -l \`echo a b c\``
11348                // emits one arg per word. The $(…) path applies the
11349                // same split via BUILTIN_WORD_SPLIT after capture; do
11350                // the equivalent here for the `…` form.
11351                let inner = if text.len() >= 2 && text.starts_with('`') && text.ends_with('`') {
11352                    &text[1..text.len() - 1]
11353                } else {
11354                    text.as_str()
11355                };
11356                // Apply the live VM status before running the inner
11357                // shell so the inherited $? matches zsh's lastval
11358                // propagation.
11359                exec.set_last_status(live_status);
11360                let captured = exec.run_command_substitution(inner);
11361                let trimmed = captured.trim_end_matches('\n');
11362                if exec.in_dq_context > 0 {
11363                    Value::str(trimmed.to_string())
11364                } else {
11365                    let ifs = exec.scalar("IFS").unwrap_or_else(|| " \t\n".to_string());
11366                    let parts: Vec<Value> = trimmed
11367                        .split(|c: char| ifs.contains(c))
11368                        .filter(|s| !s.is_empty())
11369                        .map(|s| Value::str(s.to_string()))
11370                        .collect();
11371                    if parts.is_empty() {
11372                        Value::str(String::new())
11373                    } else if parts.len() == 1 {
11374                        parts.into_iter().next().unwrap()
11375                    } else {
11376                        Value::array(parts)
11377                    }
11378                }
11379            }
11380            4 => {
11381                // HeredocBody: expand variables / command-subst / arith
11382                // but NOT glob or brace. Heredoc lines like `[42]` must
11383                // pass through verbatim — running them through the
11384                // default pipeline triggers NOMATCH on the literal.
11385                Value::str(crate::ported::subst::singsub(&text))
11386            }
11387            _ => {
11388                // Default (unquoted): the lexer's gettokstr already
11389                // tokenized backslash-escapes (`\$` → Bnull+$, etc).
11390                // Pass `text` through verbatim — multsub/stringsubst
11391                // recognize the markers natively. No bridge-side
11392                // re-tokenization needed.
11393                //
11394                // Mode 6 = unquoted RHS in scalar-assign context.
11395                // Pass PREFORK_ASSIGN so prefork's filesub colon-walk
11396                // fires per c:Src/exec.c:2546.
11397                let prepped: String = text.clone();
11398                if std::env::var("ZSHRS_TRACE_DEFP").is_ok() {
11399                    eprintln!(
11400                        "[TRACE_DEFP] text={:?} prepped={:?} mode={}",
11401                        text, prepped, mode
11402                    );
11403                }
11404                // Mode 8 = the unquoted VALUE of a `NAME=VALUE` assignment
11405                // (bare statement or typeset-family argument). C preforks
11406                // exactly that with `PREFORK_SINGLE|PREFORK_ASSIGN`
11407                // (c:Src/exec.c:2603 and c:Src/exec.c:4239-4241); the
11408                // PREFORK_SINGLE half is paramsubst's `ssub`
11409                // (c:Src/subst.c:1761), which gates off the forced split at
11410                // c:Src/subst.c:3913.
11411                let pf_flags = if mode == 8 {
11412                    crate::ported::zsh_h::PREFORK_SINGLE | crate::ported::zsh_h::PREFORK_ASSIGN
11413                } else if mode == 6 {
11414                    crate::ported::zsh_h::PREFORK_ASSIGN
11415                } else {
11416                    0
11417                };
11418                // c:Src/subst.c:544+ — `multsub(&prepped, 0)` is the
11419                // unquoted-argv equivalent of zsh's `prefork(list,
11420                // 0, NULL)` for a single-element list. Returns the
11421                // post-expansion node list (Vec<String>) so array-
11422                // shape results (e.g. `${a:e}`, `${a[@]}`,
11423                // `${(s::)str}`) splat into multiple argv words.
11424                // singsub() collapses to one string and discards the
11425                // splat — parity bug #28 (whole-array modifier).
11426                // c:Src/subst.c:3929-3932 — `if (isarr) l->list.flags |=
11427                // LF_ARRAY; else l->list.flags &= ~LF_ARRAY;`. C's paramsubst
11428                // holds the LinkList and stamps its `isarr` on it directly;
11429                // the Rust port hands the same bit back through the
11430                // `PARAMSUBST_LF_ARRAY` thread-local (subst.rs:20511) — set at
11431                // subst.rs:17796 (`isarr != 0 && !forced_split_to_one`) and
11432                // reset to false at the top of EVERY paramsubst
11433                // (subst.rs:3891 / subst.rs:17445).
11434                //
11435                // Clear it BEFORE multsub so a segment that runs no paramsubst
11436                // at all reads false instead of some earlier expansion's
11437                // value. `multsub`'s own `isarr` return cannot be used for
11438                // this: an unquoted `$(cmd)` / `` `cmd` `` sets LF_ARRAY
11439                // unconditionally (subst.rs:897 / :1159, c:Src/subst.c:285-286
11440                // `if (!qt) list->list.flags |= LF_ARRAY;` and c:331), so
11441                // `x$(true)y` would look array-shaped when zsh keeps that
11442                // word (verified: it is the single word `xy`).
11443                //
11444                // Every paramsubst re-initialises the cell on entry, so
11445                // clearing it here cannot disturb subst.rs's own readers
11446                // (stringsubst reads it immediately after each paramsubst
11447                // call, subst.rs:1094).
11448                crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.set(false));
11449                let (_first, nodes, _ms_ws, _ret) =
11450                    crate::ported::subst::multsub(&prepped, pf_flags);
11451                // Read immediately: brace expansion / filesub / glob below can
11452                // re-enter paramsubst and overwrite the cell. `seg_is_array` is
11453                // the array-ness of the OUTERMOST paramsubst in this segment —
11454                // C's c:4245 `if (isarr)` for the same expansion. Paired with
11455                // `seg_zero_words` it separates the two empty shapes for the
11456                // empty-result arm further down (c:4362 vs c:4464).
11457                let seg_is_array = crate::ported::subst::PARAMSUBST_LF_ARRAY.with(|c| c.get());
11458                let seg_zero_words = nodes.is_empty();
11459                if std::env::var("ZSHRS_TRACE_MULTSUB").is_ok() {
11460                    eprintln!("[TRACE_MULTSUB] prepped={:?} nodes={:?}", prepped, nodes);
11461                }
11462                // c:Src/subst.c:166 — xpandbraces runs AFTER prefork's
11463                // substitution pass and BEFORE untokenize/glob. Per
11464                // word, scan for Inbrace TOKEN and expand. Words that
11465                // don't contain Inbrace TOKEN pass through unchanged.
11466                // Brace expansion is done here (inside the bridge
11467                // default arm) instead of via a post-EXPAND_TEXT
11468                // BRACE_EXPAND emit because untokenize (line below)
11469                // strips TOKEN bytes, after which the strict-TOKEN
11470                // xpandbraces gate would no longer match.
11471                let brace_ccl = opt_state_get("braceccl").unwrap_or(false);
11472                // c:Src/options.c — `no_brace_expand` (negated
11473                // `braceexpand`) gates brace expansion entirely.
11474                // When off, `{a,b}` stays literal.
11475                // c:Src/subst.c:170 — `if (unset(IGNOREBRACES) && !(flags &
11476                // PREFORK_SINGLE))` guards the `xpandbraces` loop, so a word
11477                // preforked as a scalar (assignment VALUE, mode 8) is NEVER
11478                // brace-expanded: `local x={a,b}` stores the five literal
11479                // characters. This pass stands in for prefork's loop, so it
11480                // owes the same guard.
11481                let brace_expand = opt_state_get("braceexpand").unwrap_or(true)
11482                    && (pf_flags & crate::ported::zsh_h::PREFORK_SINGLE) == 0; // c:170
11483                let pre_brace: Vec<String> = if nodes.is_empty() {
11484                    vec![String::new()]
11485                } else {
11486                    nodes
11487                };
11488                let brace_expanded: Vec<String> = pre_brace
11489                    .into_iter()
11490                    .flat_map(|w| {
11491                        if brace_expand && w.contains('\u{8f}') {
11492                            crate::ported::glob::xpandbraces(&w, brace_ccl)
11493                        } else {
11494                            vec![w]
11495                        }
11496                    })
11497                    .collect();
11498                // zsh stores the option as `glob` (default ON);
11499                // `setopt noglob` writes `glob=false`. Honor either
11500                // form so the dispatcher behaves the same as zsh.
11501                // Mode 7 = redirect-target word: glob only under
11502                // MULTIOS (c:Src/glob.c:2161-2167 xpandredir,
11503                // "Globbing is only done for multios.").
11504                let noglob = opt_state_get("noglob").unwrap_or(false)
11505                    || opt_state_get("GLOB").map(|v| !v).unwrap_or(false)
11506                    || !opt_state_get("glob").unwrap_or(true)
11507                    || (mode == 7 && !opt_state_get("multios").unwrap_or(true));
11508                let parts: Vec<String> = brace_expanded
11509                    .into_iter()
11510                    .flat_map(|s| {
11511                        // The lexer leaves glob metacharacters in their
11512                        // META-encoded form: `*` → `\u{87}`, `?` →
11513                        // `\u{86}`, `[` → `\u{91}`, etc. expand_string
11514                        // doesn't untokenize them, so the literal-char
11515                        // checks below (`s.contains('*')`) would miss
11516                        // every real glob and skip expand_glob — that
11517                        // bug let `echo *.toml` print the literal
11518                        // `*.toml` because the META `\u{87}` never
11519                        // matched the literal `*`. Untokenize once so
11520                        // the metacharacter checks see the canonical
11521                        // form. zsh's pattern.c expects `*` etc. as
11522                        // bare chars at the glob layer.
11523                        // c:Src/pattern.c:4306 haswilds on the still-
11524                        // TOKENIZED word (pre-untokenize), matching C's
11525                        // zglob entry gate (Src/glob.c:1230) which runs
11526                        // on the lexer-tokenized string. haswilds
11527                        // matches ONLY token codes: source-level
11528                        // `*.toml` carries Star and fires; bare literal
11529                        // `[`/`*`/`?` from `$'...'` decode, `:-`
11530                        // default values, or nested-substitution
11531                        // results were never shtokenize'd (C
11532                        // subst.c:3231 sets globsubst=0 in the `:-`
11533                        // arm) and stay literal — bug #625. Plain
11534                        // multibyte text (`↔`) never matches a token
11535                        // codepoint — bug #627.
11536                        let is_glob_pre = !noglob && crate::ported::pattern::haswilds(&s);
11537                        // c:Src/glob.c:1230 — zglob receives the word in
11538                        // LEXER-TOKENIZED form and only untokenizes it when
11539                        // it declines to glob (c:1232) or falls back to the
11540                        // literal (c:1884). The token form is what makes a
11541                        // QUOTED metachar distinguishable from an active one:
11542                        // in `*(.e['[[ $REPLY == a* ]]'])` the body's `]`
11543                        // bytes are raw ASCII while the qualifier's real
11544                        // closer is `Outbrack`, which is exactly how
11545                        // checkglobqual (c:1163/1170, testing Outpar/Inpar)
11546                        // and get_strarg's tokenized delimiter half
11547                        // (Src/subst.c:1379-1390) find the true end. zshrs
11548                        // untokenized here, one line before the glob layer,
11549                        // so every quoted metachar became indistinguishable
11550                        // from an active one and the qualifier parser closed
11551                        // on the first quoted `]`. Keep the tokenized word
11552                        // for the glob call; the untokenized form still
11553                        // drives the non-glob arms below.
11554                        let s_tok = s.clone();
11555                        let s = crate::lex::untokenize(&s);
11556                        // Skip glob expansion for assignment-shaped
11557                        // words (`NAME=value`). zsh doesn't expand the
11558                        // RHS of an assignment as a path glob unless
11559                        // `setopt globassign` is set, and feeding such
11560                        // words through expand_glob makes NOMATCH
11561                        // (default ON) fire spuriously on
11562                        // `integer i=2*3+1`, `path=*.rs`, etc.
11563                        let is_assignment_shape = {
11564                            let bytes = s.as_bytes();
11565                            let mut i = 0;
11566                            if !bytes.is_empty()
11567                                && (bytes[0] == b'_' || bytes[0].is_ascii_alphabetic())
11568                            {
11569                                i += 1;
11570                                while i < bytes.len()
11571                                    && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric())
11572                                {
11573                                    i += 1;
11574                                }
11575                                i < bytes.len() && bytes[i] == b'='
11576                            } else {
11577                                false
11578                            }
11579                        };
11580                        // Glob-trigger decision: pre-untokenize
11581                        // haswilds_tokens_only result (computed above
11582                        // before the untokenize that collapses META
11583                        // tokens to their ASCII forms). The TOKEN-only
11584                        // gate matches C `Src/pattern.c:4306-4376`
11585                        // exactly — only Inbrack/Star/Quest/Inpar/Bar/
11586                        // Inang/Pound/Hat token codes count as wild,
11587                        // not their literal ASCII counterparts. Source-
11588                        // level `*.toml` carries Star token so globs;
11589                        // `$'…'`-decoded `[abc]` carries bare `[` so
11590                        // stays literal. Bug #625.
11591                        // c:Src/subst.c:677-678 — `filesub`'s own precondition
11592                        // for the PREFORK_TYPESET arm is `(*namptr)[1] &&
11593                        // strchr(*namptr + 1, Equals)`: an `=` anywhere but
11594                        // position 0. There is NO identifier test. Gating the
11595                        // arm below on `is_assignment_shape` — which demands a
11596                        // leading `[A-Za-z_][A-Za-z0-9_]*` — therefore lost every
11597                        // word whose `=` is not preceded by a bare identifier:
11598                        //   setopt magicequalsubst; print -r -- x:y=~/z
11599                        //   zsh: x:y=/home/u/z      zshrs: x:y=~/z
11600                        // Same for `ME:a=~/x`, `1abc=~/x` and `-o=~/x`, the last
11601                        // of which is the common `--prefix=~/dir` shape.
11602                        // `is_assignment_shape` stays on the GLOB arm, where the
11603                        // identifier test is the right one (that arm is about
11604                        // not path-globbing an assignment RHS without
11605                        // GLOB_ASSIGN).
11606                        // c:Src/subst.c:678 — filesub's own gate is
11607                        // `strchr(*namptr + 1, Equals)`: the Equals TOKEN,
11608                        // which the lexer emits ONLY for an unquoted `=`.
11609                        // Test the still-TOKENIZED word so a quoted `=` does
11610                        // not arm the arm below.
11611                        let has_nonleading_equals = s_tok
11612                            .chars()
11613                            .skip(1)
11614                            .any(|c| c == crate::ported::zsh_h::Equals);
11615                        if is_glob_pre && !is_assignment_shape {
11616                            exec.expand_glob(&s_tok)
11617                        } else if has_nonleading_equals
11618                            && crate::ported::zsh_h::isset(crate::ported::zsh_h::MAGICEQUALSUBST)
11619                        {
11620                            // c:Src/exec.c:3353 — when MAGIC_EQUAL_SUBST is set
11621                            // on a non-typeset command, esprefork = PREFORK_TYPESET,
11622                            // so every arg runs through
11623                            // filesub(PREFORK_TYPESET): the `~`/`=` after the
11624                            // first `=` (and after each `:`) undergo filename
11625                            // expansion. `print foo=~/bar` → `foo=$HOME/bar`.
11626                            // filesub (subst.c:667) keys on the Tilde/Equals
11627                            // TOKENS, not the literal chars, because that is
11628                            // exactly what tells a quoted `~`/`=` from a live
11629                            // one. This arm used to `untokenize` the word and
11630                            // then `shtokenize` it back, which re-marks EVERY
11631                            // literal `~`/`=` as a token — including the ones
11632                            // that came out of quotes. With MAGIC_EQUAL_SUBST
11633                            // set, `print -r -- --a='b:=c'` then did `=`
11634                            // filename expansion on the quoted `=c` and failed
11635                            // with `c not found` (zsh prints `--a=b:=c`), and
11636                            // `A=( --height='${X:=75%}' )` — fzf-tab.plugin.zsh
11637                            // sh:117-128 — errored `75%} not found` at plugin
11638                            // load, aborting the rest of the zinit turbo
11639                            // `atload` chain. Hand filesub the word C hands it:
11640                            // the one the lexer produced.
11641                            let exp = crate::ported::subst::filesub(
11642                                &s_tok,
11643                                crate::ported::zsh_h::PREFORK_TYPESET,
11644                            );
11645                            vec![crate::lex::untokenize(&exp).to_string()]
11646                        } else {
11647                            vec![s]
11648                        }
11649                    })
11650                    .collect();
11651                if parts.len() == 1 {
11652                    let only = parts.into_iter().next().unwrap_or_default();
11653                    // Empty unquoted expansion → drop the arg entirely
11654                    // (zsh "remove empty unquoted words" rule). Returning
11655                    // an empty Value::Array makes pop_args contribute zero
11656                    // items. Direct port of subst.c's empty-elide pass at
11657                    // the end of multsub which removes empty linknodes
11658                    // from unquoted contexts. Quoted DQ/SQ paths (modes
11659                    // 1/2/5) take separate arms above and always emit
11660                    // Value::Str so the empty arg survives.
11661                    //
11662                    // c:Src/subst.c:4437 + 1650-1656 — a word CONTAINING a
11663                    // quoted span never drops: `x"${v[-1]}"y` (v empty)
11664                    // is the scalar "xy", and a standalone `"${v[-1]}"`
11665                    // is ONE empty arg. The lexer marks DQ/SQ spans with
11666                    // Dnull(\u{9e})/Snull(\u{9d})/Qstring(\u{8c})/
11667                    // Bnull(\u{9f}); their presence in the SOURCE word
11668                    // means qt semantics apply. Without this gate, zpwr's
11669                    // global `setopt rc_expand_param` turned autopair's
11670                    // `local lchar="${LBUFFER[-1]}"` (empty prompt +
11671                    // backspace) into an ARGLESS `local` — the full
11672                    // parameter-table dump the user saw per keystroke.
11673                    if only.is_empty() {
11674                        // A quote span that WRAPS the expansion keeps the
11675                        // empty arg (`"${v[-1]}"` → one empty arg). But a
11676                        // quote INSIDE the `${…}` braces — e.g. the alternate
11677                        // of `${x:+'q'}` or `${x:-'d'}` — does NOT: when that
11678                        // branch isn't taken the result is a plain unquoted
11679                        // empty and must ELIDE, matching zsh (`a=(A ${x:+'q'}
11680                        // C)` → 2 elements, not 3). So only count quote
11681                        // markers at brace-depth 0 (outside `${…}`). Inbrace
11682                        // = \u{8f}, Outbrace = \u{90}.
11683                        let mut depth = 0i32;
11684                        let mut word_has_quoted_span = false;
11685                        for c in text.chars() {
11686                            match c {
11687                                '\u{8f}' => depth += 1,
11688                                '\u{90}' => depth -= 1,
11689                                '\u{9e}' | '\u{9d}' | '\u{8c}' | '\u{9f}' | '"' | '\''
11690                                    if depth <= 0 =>
11691                                {
11692                                    word_has_quoted_span = true;
11693                                    break;
11694                                }
11695                                _ => {}
11696                            }
11697                        }
11698                        if word_has_quoted_span {
11699                            // Returns a SCALAR Value, so the empty-Array
11700                            // shape bit does not describe it — leave the
11701                            // cell alone. Overwriting it here would let a
11702                            // trailing quoted-empty segment resurrect a word
11703                            // an EARLIER empty array already deleted:
11704                            // `setopt rcexpandparam; a=(); x${a}"${P}"y` is
11705                            // ZERO words in zsh, and concat_plan9's
11706                            // `(Array(empty), scalar)` arm returns the scalar
11707                            // when the bit says "scalar".
11708                            Value::str(String::new())
11709                        } else {
11710                            // The empty `Value::Array` below stands for TWO
11711                            // different C shapes, and under RC_EXPAND_PARAM
11712                            // they behave OPPOSITELY:
11713                            //
11714                            //   c:Src/subst.c:4362-4365 (plan9, empty ARRAY)
11715                            //     if (plan9) { uremnode(l, n); return n; }
11716                            //   → the whole word is deleted:
11717                            //     `a=(); x${a}y` and `x${${a}}y` are 0 words.
11718                            //
11719                            //   c:Src/subst.c:4438-4467 (scalar arm, empty
11720                            //   SCALAR) — c:4464
11721                            //     *str = strcatsub(&y, ostr, aptr, x, xlen,
11722                            //                      fstr, globsubst, copied);
11723                            //   then c:4467 `setdata(n, (void *) y);`
11724                            //   → the surrounding text survives:
11725                            //     `unset P; x${P}y` and `x${${P}}y` are the
11726                            //     single word `xy`.
11727                            //
11728                            // Nesting is NOT the discriminator — shape is.
11729                            // The word is deleted only when the expansion was
11730                            // ARRAY-shaped (c:4245 `if (isarr)`) AND produced
11731                            // zero words, i.e. C's `while ((x = *aval++))`
11732                            // loop at c:4327 never ran and left `plan9`
11733                            // non-zero at c:4362. Anything else empty — an
11734                            // unset/empty scalar, a nested subexp that
11735                            // resolved to a scalar, a command substitution
11736                            // with no output — takes c:4438's scalar arm and
11737                            // keeps the word.
11738                            note_empty_is_scalar(!(seg_zero_words && seg_is_array));
11739                            Value::array(Vec::new())
11740                        }
11741                    } else {
11742                        Value::str(only)
11743                    }
11744                } else {
11745                    Value::array(parts.into_iter().map(Value::str).collect())
11746                }
11747            }
11748        });
11749        // Pull any inner cmd-subst (`` `cmd` `` via mode 3 or via
11750        // mode 0/6 multsub → getoutput, `$(cmd)` via the default
11751        // arm's multsub path, nested `$()`s reached through
11752        // stringsubst) back into vm.last_status so a containing
11753        // assignment's BUILTIN_SET_VAR — which reads vm.last_status —
11754        // sees the cmd-subst's exit. Without this, backtick
11755        // assignments (`a=\`false\`; echo $?`) reported 0 because the
11756        // ported LASTVAL update never reached the VM-side counter.
11757        let cs_status = with_executor(|exec| exec.last_status());
11758        vm.last_status = cs_status;
11759        result_value
11760    });
11761
11762    // `${#name}` — pops [name]. Returns the value's element count for
11763    // arrays (indexed and assoc) or character length for scalars.
11764    // BUILTIN_PARAM_LENGTH — `${#name}`. PURE PASSTHRU.
11765    vm.register_builtin(BUILTIN_PARAM_LENGTH, |vm, _argc| {
11766        let name = vm.pop().to_str();
11767        // PARAM_LENGTH's empty-result semantics differ from
11768        // paramsubst_to_value: 0 nodes → "0" (numeric length), not
11769        // empty array. paramsubst on `${#X}` always returns at least
11770        // one node in practice (the length string); the empty case
11771        // is defensive.
11772        let mut ret_flags: i32 = 0;
11773        let (_full, _pos, nodes) = crate::ported::subst::paramsubst(
11774            &format!("${{#{}}}", name),
11775            0,
11776            false,
11777            0i32,
11778            &mut ret_flags,
11779        );
11780        if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
11781            with_executor(|exec| exec.set_last_status(1));
11782        }
11783        if nodes.is_empty() {
11784            Value::str("0")
11785        } else {
11786            nodes_to_value(nodes)
11787        }
11788    });
11789
11790    // `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
11791    // `${var/%pat/repl}` — Pops [name, pattern, replacement, op_byte].
11792    // op: 0=first, 1=all, 2=anchor-prefix (`/#`), 3=anchor-suffix (`/%`).
11793    // BUILTIN_PARAM_REPLACE — `${var/pat/repl}` / `${var//pat/repl}` /
11794    // `${var/#pat/repl}` / `${var/%pat/repl}`. PURE PASSTHRU.
11795    vm.register_builtin(BUILTIN_PARAM_REPLACE, |vm, _argc| {
11796        let dq_flag = vm.pop().to_int() != 0;
11797        let op = vm.pop().to_int() as u8;
11798        let repl = vm.pop().to_str();
11799        let pattern = vm.pop().to_str();
11800        let name = vm.pop().to_str();
11801        // !!! DASH-STRICT GATE !!! dash / ash have no `${var/pat/repl}`
11802        // pattern-replacement expansion — it is a "Bad substitution" error
11803        // (bash/ksh/POSIX-sh support it, so those modes fall through). Raise
11804        // the canonical zsh diagnostic + exit 1 to match /bin/dash's failure.
11805        if crate::dash_mode::dash_strict() {
11806            crate::ported::utils::zerr("bad substitution");
11807            crate::ported::utils::errflag.fetch_or(
11808                crate::ported::zsh_h::ERRFLAG_ERROR,
11809                std::sync::atomic::Ordering::Relaxed,
11810            );
11811            with_executor(|exec| exec.set_last_status(1));
11812            return Value::str("");
11813        }
11814        // DQ context: C's lexer marks every `$` inside double quotes
11815        // as the Qstring token (Src/lex.c dquote_parse) and keeps `'`
11816        // a plain char — so a DQ replacement's `$'…'` is LITERAL in
11817        // C (Src/subst.c:301 decodes only the tokenized Snull form;
11818        // `"${a/X/$'\0'}"` keeps the five chars `$'\0'`). The body
11819        // rebuilt below re-enters stringsubst as raw text, which
11820        // would mis-decode `$'…'` as ANSI-C; stamp the Qstring
11821        // marker on the repl's `$` so stringsubst sees the same DQ
11822        // signal C's tokens carry. The PATTERN side keeps decoding
11823        // (matches observed zsh: the pattern's `$'\0'` matches a
11824        // real NUL while the repl's stays literal).
11825        let repl = if dq_flag {
11826            repl.replace('$', "\u{8c}")
11827        } else {
11828            repl
11829        };
11830        // op encoding: 0 = first `/`, 1 = all `//`, 2 = anchor-prefix
11831        // `/#`, 3 = anchor-suffix `/%`. The brace form distinguishes
11832        // first-vs-all by single vs doubled slash, and anchored by
11833        // a `#` or `%` immediately after the slash(es).
11834        let body = match op {
11835            0 => format!("${{{}/{}/{}}}", name, pattern, repl),
11836            1 => format!("${{{}//{}/{}}}", name, pattern, repl),
11837            2 => format!("${{{}/#{}/{}}}", name, pattern, repl),
11838            3 => format!("${{{}/%{}/{}}}", name, pattern, repl),
11839            _ => format!("${{{}/{}/{}}}", name, pattern, repl),
11840        };
11841        // c:Src/subst.c:1625 — paramsubst's qt flag. The compiler
11842        // threads the word's DQ context onto the stack; dropping it
11843        // (the old `let _dq_flag`) ran the rebuilt body with qt=false
11844        // whenever the opcode fired outside an EXPAND_TEXT scope, so
11845        // DQ-only semantics inside the replacement (e.g. `$'` staying
11846        // literal per Src/subst.c:301 — `"${a/x/$'\t'q}"`) were lost.
11847        // Bump in_dq_context exactly like EXPAND_TEXT mode 1 so
11848        // paramsubst_to_value's qt probe sees the right context.
11849        if dq_flag {
11850            with_executor(|exec| exec.in_dq_context += 1);
11851        }
11852        let ret = paramsubst_to_value(&body);
11853        if dq_flag {
11854            with_executor(|exec| exec.in_dq_context -= 1);
11855        }
11856        ret
11857    });
11858
11859    vm.register_builtin(BUILTIN_REGISTER_COMPILED_FN, |vm, argc| {
11860        let args = pop_args(vm, argc);
11861        let mut iter = args.into_iter();
11862        let name = iter.next().unwrap_or_default();
11863        let body_b64 = iter.next().unwrap_or_default();
11864        let body_source = iter.next().unwrap_or_default();
11865        let line_base_str = iter.next().unwrap_or_default();
11866        let line_base: i64 = line_base_str.parse().unwrap_or(0);
11867        // c:Src/exec.c:5382 `do_tracing = *state->pc++;` — the `-T` of
11868        // `function -T name { … }`, carried across from compile_funcdef.
11869        let do_tracing = iter.next().map(|s| s == "1").unwrap_or(false); // c:5382
11870                                                                         // c:Src/exec.c:5451-5456 — `shf->redir = <redir_prog>`: the rendered
11871                                                                         // text of the definition's trailing redirections (empty when there
11872                                                                         // were none). See `shfunc::redir_text`.
11873        let redir_text = iter.next().unwrap_or_default(); // c:5453
11874                                                          // c:5387 — `tracing_flags = do_tracing ? PM_TAGGED_LOCAL : 0;`
11875        let tracing_flags: u32 = if do_tracing {
11876            crate::ported::zsh_h::PM_TAGGED_LOCAL
11877        } else {
11878            0
11879        }; // c:5387
11880        let bytes = base64_decode(&body_b64);
11881        let status = match bincode::deserialize::<fusevm::Chunk>(&bytes) {
11882            Ok(chunk) => with_executor(|exec| {
11883                // c:Src/exec.c:5383 — `shf->filename =
11884                // ztrdup(scriptfilename);` — the function's
11885                // definition-file is read from the canonical
11886                // file-scope `scriptfilename` global at compile
11887                // time, NOT from a per-executor struct field.
11888                // exec.scriptfilename is seeded once at
11889                // bins/zshrs.rs:1717 to the bin basename ("zsh")
11890                // and never updates on source/dot, so reading from
11891                // it left every user function's def_file as "zsh".
11892                // Route through scriptfilename_get() so source /
11893                // dot's set_scriptfilename calls propagate.
11894                let def_file = crate::ported::utils::scriptfilename_get()
11895                    .or_else(|| exec.scriptfilename.clone());
11896                let def_file_for_prov = def_file.clone();
11897                if !body_source.is_empty() {
11898                    exec.function_source
11899                        .insert(name.clone(), body_source.clone());
11900                }
11901                exec.function_line_base.insert(name.clone(), line_base);
11902                exec.function_def_file.insert(name.clone(), def_file);
11903                // PFA-SMR aspect: every `name() {}` / `function name { }`
11904                // funnels through here at compile time. Emit one record
11905                // with the function name + raw body source.
11906                #[cfg(feature = "recorder")]
11907                if crate::recorder::is_enabled() {
11908                    let ctx = exec.recorder_ctx();
11909                    let body = if body_source.is_empty() {
11910                        None
11911                    } else {
11912                        Some(body_source.as_str())
11913                    };
11914                    crate::recorder::emit_function(&name, body, ctx);
11915                }
11916                // c:Src/exec.c:5516-5531 — `TRAP<SIG>() { ... }` is the
11917                // function-named trap install. zsh detects the `TRAP`
11918                // prefix at func-def time and calls
11919                // `settrap(signum, NULL, ZSIG_FUNC)` so the next
11920                // dispatch of that signal routes to the named shfunc.
11921                // Bug #157 in docs/BUGS.md — fusevm_bridge's funcdef
11922                // opcode skipped this dispatch entirely, so TRAPEXIT /
11923                // TRAPUSR1 / TRAPZERR / TRAPDEBUG never fired.
11924                //
11925                // ORDER MATTERS. C runs settrap at c:5518 and
11926                // `shfunctab->addnode` only at c:5539, and dosavetrap
11927                // says why (c:634-637): "Get the old function: this
11928                // assumes we haven't added the new one yet." Running
11929                // the install first made settrap→unsettrap→removetrap→
11930                // dosavetrap snapshot the NEW body, so endtrapscope
11931                // "restored" the inner definition and a nested
11932                // `TRAPEXIT() { … }` permanently clobbered the outer one.
11933                // BUGS.md #1114 — the TRAPxxx() FUNCTION-trap form is zsh-only.
11934                // bash, ksh and dash have no such concept: there `TRAPINT` is an
11935                // ordinary function whose name merely begins with TRAP, and SIGINT
11936                // keeps its default disposition. Recognising it in a drop-in mode
11937                // also SILENTLY DESTROYS an already-installed list trap for that
11938                // signal, since the two forms are alternatives in zsh.
11939                // posix_faithful() is raised only for a bare drop-in flag, so
11940                // --zsh, native zshrs and `emulate sh` are untouched.
11941                if !crate::extensions::dash_mode::posix_faithful() && name.len() > 4 && name.starts_with("TRAP") {
11942                    if let Some(sn) = crate::ported::jobs::getsigidx(&name[4..]) {
11943                        let _ = crate::ported::signals::settrap(
11944                            sn,
11945                            None,
11946                            crate::ported::zsh_h::ZSIG_FUNC as i32,
11947                        );
11948                        // c:5530 — `removetrapnode(signum);` "Remove the
11949                        // old node explicitly in case it has an
11950                        // alternative name". NOT mirrored here: zshrs's
11951                        // `jobs::removetrapnode` routes through
11952                        // `hashtable::removeshfuncnode`, which calls back
11953                        // into `unsettrap` (C explicitly avoids that path
11954                        // — see the comment at Src/signals.c:836-838) and
11955                        // would tear down the trap `settrap` just armed.
11956                        // The `tab.add` below overwrites the canonical
11957                        // `TRAP<SIG>` node anyway; only the alt-name case
11958                        // (TRAPCLD vs TRAPCHLD) is left unhandled.
11959                    }
11960                }
11961                // Mirror into canonical shfunctab so scanfunctions /
11962                // ${(k)functions} / functions builtin see user defs.
11963                // C: exec.c:funcdef → shfunctab->addnode(ztrdup(name),shf).
11964                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
11965                    let mut shf = crate::ported::hashtable::shfunc_with_body(&name, &body_source);
11966                    // c:Src/exec.c:5453/5455 — `shf->redir = …`. Stored as
11967                    // rendered text (see `shfunc::redir_text`); `None` is C's
11968                    // `shf->redir == NULL`.
11969                    shf.redir_text = if redir_text.is_empty() {
11970                        None
11971                    } else {
11972                        Some(redir_text.clone())
11973                    };
11974                    // c:Src/exec.c:5437 — `shf->node.flags = tracing_flags;`
11975                    shf.node.flags |= tracing_flags as i32; // c:5437
11976                                                            // c:5532-5538 — /* Is this function traced and redefining
11977                                                            //                  itself? */
11978                                                            //     if (funcstack && funcstack->tp == FS_FUNC &&
11979                                                            //             !strcmp(s, funcstack->name)) {
11980                                                            //         Shfunc old = ((Shfunc)shfunctab->getnode(shfunctab, s));
11981                                                            //         if (old)
11982                                                            //             shf->node.flags |= old->node.flags &
11983                                                            //                                (PM_TAGGED|PM_TAGGED_LOCAL);
11984                                                            //     }
11985                                                            // The ported walker does this at exec.rs:7387, but fusevm —
11986                                                            // not that walker — is what registers a `name() { … }`, so a
11987                                                            // `functions -T f`-tagged function that redefined itself
11988                                                            // came back untraced (E02xtrace:6).
11989                    if let Ok(stk) = crate::ported::modules::parameter::FUNCSTACK.lock() {
11990                        if let Some(top) = stk.last() {
11991                            // c:5533
11992                            if top.tp == crate::ported::zsh_h::FS_FUNC && top.name == name {
11993                                // c:5535 — `Shfunc old = shfunctab->getnode(shfunctab, s);`
11994                                // Read through the write guard already held
11995                                // above: `shfunctab_lock()` is a std RwLock and
11996                                // is NOT reentrant, so re-acquiring it here
11997                                // self-deadlocked on exactly the
11998                                // self-redefinition case (C04funcdef:30 hung).
11999                                if let Some(old) = tab.get(&name) {
12000                                    // c:5537
12001                                    shf.node.flags |= old.node.flags
12002                                        & (crate::ported::zsh_h::PM_TAGGED as i32
12003                                            | crate::ported::zsh_h::PM_TAGGED_LOCAL as i32);
12004                                }
12005                            }
12006                        }
12007                    }
12008                    // `shfunc_with_body` stamps the AMBIENT `scriptfilename`
12009                    // (c:Src/exec.c:5383). That is right for an ordinary
12010                    // definition but wrong twice over for an autoloaded one:
12011                    //
12012                    //  * c:Src/exec.c:5622-5630 — while an autoload file's text
12013                    //    runs, C sets `scriptfilename = getshfuncfile(shf)`, so a
12014                    //    `name() { … }` INSIDE it records the fpath file. zshrs
12015                    //    runs that body on the normal VM path with the CALLER's
12016                    //    scriptfilename still in place, so a ksh-style autoload
12017                    //    file — one that defines the function and then calls it —
12018                    //    got attributed to whoever triggered the load.
12019                    //  * the function is then re-registered UNCHANGED when its
12020                    //    chunk is compiled at call time, and that second stamp
12021                    //    would overwrite the first even if the first were right.
12022                    //
12023                    // Result before this: `whence -v f` said "from ./caller.zsh"
12024                    // where zsh says "from dir/f", and `funcsourcetrace[1]`
12025                    // pointed at the caller — which compsys reads directly
12026                    // (`_git` locates git-completion.bash through
12027                    // `"$(dirname ${funcsourcetrace[1]%:*})"`).
12028                    if let Some(f) = crate::vm_helper::autoload_def_file(&name) {
12029                        shf.filename = Some(f); // c:5625 getshfuncfile(shf)
12030                    } else if let Some(prev) = tab.get(&name) {
12031                        // Re-registration of an unchanged body relabels nothing.
12032                        if prev.body.as_deref() == Some(body_source.as_str()) {
12033                            shf.filename = prev.filename.clone();
12034                            shf.node.flags |=
12035                                prev.node.flags & crate::ported::zsh_h::PM_LOADDIR as i32;
12036                        }
12037                    }
12038                    // c:Src/exec.c:5409 — `shf->lineno = lineno;`. Use
12039                    // the same max(1, line_base) clamp as the synth_shf
12040                    // in vm_helper::dispatch_function_call. Bug #396.
12041                    shf.lineno = std::cmp::max(1, line_base);
12042                    // c:Src/exec.c:5402 — `shfunc_set_sticky(shf);`
12043                    // stamps the definition-time `sticky` emulation
12044                    // snapshot onto the function so a later call can
12045                    // re-enter that emulation (doshfunc c:5978). The
12046                    // ported execfuncdef does this at exec.rs:7141, but
12047                    // fusevm — not that walker — is what actually
12048                    // registers a `name() { … }`, so `emulate sh -c
12049                    // 'f() { … }'` produced a function with no sticky
12050                    // emulation at all (B07emulate.ztst:6,7,8,12,13,14).
12051                    crate::ported::exec::shfunc_set_sticky(&mut shf);
12052                    // zshrs re-runs this registration when a function's
12053                    // chunk is (re)compiled at call time — see the
12054                    // filename note above. That second pass happens with
12055                    // the AMBIENT sticky (normally none), which would
12056                    // erase the definition-time stamp. An unchanged body
12057                    // is not a redefinition, so keep what it had.
12058                    if shf.sticky.is_none() {
12059                        if let Some(prev) = tab.get(&name) {
12060                            if prev.body.as_deref() == Some(body_source.as_str()) {
12061                                shf.sticky = prev
12062                                    .sticky
12063                                    .as_deref()
12064                                    .map(|b| crate::ported::exec::sticky_emulation_dup(b, 0));
12065                            }
12066                        }
12067                    }
12068                    // docs/BUGS.md #1105 — C settles every lexer-time
12069                    // decision when the definition runs (c:Src/exec.c:5389
12070                    // `shf->funcdef = dupeprog(…)`) and prints that wordcode
12071                    // back unchanged (c:Src/hashtable.c:954). zshrs stores
12072                    // the raw source and re-lexes it to print, so the one
12073                    // lexer-time option that survives into a deparse —
12074                    // RCQUOTES, c:Src/lex.c:1328 — has to be recorded here
12075                    // or a later `setopt rcquotes` rewrites the listing of a
12076                    // function defined long before it. Skipped for the
12077                    // re-registration of an unchanged body (the same test
12078                    // the filename and sticky stamps above use): that second
12079                    // pass runs at CALL time, whose option state is not the
12080                    // definition's.
12081                    if tab
12082                        .get(&name)
12083                        .is_none_or(|prev| prev.body.as_deref() != Some(body_source.as_str()))
12084                    {
12085                        crate::vm_helper::funcdef_note_rcquotes(
12086                            &name,
12087                            &body_source,
12088                            crate::ported::zsh_h::isset(crate::ported::zsh_h::RCQUOTES),
12089                        );
12090                    }
12091                    tab.add(shf);
12092                }
12093                // Lineage tap: this is where a `name() { … }` actually
12094                // lands in the VM path — `execfuncdef`'s shfunctab
12095                // install only runs for the interpreter path. The first
12096                // definition is the function's origin; a later one is a
12097                // `redefine` op on the same chain.
12098                if crate::provenance::active() {
12099                    crate::provenance::on_func_define(
12100                        &name,
12101                        Some(body_source.as_str()),
12102                        def_file_for_prov.as_deref(),
12103                        std::cmp::max(1, line_base),
12104                    );
12105                }
12106                exec.functions_compiled.insert(name, chunk);
12107                0
12108            }),
12109            Err(_) => 1,
12110        };
12111        Value::Status(status)
12112    });
12113
12114    // Wire the ShellHost so direct shell ops (Op::Glob, Op::TildeExpand,
12115    // Op::ExpandParam, Op::CmdSubst, Op::CallFunction, etc.) route through
12116    // ZshrsHost back into the executor.
12117    vm.set_shell_host(Box::new(ZshrsHost));
12118}
12119
12120impl ZshrsHost {
12121    /// True iff `c` can be a `(j:…:)` / `(s:…:)` delimiter — non-alphanumeric,
12122    /// non-underscore. Restricting to punctuation avoids `(jL)` consuming `L`
12123    /// as a delim instead of as the next flag.
12124    fn is_zsh_flag_delim(c: char) -> bool {
12125        !c.is_ascii_alphanumeric() && c != '_'
12126    }
12127}
12128
12129/// Shared `${name[idx]}` subscript dispatch for BUILTIN_ARRAY_INDEX
12130/// and the KSHARRAYS-unset arm of BUILTIN_ARRAY_INDEX_UNBRACED.
12131///
12132/// c:Src/subst.c subscript parsing — when paramsubst re-parses the
12133/// synthesized `${name[idx]}` body, characters like `'` `"` `\` `$`
12134/// etc. are LEXER-active inside the `[…]` and get reinterpreted
12135/// (quote-strip, paramsubst recursion, …). For PRE-EVALUATED key
12136/// strings (the dynamic-key fast path at compile_zsh.rs:3234 already
12137/// expanded `$k` via EXPAND_TEXT), the idx is a literal string that
12138/// must match the stored key byte-for-byte — no further
12139/// reinterpretation. Direct assoc lookup bypasses the lexer for this
12140/// case, avoiding the quote-strip bug where `h[a'b]` failed to
12141/// resolve because paramsubst's subscript lexer treated the `'` as a
12142/// quote. Bug #338. Only fires for simple assoc-name + non-flag idx
12143/// (no outer-flag sentinels, no `(…)` flag prefix on idx, no splat
12144/// operator). Other paths (slice, splat, flag-based search,
12145/// magic-assoc) still flow through paramsubst.
12146fn array_index_lookup(name: &str, idx: &str) -> Value {
12147    let idx_is_simple = !idx.starts_with('(') && idx != "@" && idx != "*" && !idx.contains(',');
12148    if idx_is_simple {
12149        // assoc_key_hit: single-lock O(1) probe — exec.assoc() clones
12150        // the WHOLE map per lookup (O(n), quadratic in shell loops).
12151        // When `name` IS an assoc, exact-key semantics apply to EVERY
12152        // plain key: hit → value, miss → empty (C `${assoc[missing]}`).
12153        // Never fall through to the textual `${name[key]}` rebuild —
12154        // keys carrying `{`/`}`/`[`/`]` (zsh-autopair probes
12155        // `${AUTOPAIR_LBOUNDS[$pair]}` with pair='{') re-parse as
12156        // broken syntax there ("failed to compile regex: repetition
12157        // quantifier…" + a `}` appended per keystroke).
12158        if let Some((_, v)) = crate::vm_helper::assoc_key_hit(name, idx) {
12159            return Value::str(v.unwrap_or_default());
12160        }
12161    }
12162    // c:Src/params.c:1449-1450 getindex — a leading `(e)`/`(E)` flag
12163    // group makes the subscript LITERAL (group consumed, exact key).
12164    // The textual rebuild below re-parses a FLAT `${name[(e)KEY]}`
12165    // string, so a `]` / `}` that arrived via `$key` expansion
12166    // terminates the subscript / brace early — "bad substitution" or
12167    // spilled-junk values (zpwr expandstats iterates alias keys
12168    // containing brackets). C never re-parses: getarg scans the
12169    // TOKENIZED source where expanded data brackets are inert. Do the
12170    // exact-match lookup directly against the assoc (plain or magic
12171    // alias tables); search groups ((r)/(i)/(k)/…) and other targets
12172    // keep the textual path.
12173    if let Some(rest) = idx.strip_prefix('(') {
12174        if let Some(close) = rest.find(')') {
12175            let grp = &rest[..close];
12176            if !grp.is_empty() && grp.chars().all(|ch| ch == 'e' || ch == 'E') {
12177                let key = &rest[close + 1..];
12178                if let Some(hit) = direct_assoc_key_get(name, key) {
12179                    return Value::str(hit.unwrap_or_default());
12180                }
12181            }
12182        }
12183    }
12184    // Plain assoc key that the flat rebuild would mangle (`]` closes
12185    // the subscript, `}` closes the brace): direct lookup. On a miss
12186    // return empty — the textual fallback cannot represent the key.
12187    if (idx.contains(']') || idx.contains('}')) && !idx.starts_with('(') {
12188        if let Some(hit) = direct_assoc_key_get(name, idx) {
12189            return Value::str(hit.unwrap_or_default());
12190        }
12191    }
12192    let body = format!("${{{}[{}]}}", name, idx);
12193    paramsubst_to_value(&body)
12194}
12195
12196/// Exact-key read against an assoc-like target WITHOUT the textual
12197/// `${name[key]}` reparse (see array_index_lookup — expanded `]`/`}`
12198/// in keys break the flat form). `Some(hit)` when `name` is a target
12199/// this helper understands (plain assoc, or the alias magic assocs of
12200/// zsh/parameter — Src/Modules/parameter.c getpmalias family);
12201/// `None` = not direct-capable, caller keeps the textual path.
12202fn direct_assoc_key_get(name: &str, key: &str) -> Option<Option<String>> {
12203    use crate::ported::zsh_h::{ALIAS_GLOBAL, DISABLED};
12204    // c:Src/Modules/parameter.c:1247+ getpmalias / getpmgalias /
12205    // getpmsalias — each view filters its table by flags.
12206    let alias_view = |global: bool, suffix: bool, disabled: bool| -> Option<String> {
12207        let tab = if suffix {
12208            crate::ported::hashtable::sufaliastab_lock()
12209        } else {
12210            crate::ported::hashtable::aliastab_lock()
12211        };
12212        tab.read().ok().and_then(|t| {
12213            t.iter().find_map(|(k, a)| {
12214                let f = a.node.flags as u32;
12215                if k == key
12216                    && ((f & ALIAS_GLOBAL as u32 != 0) == global || suffix)
12217                    && ((f & DISABLED as u32 != 0) == disabled)
12218                {
12219                    Some(a.text.clone())
12220                } else {
12221                    None
12222                }
12223            })
12224        })
12225    };
12226    match name {
12227        "aliases" => Some(alias_view(false, false, false)),
12228        "galiases" => Some(alias_view(true, false, false)),
12229        "saliases" => Some(alias_view(false, true, false)),
12230        "dis_aliases" => Some(alias_view(false, false, true)),
12231        "dis_galiases" => Some(alias_view(true, false, true)),
12232        "dis_saliases" => Some(alias_view(false, true, true)),
12233        _ => {
12234            // Single-lock O(1) probe (see assoc_key_hit) — the previous
12235            // double exec.assoc() cloned the whole map twice per lookup.
12236            crate::vm_helper::assoc_key_hit(name, key).map(|(_, v)| v)
12237        }
12238    }
12239}
12240
12241/// KSHARRAYS bare-`$name` expansion words for the unbraced
12242/// no-subscript form (BUILTIN_ARRAY_INDEX_UNBRACED's KSHARRAYS arm).
12243///
12244/// - `@` / `*` stay the full positional list (the c:Src/params.c:
12245///   2293-2296 first-element collapse is gated on
12246///   `itype_end(t, IIDENT, 1) != t` — an identifier-shaped name —
12247///   which `@`/`*` are not). The literal `[idx]` then joins the LAST
12248///   word, matching zsh 5.9: `setopt ksharrays; set -- p q;
12249///   print -- $@[0]` → `zsh:1: no matches found: q[0]`.
12250/// - Identifier-named arrays collapse to the FIRST element
12251///   (c:Src/params.c:2293-2296 `v->end = 1, v->isarr = 0`).
12252/// - Assocs collapse to the first value in scan order; the `options`
12253///   magic assoc's scan order is `OPTIONTAB` bucket order (first key
12254///   `posixargzero`), matching zsh 5.9: `emulate sh -L;
12255///   print $options[posixargzero]` → `off[posixargzero]`.
12256/// - Scalars / unset names expand to their value / empty (zsh 5.9:
12257///   `setopt ksharrays; print -- $unsetvar[0]` →
12258///   `zsh:1: no matches found: [0]`).
12259fn ksharrays_bare_words(name: &str) -> Vec<String> {
12260    if name == "@" || name == "*" {
12261        return with_executor(|exec| exec.pparams());
12262    }
12263    // Magic special-parameter lookups first — mirrors the
12264    // BUILTIN_GET_VAR precedence (partab before executor tables).
12265    if let Some(vals) = crate::vm_helper::partab_array_get(name) {
12266        return vec![vals.into_iter().next().unwrap_or_default()];
12267    }
12268    if let Some(keys) = crate::vm_helper::partab_scan_keys(name) {
12269        let v = keys
12270            .first()
12271            .and_then(|k| crate::vm_helper::partab_get(name, k))
12272            .unwrap_or_default();
12273        return vec![v];
12274    }
12275    let arr_or_assoc = with_executor(|exec| {
12276        if let Some(arr) = exec.array(name) {
12277            // c:Src/params.c:2293-2296 — first element only.
12278            return Some(arr.first().cloned().unwrap_or_default());
12279        }
12280        if let Some(map) = exec.assoc(name) {
12281            // c:Src/params.c:2351-2358 — under KSH EMULATION a bare
12282            // `$assoc` is `${assoc[0]}` (KEY-"0" lookup), EMPTY unless the
12283            // hash has a key "0": `emulate -L ksh; typeset -A h=(a 1 b 2);
12284            // print $h` is empty. Every other mode (`setopt ksharrays`,
12285            // `emulate sh`) falls through to the first bucket value below.
12286            if crate::ported::zsh_h::EMULATION(crate::ported::zsh_h::EMULATE_KSH) {
12287                return Some(map.get("0").cloned().unwrap_or_default());
12288            }
12289            // Mirrors BUILTIN_GET_VAR's bare-assoc ordering
12290            // (sorted keys, first value).
12291            let mut keys: Vec<&String> = map.keys().collect();
12292            keys.sort();
12293            return Some(
12294                keys.first()
12295                    .and_then(|k| map.get(*k).cloned())
12296                    .unwrap_or_default(),
12297            );
12298        }
12299        None
12300    });
12301    if let Some(v) = arr_or_assoc {
12302        return vec![v];
12303    }
12304    vec![with_executor(|exec| exec.get_variable(name))]
12305}
12306
12307/// Run `body` through `crate::ported::subst::paramsubst` and convert
12308/// the resulting node list into a fusevm `Value`. Centralises the
12309/// pattern duplicated across ~10 BUILTIN_* handlers:
12310///   - build a `${...}` body string from opcode operands
12311///   - paramsubst the body
12312///   - propagate errflag to `exec.last_status`
12313///   - delegate the LinkList → Value conversion to `nodes_to_value`
12314///
12315/// **Extension** — Rust-only helper. No direct C analog because C
12316/// zsh uses LinkList everywhere; the conversion happens at the
12317/// boundary back into the VM's stack.
12318fn paramsubst_to_value(body: &str) -> Value {
12319    paramsubst_to_value_pf(body, 0)
12320}
12321
12322/// `paramsubst_to_value` with an explicit `pf_flags` (`PREFORK_*`) set.
12323///
12324/// c:Src/subst.c:1627 — `paramsubst(l, n, str, qt, pf_flags, ret_flags)`.
12325/// The only caller that needs a non-zero set today is the `${(flags)NAME}`
12326/// fast path when the word is a scalar-assignment VALUE: C preforks that with
12327/// `PREFORK_SINGLE|PREFORK_ASSIGN` (c:Src/exec.c:2603 for `x=…`,
12328/// c:Src/exec.c:4239-4241 for the typeset-family `NAME=…` argument), and
12329/// `PREFORK_SINGLE` is the `ssub` that turns off c:3913's forced split.
12330fn paramsubst_to_value_pf(body: &str, pf_flags: i32) -> Value {
12331    // c:Src/subst.c:1625 paramsubst's `qt` flag is the C signal that
12332    // the current expansion is inside `"…"`. The fast-path bridges
12333    // (BUILTIN_PARAM_*, BUILTIN_BRIDGE_BRACE_ARRAY) used to hardcode
12334    // qt=false, which silently broke DQ-only semantics inside
12335    // `${arr:^other}` / `${arr:^^other}` (Src/subst.c:3456-3520).
12336    // The executor's `in_dq_context` counter is bumped by EXPAND_TEXT
12337    // mode 1 / mode 5 before the bridge fires, so reading it here
12338    // propagates the DQ flag without changing every bridge call site.
12339    let qt = with_executor(|exec| exec.in_dq_context > 0);
12340    let mut ret_flags: i32 = 0;
12341    let (_full, _pos, nodes) =
12342        crate::ported::subst::paramsubst(body, 0, qt, pf_flags, &mut ret_flags);
12343    if crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed) != 0 {
12344        with_executor(|exec| exec.set_last_status(1));
12345    }
12346    // c:Src/lex.c untokenize — the final argv pass C runs on every
12347    // expanded word (glob.c:1862 / exec.c) DROPS the Nularg
12348    // empty-word sentinel (c:2089 `if (c != Nularg)`) that
12349    // remnulargs faithfully leaves in place (glob.c:3673 re-adds it
12350    // for all-empty results). These fast-path bridges are terminal —
12351    // their output lands directly in argv slots — so apply it here.
12352    // Without it, quoted splits with empty pieces ("${(s:|:)x}" on
12353    // "|a|b|") leak U+00A1 into argv.
12354    let nodes: Vec<String> = nodes
12355        .into_iter()
12356        .map(|n| crate::ported::lex::untokenize(&n))
12357        .collect();
12358    let value = nodes_to_value(nodes);
12359    // Provenance: this is the funnel every `${...}` bytecode fast path
12360    // reaches, so it is where a tracked parameter's chain is handed to
12361    // the value the expansion produced.
12362    if crate::provenance::active() {
12363        if let Some(name) = prov_subst_name(body) {
12364            crate::provenance::on_param_read(&name, &value);
12365        }
12366    }
12367    value
12368}
12369
12370/// Parameter name inside a `${...}` expansion body, for the provenance
12371/// tap in `paramsubst_to_value_pf`. Skips a leading `(flags)` group and
12372/// the `#`/`+`/`^`/`=`/`~` prefix sigils, then takes the identifier —
12373/// `${(k)assoc[key]}` yields `assoc`, `${#F}` yields `F`. Returns `None`
12374/// for the forms with no single named source (`$(...)`, `${(%)...}`).
12375fn prov_subst_name(body: &str) -> Option<String> {
12376    let mut rest = body.strip_prefix("${").or_else(|| body.strip_prefix('$'))?;
12377    rest = rest.trim_end_matches('}');
12378    // Skip one leading `(flags)` group.
12379    if let Some(after) = rest.strip_prefix('(') {
12380        rest = after.split_once(')').map(|(_, r)| r)?;
12381    }
12382    rest = rest.trim_start_matches(['#', '+', '^', '=', '~']);
12383    let name: String = rest
12384        .chars()
12385        .take_while(|c| c.is_alphanumeric() || *c == '_')
12386        .collect();
12387    if name.is_empty() {
12388        None
12389    } else {
12390        Some(name)
12391    }
12392}
12393
12394/// Wrap a `Vec<String>` (e.g. paramsubst nodes, multsub parts,
12395/// xpandbraces output) into a fusevm `Value`: 0 → empty Array, 1 →
12396/// Str, >1 → Array. Same unwrap idiom every handler that calls a
12397/// canonical Vec-returning fn does.
12398/// c:Src/subst.c:1663 — `int plan9 = isset(RCEXPANDPARAM);`
12399///
12400/// zsh calls the RC_EXPAND_PARAM word shape "plan9" after the rc(1)
12401/// shell it comes from. The option is read fresh on every expansion
12402/// (`setopt` mid-script changes the very next word), so the concat
12403/// builtins must consult it at RUNTIME, not bake it in at compile time.
12404fn plan9_active() -> bool {
12405    with_executor(|_exec| opt_state_get("rcexpandparam").unwrap_or(false))
12406}
12407
12408thread_local! {
12409    /// The `isarr` bit that `Value` cannot carry.
12410    ///
12411    /// c:Src/subst.c:4245 `if (isarr)` gates the whole array emit block,
12412    /// so plan9's word-removal rule (c:4362 `uremnode`) only ever applies
12413    /// to an ARRAY-valued expansion. An empty SCALAR has `isarr == 0`,
12414    /// takes the c:4437 scalar branch, and leaves the surrounding text
12415    /// intact — `setopt rcexpandparam; v=; print -rl -- x$v y` prints `x`
12416    /// and `y`, while the same line with an empty ARRAY prints only `y`.
12417    ///
12418    /// zshrs collapses BOTH shapes to `Value::Array(vec![])`: a real empty
12419    /// array, and an unquoted empty scalar (collapsed so a standalone `$v`
12420    /// contributes zero argv words, mirroring prefork's `uremnode` at
12421    /// c:Src/subst.c:184-187). The two are indistinguishable by the time
12422    /// they reach a concat builtin, so the expansion builtins record here
12423    /// whether the empty Array they just produced came from a scalar.
12424    ///
12425    /// Sticky until the next expansion overwrites it — a word folds its
12426    /// segments left-associatively (`concat(concat(x, $e), y)`), so the
12427    /// propagating second concat must still see the first one's bit.
12428    /// Consequence: only a builtin that RETURNS an empty `Value::Array` may
12429    /// write the cell. A builtin returning an empty SCALAR `Value` must leave
12430    /// it alone, or it resurrects a word an earlier empty array deleted
12431    /// (`a=(); x${a}"${P}"y` is zero words in zsh) — see the
12432    /// `word_has_quoted_span` arm of `BUILTIN_EXPAND_TEXT`.
12433    static EMPTY_EXPANSION_IS_SCALAR: std::cell::Cell<bool> =
12434        const { std::cell::Cell::new(false) };
12435}
12436
12437/// Record whether the empty expansion just produced was a scalar (`true`)
12438/// or a genuine array (`false`). See `EMPTY_EXPANSION_IS_SCALAR`.
12439fn note_empty_is_scalar(is_scalar: bool) {
12440    EMPTY_EXPANSION_IS_SCALAR.with(|c| c.set(is_scalar));
12441}
12442
12443/// True when the empty `Value::Array` about to be concatenated stands for
12444/// an empty SCALAR (c:4438-4467), not an empty array (c:4362).
12445fn empty_is_scalar() -> bool {
12446    EMPTY_EXPANSION_IS_SCALAR.with(|c| c.get())
12447}
12448
12449/// Re-assert `was_scalar` when `v` is an empty result.
12450///
12451/// For a pass-through stage — one that rewrites word TEXT but cannot turn a
12452/// scalar into an array or vice versa — the shape bit belongs to whichever
12453/// expansion produced the value, not to the stage. Such stages usually end in
12454/// `nodes_to_value`, which records "array" for an empty result, so without
12455/// this the producer's bit is lost before `concat_plan9` reads it.
12456/// Non-empty results carry their shape in the `Value` itself and are
12457/// returned untouched.
12458fn restore_empty_shape(v: Value, was_scalar: bool) -> Value {
12459    if matches!(&v, Value::Array(a) if a.is_empty()) {
12460        note_empty_is_scalar(was_scalar);
12461    }
12462    v
12463}
12464
12465/// `concat_splice` with the provenance tap applied to the result. Every
12466/// concat bytecode op routes through this (or `concat_plan9_prov`), so a
12467/// word built out of a tracked parameter keeps that parameter's lineage
12468/// even though the concatenated bytes are a fresh allocation.
12469fn concat_splice_prov(lhs: Value, rhs: Value) -> Value {
12470    if !crate::provenance::active() {
12471        return concat_splice(lhs, rhs);
12472    }
12473    let (l, r) = (lhs.clone(), rhs.clone());
12474    let out = concat_splice(lhs, rhs);
12475    crate::provenance::on_concat(&l, &r, &out);
12476    out
12477}
12478
12479/// `concat_plan9` with the provenance tap. See `concat_splice_prov`.
12480fn concat_plan9_prov(lhs: Value, rhs: Value) -> Value {
12481    if !crate::provenance::active() {
12482        return concat_plan9(lhs, rhs);
12483    }
12484    let (l, r) = (lhs.clone(), rhs.clone());
12485    let out = concat_plan9(lhs, rhs);
12486    crate::provenance::on_concat(&l, &r, &out);
12487    out
12488}
12489
12490/// c:Src/subst.c:4366-4437 — the NON-plan9 arm of paramsubst's array
12491/// emit block: "simply join the first and last values."
12492///
12493/// The word prefix (`ostr..aptr`) is concatenated onto element 0
12494/// (c:4386 `strcatsub(&y, ostr, aptr, x, xlen, NULL, …)`), the interior
12495/// elements are emitted bare (c:4393-4412), and the word suffix (`fstr`)
12496/// is concatenated onto the final element (c:4414-4429). Applied left-
12497/// associatively across a word's segments this reproduces zsh's
12498/// `pre${arr}post` → `prep` / `q` / `rpost`.
12499///
12500/// An EMPTY array never reaches this arm in C: c:4261
12501/// `if ((!aval[0] || !aval[1]) && !plan9)` collapses it to the scalar ""
12502/// first, so the surrounding text survives as one word (`x$e y` → `x`).
12503/// That is what the empty-Array arms below reproduce.
12504fn concat_splice(lhs: Value, rhs: Value) -> Value {
12505    match (lhs, rhs) {
12506        (Value::Array(la), Value::Array(ra)) => {
12507            if la.is_empty() {
12508                return Value::Array(ra);
12509            }
12510            if ra.is_empty() {
12511                return Value::Array(la);
12512            }
12513            // Last of la merges with first of ra; rest unchanged.
12514            let mut la = la.to_vec();
12515            let last_l = la.pop().unwrap();
12516            let mut ra_iter = ra.iter().cloned();
12517            let first_r = ra_iter.next().unwrap();
12518            let l_s = last_l.as_str_cow();
12519            let r_s = first_r.as_str_cow();
12520            let mut merged = String::with_capacity(l_s.len() + r_s.len());
12521            merged.push_str(&l_s);
12522            merged.push_str(&r_s);
12523            la.push(Value::str(merged));
12524            la.extend(ra_iter);
12525            Value::array(la)
12526        }
12527        (Value::Array(la), rhs_scalar) => {
12528            // c:4261 — empty array + empty surrounding text is zero
12529            // words, not one empty word. Bug #120 in docs/BUGS.md:
12530            // `b=("${a[@]:0:-1}")` gave len=1 instead of zsh's len=0.
12531            let rhs_s = rhs_scalar.as_str_cow();
12532            if la.is_empty() {
12533                if rhs_s.is_empty() {
12534                    return Value::array(Vec::new());
12535                }
12536                return Value::str(rhs_s.to_string());
12537            }
12538            let mut la = la.to_vec();
12539            let last = la.pop().unwrap();
12540            let l_s = last.as_str_cow();
12541            let mut s = String::with_capacity(l_s.len() + rhs_s.len());
12542            s.push_str(&l_s);
12543            s.push_str(&rhs_s);
12544            la.push(Value::str(s));
12545            Value::array(la)
12546        }
12547        (lhs_scalar, Value::Array(ra)) => {
12548            let lhs_s = lhs_scalar.as_str_cow();
12549            if ra.is_empty() {
12550                // Symmetric c:4261 empty-array rule; see the arm above.
12551                if lhs_s.is_empty() {
12552                    return Value::array(Vec::new());
12553                }
12554                return Value::str(lhs_s.to_string());
12555            }
12556            let mut ra = ra.to_vec();
12557            let first = ra.remove(0);
12558            let r_s = first.as_str_cow();
12559            let mut s = String::with_capacity(lhs_s.len() + r_s.len());
12560            s.push_str(&lhs_s);
12561            s.push_str(&r_s);
12562            let mut out = Vec::with_capacity(ra.len() + 1);
12563            out.push(Value::str(s));
12564            out.extend(ra);
12565            Value::array(out)
12566        }
12567        (lhs_s, rhs_s) => {
12568            let l = lhs_s.as_str_cow();
12569            let r = rhs_s.as_str_cow();
12570            let mut s = String::with_capacity(l.len() + r.len());
12571            s.push_str(&l);
12572            s.push_str(&r);
12573            Value::str(s)
12574        }
12575    }
12576}
12577
12578/// c:Src/subst.c:4316-4365 — the plan9 (RC_EXPAND_PARAM) arm of
12579/// paramsubst's array emit block.
12580///
12581/// Every element gets the FULL word prefix and suffix
12582/// (c:4341 `strcatsub(&y, ostr, aptr, x, xlen, y + 1, …)` inside the
12583/// per-element loop), giving the cross product with the surrounding
12584/// text: `pre${arr}post` → `preppost` / `preqpost` / `prerpost`.
12585///
12586/// An EMPTY array removes the WHOLE word: the c:4327
12587/// `while ((x = *aval++))` loop body never runs, so `plan9` is still
12588/// non-zero at c:4362 and the node is deleted —
12589/// `if (plan9) { uremnode(l, n); return n; }` (c:4362-4365). `e=();
12590/// setopt RC_EXPAND_PARAM; print -rl -- x$e y` prints only `y`. This is
12591/// the opposite of the non-plan9 rule at c:4261, which keeps `x`.
12592fn concat_plan9(lhs: Value, rhs: Value) -> Value {
12593    // c:4245 `if (isarr)` — an empty SCALAR never enters the array emit
12594    // block, so it contributes "" and the word survives (c:4437). Only a
12595    // real empty ARRAY reaches c:4362's `uremnode`. `Value` cannot tell
12596    // the two apart; EMPTY_EXPANSION_IS_SCALAR carries the missing bit.
12597    let scalar_empty = empty_is_scalar();
12598    match (lhs, rhs) {
12599        // c:4362-4365 — an empty array on either side deletes the word.
12600        // Propagated as an empty Array so a later concat in the same word
12601        // (`x${e[@]}y` folds twice) keeps the word deleted; pop_args
12602        // splats an empty Array into zero argv words.
12603        (Value::Array(la), rhs_v) if la.is_empty() => {
12604            if scalar_empty {
12605                // Empty scalar prefix: "" + rhs (c:4437 strcatsub).
12606                return match rhs_v {
12607                    Value::Array(ra) if ra.is_empty() => Value::array(Vec::new()),
12608                    other => other,
12609                };
12610            }
12611            Value::array(Vec::new())
12612        }
12613        (lhs_v, Value::Array(ra)) if ra.is_empty() => {
12614            if scalar_empty {
12615                // Empty scalar suffix: lhs + "" (c:4437 strcatsub).
12616                return lhs_v;
12617            }
12618            Value::array(Vec::new())
12619        }
12620        (Value::Array(la), Value::Array(ra)) => {
12621            let mut out = Vec::with_capacity(la.len() * ra.len());
12622            for a in la.iter() {
12623                let a_s = a.as_str_cow();
12624                for b in ra.iter() {
12625                    let b_s = b.as_str_cow();
12626                    let mut s = String::with_capacity(a_s.len() + b_s.len());
12627                    s.push_str(&a_s);
12628                    s.push_str(&b_s);
12629                    out.push(Value::str(s));
12630                }
12631            }
12632            Value::array(out)
12633        }
12634        (Value::Array(la), rhs_scalar) => {
12635            let r = rhs_scalar.as_str_cow();
12636            let out: Vec<Value> = la
12637                .iter()
12638                .map(|a| {
12639                    let a_s = a.as_str_cow();
12640                    let mut s = String::with_capacity(a_s.len() + r.len());
12641                    s.push_str(&a_s);
12642                    s.push_str(&r);
12643                    Value::str(s)
12644                })
12645                .collect();
12646            Value::array(out)
12647        }
12648        (lhs_scalar, Value::Array(ra)) => {
12649            let l = lhs_scalar.as_str_cow();
12650            let out: Vec<Value> = ra
12651                .iter()
12652                .map(|b| {
12653                    let b_s = b.as_str_cow();
12654                    let mut s = String::with_capacity(l.len() + b_s.len());
12655                    s.push_str(&l);
12656                    s.push_str(&b_s);
12657                    Value::str(s)
12658                })
12659                .collect();
12660            Value::array(out)
12661        }
12662        (lhs_s, rhs_s) => {
12663            // Both scalar: nothing to distribute (c:4444 scalar branch).
12664            let l = lhs_s.as_str_cow();
12665            let r = rhs_s.as_str_cow();
12666            let mut s = String::with_capacity(l.len() + r.len());
12667            s.push_str(&l);
12668            s.push_str(&r);
12669            Value::str(s)
12670        }
12671    }
12672}
12673
12674/// Flatten one word-segment `Value` into its element strings: an Array splats
12675/// to its items, a scalar is a single element.
12676fn word_seg_elems(v: &Value) -> Vec<String> {
12677    match v {
12678        Value::Array(items) => items.iter().map(|i| i.as_str_cow().into_owned()).collect(),
12679        other => vec![other.as_str_cow().into_owned()],
12680    }
12681}
12682
12683/// Assemble a DQ word from its segments, mixing plan9 (`^`, cross-product) and
12684/// non-plan9 (splice) segments in one pass — see BUILTIN_WORD_ASSEMBLE_PLAN9.
12685///
12686/// c:Src/subst.c:4316-4437 — zsh threads a "growing edge" (`aptr`/`fstr`)
12687/// through the whole word: an element stays active until a splice freezes all
12688/// but the last. `active_lo` is the index where that active tail begins.
12689///   * plan9 segment  → every active element crosses with EVERY new element;
12690///     all results stay active (c:4316-4350 cartesian). An empty plan9 array
12691///     deletes the word (c:4362 `uremnode`).
12692///   * splice segment → every active element takes the FIRST new element, the
12693///     remaining new elements append as fresh words; the last becomes the new
12694///     growing edge (c:4366-4437 first/last join). A single-element splice keeps
12695///     the whole active tail active (nothing frozen). An empty splice array
12696///     contributes nothing and leaves the word intact.
12697fn word_assemble_plan9(segments: &[Value], plan9_flags: &[bool]) -> Value {
12698    let mut words: Vec<String> = Vec::new();
12699    let mut active_lo: usize = 0;
12700    let mut started = false;
12701    for (i, seg) in segments.iter().enumerate() {
12702        let plan9 = plan9_flags.get(i).copied().unwrap_or(false);
12703        let elems = word_seg_elems(seg);
12704        if plan9 && elems.is_empty() {
12705            // c:4362-4365 — plan9 empty array deletes the whole word.
12706            return Value::array(Vec::new());
12707        }
12708        if !started {
12709            started = true;
12710            // c:Src/subst.c:4261 — `if ((!aval[0] || !aval[1]) && !plan9)`.
12711            // A NON-plan9 EMPTY expansion (empty array, or an empty scalar
12712            // that zshrs collapsed to the same empty `Value::Array`) is
12713            // folded into the word text as the empty string and the node
12714            // SURVIVES (c:4268-4274 `strcatsub` of prefix + "" + suffix).
12715            // Only the plan9 arm deletes the word, and that is the
12716            // `uremnode` case already returned above (c:4362-4365).
12717            //
12718            // Seeding `words` with that single empty element is what keeps a
12719            // growing edge alive for the segments that follow. Leaving
12720            // `words` empty instead made `words[active_lo..]` an empty slice
12721            // forever, so every later segment cross-multiplied against
12722            // nothing and the whole word vanished: `n=""; a=(x y z);
12723            // print -rl -- $n${^a}` printed nothing where zsh prints
12724            // `x`/`y`/`z`, and `$n$a${^a}` dropped the leading `x`. Only a
12725            // word whose FIRST segment was the empty one was affected —
12726            // `pre$n${^a}` already started from the literal and hit the
12727            // "contributes nothing" `continue` below.
12728            words = if elems.is_empty() {
12729                vec![String::new()]
12730            } else {
12731                elems
12732            };
12733            // plan9 → the whole first array is the growing edge; splice/scalar
12734            // → only its last element grows, earlier ones are finalized words.
12735            active_lo = if plan9 {
12736                0
12737            } else {
12738                words.len().saturating_sub(1)
12739            };
12740            continue;
12741        }
12742        if plan9 {
12743            let mut new_active = Vec::with_capacity(words[active_lo..].len() * elems.len());
12744            for a in &words[active_lo..] {
12745                for r in &elems {
12746                    new_active.push(format!("{a}{r}"));
12747                }
12748            }
12749            let frozen_len = active_lo;
12750            words.truncate(frozen_len);
12751            words.extend(new_active);
12752            active_lo = frozen_len; // all cross-products stay active
12753        } else {
12754            if elems.is_empty() {
12755                // Non-plan9 empty array contributes nothing; word survives.
12756                continue;
12757            }
12758            let frozen_len = active_lo;
12759            let r0 = &elems[0];
12760            let head: Vec<String> = words[active_lo..]
12761                .iter()
12762                .map(|a| format!("{a}{r0}"))
12763                .collect();
12764            words.truncate(frozen_len);
12765            words.extend(head);
12766            words.extend(elems[1..].iter().cloned());
12767            active_lo = if elems.len() == 1 {
12768                frozen_len // single-element splice: head stays the growing edge
12769            } else {
12770                words.len() - 1 // multi: only the last appended word grows
12771            };
12772        }
12773    }
12774    match words.len() {
12775        0 => Value::array(Vec::new()),
12776        1 => Value::str(words.pop().unwrap()),
12777        _ => Value::array(words.into_iter().map(Value::str).collect()),
12778    }
12779}
12780
12781fn nodes_to_value(nodes: Vec<String>) -> Value {
12782    // c:Src/glob.c:3649 remnulargs — strip the Nularg (`\u{a1}`)
12783    //   sentinel and other INULL bytes that paramsubst's splat block
12784    //   emits for empty array elements (so prefork's empty-node-delete
12785    //   pass doesn't drop them). Downstream consumers (cond `-z`/`-n`,
12786    //   command args, etc.) must see the post-remnulargs strings. Bug
12787    //   #185 in docs/BUGS.md: `[[ -z "${b[@]}" ]]` for b=("") returned
12788    //   false because the leftover `\u{a1}` had StringLen=1.
12789    let stripped: Vec<String> = nodes
12790        .into_iter()
12791        .map(|mut s| {
12792            crate::ported::glob::remnulargs(&mut s);
12793            s
12794        })
12795        .collect();
12796    if stripped.is_empty() {
12797        // Zero nodes = an ARRAY-shaped expansion that produced no words
12798        // (empty array splat, empty slice). c:4245 `if (isarr)` holds, so
12799        // plan9 deletes the surrounding word (c:4362).
12800        note_empty_is_scalar(false);
12801        Value::array(Vec::new())
12802    } else if stripped.len() == 1 {
12803        let only = stripped.into_iter().next().unwrap();
12804        // c:Src/subst.c:183-186 — `else if (!(flags & PREFORK_SINGLE)
12805        // && !(*ret_flags & PREFORK_KEY_VALUE) && !keep)
12806        //   uremnode(list, node);`
12807        // C zsh's prefork removes empty linknodes from the result
12808        // list when in non-SINGLE (argv-context) mode. The ported
12809        // prefork at subst.rs:388-396 honors the same delete-empty
12810        // pass, but some paramsubst paths land here with a single-
12811        // empty-string Vec instead of an empty Vec (paramsubst's
12812        // slice / substring / parameter-flag branches allocate a
12813        // result before checking emptiness). Mirror the prefork
12814        // drop at this layer: single-empty under !in_dq_context
12815        // collapses to Value::Array(empty), and pop_args (line 6243)
12816        // splats the empty Array → zero argv words. DQ context
12817        // (in_dq_context > 0) keeps the empty string so
12818        // `echo "${UNSET}"` still produces an empty arg per zsh's
12819        // quoting rules (c:Src/subst.c:1650-1656 isarr comment).
12820        if only.is_empty() {
12821            let in_dq = with_executor(|exec| exec.in_dq_context > 0);
12822            if !in_dq {
12823                // One empty node = a SCALAR-shaped empty result (c:4437),
12824                // not an empty array — see EMPTY_EXPANSION_IS_SCALAR.
12825                note_empty_is_scalar(true);
12826                return Value::array(Vec::new());
12827            }
12828        }
12829        Value::str(only)
12830    } else {
12831        Value::array(stripped.into_iter().map(Value::str).collect())
12832    }
12833}
12834
12835fn pop_args(vm: &mut fusevm::VM, argc: u8) -> Vec<String> {
12836    let mut popped: Vec<Value> = Vec::with_capacity(argc as usize);
12837    for _ in 0..argc {
12838        popped.push(vm.pop());
12839    }
12840    popped.reverse();
12841    let mut args: Vec<String> = Vec::with_capacity(popped.len());
12842    for v in popped {
12843        match v {
12844            Value::Array(items) => {
12845                for item in items.iter() {
12846                    args.push(item.to_str());
12847                }
12848            }
12849            other => args.push(other.to_str()),
12850        }
12851    }
12852    // `expand_glob` set the glob-failed cell when a no-match glob
12853    // triggered nomatch (c:Src/glob.c:1877). Signal the failure via
12854    // last_status + the per-command glob_failed cell; the dispatcher
12855    // (`host_exec_external`) consumes + clears it and returns status 1
12856    // without running the command body.
12857    if with_executor(|exec| exec.current_command_glob_failed.get()) {
12858        with_executor(|exec| exec.set_last_status(1));
12859    }
12860    // c:Src/exec.c:2709 setunderscore / c:Src/params.c:252 underscore_gsu
12861    // — `$_` has exactly ONE store in zsh, the `zunderscore` global
12862    // (`Src/init.c:49`), read back through `underscoregetfn`. It is
12863    // never written into the parameter table: the `_` Param created by
12864    // `IPDEF2("_", underscore_gsu, PM_DONTIMPORT)` (c:Src/params.c:326)
12865    // carries `nullstrsetfn` as its setfn, so even `_=x` stores nothing.
12866    //
12867    // The deferred `pending_underscore` → `set_scalar("_")` promotion
12868    // that used to live here was a second, contradictory store: it wrote
12869    // the paramtab node, which (a) CLEARED the PM_UNSET that `unset _`
12870    // had just set — resurrecting the parameter, so `${+_}` flipped back
12871    // to 1 and `$_` kept reading a value where zsh reports empty — and
12872    // (b) shadowed the canonical zunderscore value. Every dispatch path
12873    // now calls `set_zunderscore` (the `setunderscore` equivalent) just
12874    // before running its command, which is where C sets it
12875    // (execcmd_exec, c:3545-3547), so the deferral is unnecessary as
12876    // well: argument expansion has already happened by then, exactly as
12877    // in C.
12878    args
12879}
12880
12881/// zsh dispatch order is alias → function → builtin → external. The
12882/// compiler emits direct CallBuiltin ops for known builtin names for
12883/// perf, which silently skips a user function that shadows the same
12884/// name (e.g. `echo() { ... }; echo hi` would run the C builtin
12885/// without this check). Returns Some(status) when the call is routed
12886/// to the user function; the builtin handler should fall through to
12887/// its native impl when None.
12888/// Fork+exec a system binary by name. Used by `reg_overridable!` as
12889/// the fall-through path when `[builtins].coreutils_shadows = off`
12890/// (the default) — runs the canonical `/bin/X` instead of zshrs's
12891/// in-process shadow so old scripts hit zero behavioral divergence.
12892///
12893/// Inherits stdin/stdout/stderr from the parent so pipelines work
12894/// transparently. Resolves the binary via PATH; mirrors what zsh's
12895/// own external-command dispatch would do. Returns the child's exit
12896/// status (or 127 if PATH lookup fails — the standard "command not
12897/// found" code).
12898/// RAII guard that queues signals for the lifetime of a synchronous
12899/// foreground `waitpid` (via `std::process::Command::status`/`wait`).
12900///
12901/// zshrs installs a process-wide SIGCHLD handler (`zhandler` →
12902/// `wait_for_processes` → `waitpid(-1, WNOHANG)`) that reaps EVERY
12903/// exited child to drive the job table. `std::process::Command` does
12904/// its own targeted `waitpid(pid)`; when the reaper fires on any
12905/// thread between the fork and that wait, it reaps the child first and
12906/// `Command::status()` fails with ECHILD ("No child processes (os
12907/// error 10)"). This surfaced as `zshrs: hostname: No child processes
12908/// (os error 10)` when a coreutils shadow (`coreutils_shadows = off`
12909/// default) fork-execs `/usr/bin/hostname` while a background prewarm
12910/// child exits at the same instant.
12911///
12912/// Holding this guard bumps `queueing_enabled` (a global SeqCst atomic
12913/// that `zhandler` honors on every thread), so a SIGCHLD arriving
12914/// during the wait is pushed onto the deferred queue instead of being
12915/// reaped — `Command::status()` reaps its own child and reads the real
12916/// status. On drop, `unqueue_signals()` drains the queue, so any
12917/// genuine background children that exited meanwhile still get reaped
12918/// and routed to the job table. This is the same queue_signals /
12919/// unqueue_signals fencing zsh uses around its own foreground waits
12920/// (Src/exec.c). Panic-safe via `Drop`.
12921/// Apply `entersubsh`'s trap reset to the CURRENT process state.
12922///
12923/// !!! WARNING: RUST-ONLY HELPER !!!
12924/// C does this inline inside `entersubsh` (c:Src/exec.c:1088-1092), which
12925/// every subshell — `( … )` AND each forked pipeline stage — funnels
12926/// through. zshrs has two separate places that enter a subshell context
12927/// (the in-process `subshell_begin` and the pipeline stage fork), so the
12928/// reset lives here to keep them from drifting apart.
12929///
12930/// ```c
12931/// if (!(flags & ESUB_KEEPTRAP))
12932///     for (sig = 0; sig <= SIGCOUNT; sig++)
12933///         if (!(sigtrapped[sig] & ZSIG_FUNC) &&
12934///             !(isset(POSIXTRAPS) && (sigtrapped[sig] & ZSIG_IGNORED)))
12935///             unsettrap(sig);
12936/// ```
12937///
12938/// `unsettrap` clears BOTH the body and the sigtrapped flags, so both
12939/// stores are reset here. Function-form traps (ZSIG_FUNC, kept in
12940/// shfunctab as TRAPxxx) survive by construction; under POSIX_TRAPS an
12941/// IGNORED trap survives too. The loop bound stops below the pseudo
12942/// signals (c:Src/signals.h:34-35), so ERR/ZERR and DEBUG survive while
12943/// SIGEXIT (sig 0) is cleared.
12944fn entersubsh_reset_traps() {
12945    let posixtraps = crate::ported::zsh_h::isset(crate::ported::zsh_h::POSIXTRAPS);
12946    if let Ok(mut tbl) = crate::ported::builtin::traps_table().lock() {
12947        tbl.retain(|name, body| {
12948            // Above SIGCOUNT — outside c:1088's loop entirely.
12949            if name == "ERR" || name == "ZERR" || name == "DEBUG" {
12950                return true;
12951            }
12952            // c:1090-1092 — otherwise keep ONLY (POSIXTRAPS && ignored).
12953            posixtraps && body.is_empty()
12954        });
12955    }
12956    if let Ok(mut st) = crate::ported::signals::sigtrapped.lock() {
12957        let count = crate::ported::signals_h::SIGCOUNT as usize;
12958        for sig in 0..st.len().min(count + 1) {
12959            let state = st[sig];
12960            if state == 0 {
12961                continue;
12962            }
12963            if (state & crate::ported::zsh_h::ZSIG_FUNC) != 0 {
12964                continue; // c:1090
12965            }
12966            if posixtraps && (state & crate::ported::zsh_h::ZSIG_IGNORED) != 0 {
12967                continue; // c:1091
12968            }
12969            st[sig] = 0; // c:1092 unsettrap(sig)
12970        }
12971    }
12972}
12973
12974// ---------------------------------------------------------------------------
12975// In-process subshell: signal delivery belongs to the PARENT.
12976// ---------------------------------------------------------------------------
12977
12978/// Depth of nested in-process `( … )` subshells, for the signal
12979/// bookkeeping below.
12980///
12981/// !!! WARNING: RUST-ONLY HELPER !!!
12982/// C has no counter because it forks: `entersubsh` (`Src/exec.c:1123`)
12983/// runs in a fresh process whose signal state is a private copy. This
12984/// counter exists only so `subshell_signal_enter` / `_leave` can tell
12985/// the OUTERMOST boundary — the one where `$$` stops naming the shell
12986/// that is running the body — from a nested one.
12987static SUBSH_SIGNAL_DEPTH: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0);
12988
12989/// The top-level shell's `sigtrapped[]` while an in-process subshell
12990/// runs; `None` when no subshell is active.
12991///
12992/// !!! WARNING: RUST-ONLY HELPER !!!
12993/// This is C's `sigtrapped[]` (`Src/signals.c:39`) as the PARENT still
12994/// sees it — in C that array simply stays put in the parent process
12995/// while the forked child mutates its own copy. zshrs has one process
12996/// and one array, so the parent's view has to be kept beside it.
12997static SUBSH_PARENT_SIGTRAPPED: std::sync::Mutex<Option<Vec<i32>>> = std::sync::Mutex::new(None);
12998
12999/// Signals delivered to the process while an in-process subshell was
13000/// running, which the PARENT traps. Replayed at the outermost
13001/// `subshell_end`.
13002///
13003/// !!! WARNING: RUST-ONLY HELPER !!!
13004/// The C analogue is `trap_queue[]` (`Src/signals.c:92`): the parent
13005/// sets `trap_queueing_enabled` in `zwaitjob` (`Src/jobs.c:1688`
13006/// `queue_traps(wait_cmd)`) for as long as the child runs, so a trap
13007/// that arrives meanwhile is deferred and dispatched by
13008/// `unqueue_traps()` (`Src/jobs.c:1751` → `Src/signals.c:1041-1047`)
13009/// once the child is reaped. A separate queue is needed here because
13010/// zshrs's `trap_queue` gate (`handletrap`, c:974) tests the LIVE
13011/// `sigtrapped[]`, which the in-process subshell has already cleared.
13012static SUBSH_DEFERRED_SIGS: std::sync::Mutex<Vec<i32>> = std::sync::Mutex::new(Vec::new());
13013
13014/// Called from `zhandler` (`src/ported/signals.rs`, at C's c:410
13015/// queueing check) before any trap dispatch. Returns 1 when the
13016/// delivery has been recorded for the parent and the handler must
13017/// return without acting.
13018///
13019/// !!! WARNING: RUST-ONLY HELPER !!!
13020/// C needs nothing here. `( … )` forks (`Src/exec.c:2922`
13021/// `entersubsh(flags, &esret)`), so `kill -USR1 $$` inside the
13022/// subshell names the PARENT process: the child's handlers are never
13023/// entered, and the parent — which `entersubsh` never touched —
13024/// runs its own trap once `zwaitjob` drains the trap queue. zshrs runs
13025/// the body in the same process, so the one set of handlers has to
13026/// answer for both roles. Recording the delivery and replaying it
13027/// against the parent's restored table at `subshell_end` reproduces
13028/// both the disposition zsh picks and the point at which it runs.
13029///
13030/// SIGCHLD and SIGWINCH are never deferred, matching the two signals C
13031/// itself excludes from the `settrap` / `removetrap` disposition
13032/// switches (`Src/signals.c:714-718`, `Src/signals.c:810-814`): job
13033/// reaping and resize redisplay have to stay live inside the body.
13034///
13035/// When the parent has NO trap for the signal this returns 0 and the
13036/// handler proceeds normally. That is the one leg an in-process
13037/// subshell cannot reproduce: C's parent would take the DEFAULT action
13038/// (`( trap 'echo IN' USR1; kill -USR1 $$ )` kills zsh outright), which
13039/// here would have to kill the shell out from under a body that C
13040/// leaves running.
13041pub fn subshell_defer_signal(sig: libc::c_int) -> i32 {
13042    if sig == libc::SIGCHLD || sig == libc::SIGWINCH {
13043        return 0;
13044    }
13045    let idx = crate::ported::signals_h::SIGIDX(sig) as usize;
13046    // `try_lock`, not `lock`: this runs inside a signal handler, so a
13047    // delivery that lands while the SAME thread is mid-`subshell_signal_enter`
13048    // would self-deadlock on a blocking acquire. A miss degrades to normal
13049    // handling, which is what happened before this hook existed.
13050    let parent = match SUBSH_PARENT_SIGTRAPPED.try_lock() {
13051        Ok(g) => match g.as_ref() {
13052            Some(v) => v.get(idx).copied().unwrap_or(0),
13053            None => return 0,
13054        },
13055        Err(_) => return 0,
13056    };
13057    if parent == 0 {
13058        return 0;
13059    }
13060    if (parent & crate::ported::zsh_h::ZSIG_IGNORED) != 0 {
13061        // The parent ignores it; C's parent process would drop it on
13062        // the floor (`Src/signals.c:713-719` installed SIG_IGN).
13063        return 1;
13064    }
13065    match SUBSH_DEFERRED_SIGS.try_lock() {
13066        Ok(mut q) => q.push(sig),
13067        // Could not record it; fall through to normal handling rather
13068        // than swallow the delivery outright.
13069        Err(_) => return 0,
13070    }
13071    1
13072}
13073
13074/// Arm parent-side signal delivery for the duration of an in-process
13075/// `( … )`. Must run BEFORE `entersubsh_reset_traps` clears the table.
13076///
13077/// !!! WARNING: RUST-ONLY HELPER !!!
13078/// See `subshell_defer_signal`. Only the OUTERMOST subshell records the
13079/// snapshot: `$$` names the top-level shell at every nesting level, so
13080/// a nested `( ( … ) )` still routes deliveries to the same table.
13081fn subshell_signal_enter() {
13082    if SUBSH_SIGNAL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
13083        let snap = crate::ported::signals::sigtrapped
13084            .lock()
13085            .map(|g| g.clone())
13086            .unwrap_or_default();
13087        if let Ok(mut p) = SUBSH_PARENT_SIGTRAPPED.lock() {
13088            *p = Some(snap);
13089        }
13090        if let Ok(mut q) = SUBSH_DEFERRED_SIGS.lock() {
13091            q.clear();
13092        }
13093    }
13094}
13095
13096/// Disarm parent-side delivery and dispatch whatever arrived. Must run
13097/// AFTER `subshell_end` has put the parent's `sigtrapped[]` and
13098/// `traps_table` back.
13099///
13100/// !!! WARNING: RUST-ONLY HELPER !!!
13101/// The dispatch itself is C's `unqueue_traps()` tail
13102/// (`Src/signals.c:1043-1047`): `while (trap_queue_front !=
13103/// trap_queue_rear) handletrap(trap_queue[…])`, run at the point
13104/// `zwaitjob` reaches after the child is reaped (`Src/jobs.c:1751`).
13105fn subshell_signal_leave() {
13106    if SUBSH_SIGNAL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::SeqCst) != 1 {
13107        return;
13108    }
13109    if let Ok(mut p) = SUBSH_PARENT_SIGTRAPPED.lock() {
13110        *p = None;
13111    }
13112    let pending: Vec<i32> = match SUBSH_DEFERRED_SIGS.lock() {
13113        Ok(mut q) => std::mem::take(&mut *q),
13114        Err(_) => return,
13115    };
13116    for sig in pending {
13117        // c:Src/signals.c:1046 — `(void) handletrap(trap_queue[…]);`
13118        let _ = crate::ported::signals::handletrap(sig);
13119    }
13120}
13121
13122/// Put the process's signal DISPOSITIONS back the way the parent had
13123/// them, for every signal whose trap state the subshell changed.
13124///
13125/// !!! WARNING: RUST-ONLY HELPER !!!
13126/// C gets this from the fork: `trap 'echo IN' USR1` inside `( … )`
13127/// calls `settrap` → `install_handler` (`Src/signals.c:724-732`) in the
13128/// CHILD, so the parent's `SIG_DFL` survives untouched and a later
13129/// `kill -USR1 $$` still kills the shell. zshrs shares the process, so
13130/// the child's `sigaction` leaked out and swallowed the signal. The
13131/// branch order below is `removetrap`'s tail verbatim
13132/// (`Src/signals.c:801-815`) for the "parent had no trap" case, and
13133/// `settrap`'s two installs (`Src/signals.c:713-719` ignored,
13134/// `Src/signals.c:724-732` trapped) for the others.
13135fn subshell_restore_signal_dispositions(parent: &[i32], child: &[i32]) {
13136    let count = crate::ported::signals_h::SIGCOUNT;
13137    for sig in 1..=count {
13138        let i = sig as usize;
13139        let p = parent.get(i).copied().unwrap_or(0);
13140        let c = child.get(i).copied().unwrap_or(0);
13141        if p == c {
13142            continue;
13143        }
13144        // c:714-718 / c:810-814 — C leaves these two alone in both
13145        // the install and the reset switch.
13146        if sig == libc::SIGWINCH || sig == libc::SIGCHLD {
13147            continue;
13148        }
13149        if (p & crate::ported::zsh_h::ZSIG_IGNORED) != 0 {
13150            crate::ported::signals_h::signal_ignore(sig); // c:719
13151        } else if p != 0 {
13152            crate::ported::signals::install_handler(sig); // c:732
13153        } else if sig == libc::SIGINT && crate::ported::signals::is_interact() {
13154            crate::ported::signals::intr(); // c:804
13155            crate::ported::signals::noholdintr(); // c:805
13156        } else if sig == libc::SIGHUP {
13157            crate::ported::signals::install_handler(sig); // c:807
13158        } else if sig == libc::SIGPIPE
13159            && crate::ported::signals::is_interact()
13160            && crate::ported::exec::FORKLEVEL.load(std::sync::atomic::Ordering::Relaxed) == 0
13161        {
13162            crate::ported::signals::install_handler(sig); // c:809
13163        } else {
13164            crate::ported::signals_h::signal_default(sig); // c:815
13165        }
13166    }
13167}
13168
13169/// Stack of the parent's `limits[]` and `current_limits[]` across
13170/// in-process `( … )` bodies.
13171///
13172/// !!! WARNING: RUST-ONLY HELPER !!!
13173/// C's `limits[]` / `current_limits[]` (`Src/exec.c:315`) are copied by
13174/// the fork, and `zfork` applies the child's copy to the child process
13175/// (`Src/exec.c:381-383` `setlimits(NULL)`), so `( ulimit -n 256 )`
13176/// cannot be seen by the parent. zshrs shares the process, so the array
13177/// and the real `setrlimit` state both have to be rolled back by hand.
13178///
13179/// BOTH arrays are needed, and they are not the same rollback. C keeps
13180/// them apart on purpose: `limits[]` is what the shell WANTS,
13181/// `current_limits[]` is what `setrlimit` has actually been given
13182/// (`zsetlimit`, c:316-331, only calls `setrlimit` where the two
13183/// disagree). `limit descriptors 256` without `-s` moves only the
13184/// first. So the restore replays `setrlimit` for exactly the resources
13185/// whose `current_limits[]` the BODY moved — never for a difference the
13186/// parent was already carrying, which C would not have applied either.
13187///
13188/// Thread-local because subshell begin/end always pair on one thread,
13189/// while a worker pool may have several in flight.
13190#[cfg(unix)]
13191thread_local! {
13192    static SUBSH_SAVED_LIMITS: std::cell::RefCell<Vec<(Vec<libc::rlimit>, Vec<libc::rlimit>)>> =
13193        const { std::cell::RefCell::new(Vec::new()) };
13194}
13195
13196/// `waitpid(pid, &status, 0)` that retries on `EINTR`.
13197///
13198/// !!! WARNING: RUST-ONLY HELPER !!!
13199/// No C counterpart — C zsh reaches the same place by blocking signals
13200/// around its foreground waits (`queue_signals` / `unqueue_signals`
13201/// fencing in `Src/exec.c`), so its `waitpid` is never interrupted in
13202/// the first place.
13203///
13204/// zshrs installs a process-wide SIGCHLD handler (`zhandler` →
13205/// `wait_for_processes`). When it fires while the shell is blocked in
13206/// this wait, `waitpid` returns -1/`EINTR` WITHOUT touching `status`.
13207/// The pipeline reap loop ignored the return value and read the
13208/// still-zero `status`, so `WIFEXITED(0)` was true and every FORKED
13209/// stage reported exit 0: `false | true` published `$pipestatus` as
13210/// `0 0` instead of zsh's `1 0`, and `setopt pipefail` had no non-zero
13211/// entry left to promote (c:Src/jobs.c:434-435 `if (jpipestats[i])
13212/// pipefail = jpipestats[i];`, applied at c:451-454).
13213///
13214/// Returns the raw wait status, or `None` if the child could not be
13215/// reaped at all (e.g. `ECHILD` because the handler won the race).
13216fn waitpid_eintr(pid: libc::pid_t) -> Option<i32> {
13217    loop {
13218        let mut status: i32 = 0;
13219        let rc = unsafe { libc::waitpid(pid, &mut status, 0) };
13220        if rc >= 0 {
13221            return Some(status);
13222        }
13223        let err = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
13224        if err != libc::EINTR {
13225            return None;
13226        }
13227    }
13228}
13229
13230pub(crate) struct ForegroundWaitGuard;
13231
13232impl ForegroundWaitGuard {
13233    #[inline]
13234    pub(crate) fn enter() -> Self {
13235        crate::ported::signals_h::queue_signals();
13236        ForegroundWaitGuard
13237    }
13238}
13239
13240impl Drop for ForegroundWaitGuard {
13241    #[inline]
13242    fn drop(&mut self) {
13243        crate::ported::signals_h::unqueue_signals();
13244    }
13245}
13246
13247fn exec_system_command(name: &str, args: &[String]) -> i32 {
13248    // c:Src/jobs.c — count the fork so `time` reports for an
13249    // overridable coreutils shadow run as an external (`time sleep 0`,
13250    // `time cat …`). This is a distinct spawn path from
13251    // execute_external_bg; without the bump BUILTIN_TIME_SUBLIST saw no
13252    // job and stayed silent. (Builtins that don't reach a spawn never
13253    // hit this fn.)
13254    crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
13255    // Queue signals across the wait so the SIGCHLD reaper can't steal
13256    // this child out from under Command::status — see ForegroundWaitGuard.
13257    let status = {
13258        let _wait_guard = ForegroundWaitGuard::enter();
13259        std::process::Command::new(name)
13260            .args(args)
13261            .stdin(std::process::Stdio::inherit())
13262            .stdout(std::process::Stdio::inherit())
13263            .stderr(std::process::Stdio::inherit())
13264            .status()
13265    };
13266    match status {
13267        Ok(s) => s.code().unwrap_or(if s.success() { 0 } else { 1 }),
13268        Err(e) => {
13269            eprintln!("zshrs: {}: {}", name, e);
13270            127
13271        }
13272    }
13273}
13274
13275/// !!! WARNING: RUST-ONLY HELPER !!!
13276///
13277/// C has no counterpart: `fork()` gives a forked `(...)` subshell its own
13278/// copy of the fd table, so the flock fds a subshell opens (`Src/utils.c:2111`
13279/// `addlockfd` marks them `FDT_FLOCK` / `FDT_FLOCK_EXEC`) vanish with the
13280/// child and its `fcntl(F_SETLK)` locks are released. zshrs runs `(...)`
13281/// in-process, so it has to enumerate those slots at subshell entry and
13282/// close the new ones at subshell exit. Walks the same `fdtable` /
13283/// `max_zsh_fd` pair as `zcloselockfd` (`Src/utils.c:2155-2164`).
13284fn current_flock_fds() -> Vec<i32> {
13285    use crate::ported::zsh_h::{FDT_FLOCK, FDT_FLOCK_EXEC};
13286    let max_fd = crate::ported::utils::MAX_ZSH_FD.load(std::sync::atomic::Ordering::Relaxed);
13287    if max_fd < 0 {
13288        return Vec::new();
13289    }
13290    (0..=max_fd)
13291        .filter(|fd| {
13292            let slot = crate::ported::utils::fdtable_get(*fd);
13293            slot == FDT_FLOCK || slot == FDT_FLOCK_EXEC
13294        })
13295        .collect()
13296}
13297
13298fn try_user_fn_override(name: &str, args: &[String]) -> Option<i32> {
13299    let has_fn = with_executor(|exec| {
13300        exec.functions_compiled.contains_key(name) || exec.function_exists(name)
13301    });
13302    if !has_fn {
13303        return None;
13304    }
13305    Some(with_executor(|exec| {
13306        exec.dispatch_function_call(name, args).unwrap_or(127)
13307    }))
13308}
13309
13310/// Builtin ID for `${name}` reads — routes through canonical
13311/// `getsparam` (Src/params.c:3076) via paramtab + env walk so nested
13312/// VMs (function calls) see the same storage.
13313pub const BUILTIN_GET_VAR: u16 = 283;
13314
13315/// Like `BUILTIN_GET_VAR` but forces double-quoted (DQ) semantics on
13316/// the read regardless of the runtime `in_dq_context`. The compiler
13317/// emits this for a QUOTED simple-var read (`"$name"`) — those compile
13318/// to a direct GET_VAR with no EXPAND_TEXT wrapper, so `in_dq_context`
13319/// is 0 and the plain GET_VAR would wrongly word-elide an array's empty
13320/// elements (`a=(1 "" 3); "$a"` must keep the empty → `1  3`, not
13321/// `1 3`). With force_dq the array joins via sepjoin keeping empties and
13322/// a scalar is returned verbatim (no empty-drop, no SH_WORD_SPLIT).
13323pub const BUILTIN_GET_VAR_DQ: u16 = 639;
13324
13325/// Builtin ID for `name=value` assignments — pops [name, value] and
13326/// routes through canonical `setsparam` (Src/params.c:3350).
13327pub const BUILTIN_SET_VAR: u16 = 284;
13328
13329/// Builtin ID that sets the thread-local [`SET_VAR_GLOB_ELIGIBLE`] flag true.
13330/// Emitted by the compiler immediately before a `BUILTIN_SET_VAR` whose scalar
13331/// RHS carried an UNQUOTED glob token, so the runtime knows the RHS is a literal
13332/// glob pattern eligible for GLOB_ASSIGN. Takes no stack args, pushes nothing.
13333pub const BUILTIN_MARK_GLOB_ELIGIBLE: u16 = 640;
13334
13335/// Builtin ID for pipeline execution. Pops N sub-chunk indices from the stack;
13336/// each index points into `vm.chunk.sub_chunks` (compiled stage bodies). Forks
13337/// N children, wires stdin/stdout between them via pipes, runs each stage's
13338/// bytecode on a fresh VM in its child, parent waits for all and pushes the
13339/// last stage's exit status. This is bytecode-native pipeline execution —
13340/// no tree-walker delegation.
13341pub const BUILTIN_RUN_PIPELINE: u16 = 285;
13342
13343/// Builtin ID for `Array → String` joining. Pops one value: if it's an Array,
13344/// joins its string-coerced elements with a single space; otherwise passes
13345/// through. Used after `Op::Glob` to convert the pattern's matched paths into
13346/// the single argv-token form the bytecode word model expects (no per-word
13347/// splitting yet — that's a future phase).
13348pub const BUILTIN_ARRAY_JOIN: u16 = 286;
13349
13350/// Builtin ID for `cmd &` background execution. IDs 287/288/289 are reserved
13351/// for the planned array work in Phase G1 (SET_ARRAY/SET_ASSOC/ARRAY_INDEX),
13352/// so this lands at 290. Pops the sub-chunk index then the job text; forks;
13353/// child detaches (`setsid`), runs the sub-chunk on a fresh VM, exits with
13354/// last_status; parent registers the job in the canonical JOBTAB
13355/// (initjob/addproc/spawnjob per c:Src/exec.c:1700-1758) so `jobs` / `wait
13356/// %N` / `kill %N` / `disown` and the zsh/parameter assocs all see it, then
13357/// returns Status(0) immediately.
13358pub const BUILTIN_RUN_BG: u16 = 290;
13359
13360/// Indexed-array assignment: `arr=(a b c)`. Compile_simple emits N element
13361/// pushes followed by name push, then `CallBuiltin(BUILTIN_SET_ARRAY, N+1)`.
13362/// The handler pops args (last popped = name in our pushing order) and stores
13363/// `Vec<String>` into `executor.arrays`. Tree-walker callers see the same
13364/// storage. Any prior scalar binding in `executor.variables` for `name` is
13365/// removed so `${name}` (scalar context) consistently reflects the array's
13366/// first element via `get_variable`.
13367pub const BUILTIN_SET_ARRAY: u16 = 287;
13368
13369/// Single-key set on an associative array: `foo[key]=val`. Stack (top-down):
13370/// [name, key, value]. Stores `value` into `executor.assoc_arrays[name][key]`,
13371/// creating the outer entry if missing. compile_simple detects `var[...]=...`
13372/// in assignments and emits this builtin.
13373pub const BUILTIN_SET_ASSOC: u16 = 288;
13374
13375/// `${arr[idx]}` — single-element array index. Pops two args:
13376///   stack: [name, idx_str]
13377/// Returns the indexed element as Value::str. Indexing semantics: zsh is
13378/// 1-based by default; bash is 0-based. We follow zsh.
13379/// Special idx values: `@` and `*` return the whole array as Value::Array
13380/// (which fuses correctly via the Op::Exec splice for argv splice).
13381pub const BUILTIN_ARRAY_INDEX: u16 = 289;
13382
13383/// `${#arr[@]}` and `${#arr}` (when arr is an array name) — array length.
13384/// Pops one arg: name. Returns Value::str of len.
13385
13386/// `${arr[@]}` — splice all elements as a Value::Array. Pops one arg: name.
13387/// The Array gets flattened by Op::Exec/ExecBg/CallFunction into argv.
13388pub const BUILTIN_ARRAY_ALL: u16 = 292;
13389
13390/// Flatten one level of Value::Array nesting. Pops N values; for each, if it's
13391/// a Value::Array, its elements are appended directly; otherwise the value is
13392/// appended as-is. Pushes a single Value::Array of the flattened result. Used
13393/// by the for-loop word-list compile path: when a word like `${arr[@]}`
13394/// produces a nested Array, this lets `for i in ${arr[@]}` iterate over the
13395/// inner elements rather than the outer single-element array.
13396pub const BUILTIN_ARRAY_FLATTEN: u16 = 293;
13397
13398/// `coproc [name] { body }` — bidirectional pipe to async child. Pops a name
13399/// (optional, "" for default) and a sub-chunk index. Creates two pipes, forks,
13400/// child redirects its fd 0/1 to the inner ends and runs the body, parent
13401/// stores [write_fd, read_fd] into the named array (default `COPROC`). Caller
13402/// closes the fds and `wait`s when done. Job-table integration deferred to
13403/// Phase G6 alongside the bg `&` work.
13404pub const BUILTIN_RUN_COPROC: u16 = 294;
13405
13406/// `arr+=(d e f)` — append N elements to an existing indexed array. Compile
13407/// emits N element pushes + name push, then `CallBuiltin(295, N+1)`. Handler
13408/// drains args (last popped = name), extends `executor.arrays[name]` (creates
13409/// the entry if missing). Mirrors zsh's `+=` semantics for indexed arrays.
13410pub const BUILTIN_APPEND_ARRAY: u16 = 295;
13411
13412/// `name[@]=(...)` / `name[*]=(...)` whole-array SET. Identical to
13413/// BUILTIN_SET_ARRAY for an indexed array / scalar (whole replace), but
13414/// rejects an associative target with "attempt to set slice of
13415/// associative array" (c:Src/params.c:3324-3327).
13416pub const BUILTIN_SET_ARRAY_AT: u16 = 633;
13417
13418/// `name[@]+=(...)` / `name[*]+=(...)` whole-array APPEND. Indexed
13419/// append (push), assoc target → same slice-of-assoc error as 633.
13420pub const BUILTIN_APPEND_ARRAY_AT: u16 = 634;
13421
13422/// `select var in words; do body; done` — interactive numbered-menu loop.
13423/// Compile emits N word pushes + var-name push + sub-chunk index push, then
13424/// `CallBuiltin(296, N+2)`. Handler prints `1) word1\n2) word2\n...` to
13425/// stderr, prints `$PROMPT3` (default `?# `) to stderr, reads a line from
13426/// stdin. On EOF returns 0. On a valid 1-based number, sets `var` to the
13427/// chosen word, runs the sub-chunk, then redisplays the menu and loops. On
13428/// invalid input redraws the menu without running the body. `break` from
13429/// inside the body exits the loop (handled by the body's own bytecode).
13430pub const BUILTIN_RUN_SELECT: u16 = 296;
13431
13432/// `m[k]+=value` — append onto an existing assoc-array value (string concat).
13433/// If the key doesn't exist, behaves like SET_ASSOC. Stack: [name, key, value].
13434
13435/// `break` from inside a body that runs on a sub-VM (select, future
13436/// loop-via-builtin constructs). Writes the canonical
13437/// `crate::ported::builtin::BREAKS` atomic (port of `Src/loop.c:46
13438/// breaks`). Outer-loop builtins drain BREAKS/CONTFLAG after each
13439/// body run, matching the loop.c:529-534 drain pattern.
13440pub const BUILTIN_SET_BREAK: u16 = 299;
13441
13442/// `continue` from inside a sub-VM body. Sets CONTFLAG=1 + bumps
13443/// BREAKS, matching `bin_break`'s WC_CONTINUE arm at Src/builtin.c
13444/// c:5836 `contflag = 1; FALLTHROUGH; breaks++;`.
13445pub const BUILTIN_SET_CONTINUE: u16 = 300;
13446
13447/// Brace expansion: `{a,b,c}` → 3 values, `{1..5}` → 5 values, `{01..05}` →
13448/// zero-padded numerics, `{a..e}` → letter range. Pops one string, returns
13449/// Value::Array of expansions (empty array → original string preserved).
13450pub const BUILTIN_BRACE_EXPAND: u16 = 301;
13451
13452/// Glob qualifier filter: `*(qualifier)` filters glob results by predicate.
13453/// Pops [pattern, qualifier_string]. Returns Value::Array of matching paths.
13454
13455/// Re-export the regex_match host method as a builtin so `[[ s =~ pat ]]`
13456/// works even when fusevm's Op::RegexMatch isn't routed (compat fallback).
13457
13458/// Word-split a string on IFS (default: whitespace). Pops one string,
13459/// returns Value::Array of fields. Used in array-literal context where
13460/// `arr=($(cmd))` should expand cmd's stdout into multiple elements.
13461pub const BUILTIN_WORD_SPLIT: u16 = 304;
13462
13463/// `${=name}` / SH_WORD_SPLIT forced IFS split — c:Src/subst.c:3920-3928
13464/// `aval = sepsplit(val, spsep, 0, 1);`.
13465///
13466/// Unlike BUILTIN_WORD_SPLIT (which routes through `multsub`'s
13467/// PREFORK_SPLIT walker — the c:553-620 loop that COLLAPSES runs of
13468/// separators and never emits an empty field), this is the *other* zsh
13469/// splitter: `sepsplit` → `Src/utils.c:3711 spacesplit(s, allownull=0)`,
13470/// which distinguishes the two IFS classes and preserves empty fields.
13471///
13472/// Stack: \[value\]. argc selects the empty-field rule:
13473///   * argc == 0 — the expansion is a bare unquoted word (`${=v}`): the
13474///     leading/trailing `""` fields spacesplit emits for skipped
13475///     IFS-WHITESPACE are empty argv words and prefork deletes them
13476///     (c:Src/subst.c:184-187 `uremnode`).
13477///   * argc == 1 — the expansion is quoted (`"${=v}"`) or has adjacent
13478///     word segments (`x${=v}y`): those fields survive, because in C the
13479///     word's `Dnull` quote markers / literal prefix+suffix attach to the
13480///     first and last elements (c:4386 / c:4429 strcatsub) and the node is
13481///     no longer empty. `v=$' a:b '` → `""`, `a:b`, `""` quoted; `x`,
13482///     `a:b`, `y` with surrounding text.
13483///
13484/// Empty fields that come from an IFS-NON-whitespace separator are the
13485/// `nulstring` (`Nularg`, c:Src/subst.c:36) and survive in BOTH cases —
13486/// `IFS=x; v=xaxbx` splits to `""`, `a`, `b`, `""` quoted or not.
13487pub const BUILTIN_FORCE_SPLIT: u16 = 643;
13488
13489/// Register a pre-compiled fusevm chunk as a function. Stack: [name,
13490/// base64-bincode-of-Chunk]. Used by compile_zsh's compile_funcdef to
13491/// register functions parsed via parse_init+parse without going through the
13492/// ShellCommand JSON serialization path.
13493pub const BUILTIN_REGISTER_COMPILED_FN: u16 = 305;
13494/// `BUILTIN_VAR_EXISTS` constant.
13495pub const BUILTIN_VAR_EXISTS: u16 = 306;
13496/// Native param-modifier builtins. Each takes a fixed argv shape and
13497/// returns the modified value as Value::Str.
13498///
13499/// `${var:-default}` / `${var:=default}` / `${var:?error}` / `${var:+alt}`
13500/// — pop [name, op_byte, rhs]. op_byte: 0=`:-`, 1=`:=`, 2=`:?`, 3=`:+`.
13501pub const BUILTIN_PARAM_DEFAULT_FAMILY: u16 = 307;
13502/// `${var:offset[:length]}` — pop [name, offset, length] (length=-1 means
13503/// "rest of value"; negative offset counts from end).
13504pub const BUILTIN_PARAM_SUBSTRING: u16 = 308;
13505/// `${var#pat}` / `${var##pat}` / `${var%pat}` / `${var%%pat}` — pop
13506/// [name, pattern, op_byte]. op_byte: 0=`#`, 1=`##`, 2=`%`, 3=`%%`.
13507pub const BUILTIN_PARAM_STRIP: u16 = 309;
13508/// `${var/pat/repl}` / `${var//pat/repl}` / `${var/#pat/repl}` /
13509/// `${var/%pat/repl}` — pop [name, pattern, replacement, op_byte].
13510/// op_byte: 0=first, 1=all, 2=anchor-prefix, 3=anchor-suffix.
13511pub const BUILTIN_PARAM_REPLACE: u16 = 310;
13512/// `${#name}` — character length of a scalar value, or element count
13513/// of an indexed/assoc array. Pops \[name\], returns count as Value::Str.
13514pub const BUILTIN_PARAM_LENGTH: u16 = 311;
13515/// `$((expr))` arithmetic substitution. Pops \[expr_string\], evaluates
13516/// via the executor's MathEval (integer-aware), returns result as
13517/// Value::Str. Bypasses ArithCompiler's float-only Op::Div path so
13518/// `$((10/3))` returns "3" not "3.333...".
13519pub const BUILTIN_ARITH_EVAL: u16 = 312;
13520/// `(( ... ))` math command post-eval status hook. Pops nothing,
13521/// pushes Value::Status. If errflag is set (math error in the
13522/// preceding BUILTIN_ARITH_EVAL call), clears it and emits status=2
13523/// matching c:Src/math.c arith-failure semantics. Otherwise emits
13524/// the current vm.last_status. Used by compile_arith's `(( ... ))`
13525/// path so the math command swallows errors without halting the
13526/// script — `$((... ))` substitutions skip this hook so their
13527/// errflag propagates up to the containing command.
13528pub const BUILTIN_ARITH_CMD_FINISH: u16 = 527;
13529/// `$(cmd)` command substitution. Pops \[cmd_string\], runs through
13530/// `run_command_substitution` which compiles via parse_init+parse + ZshCompiler
13531/// and captures stdout via an in-process pipe. Returns trimmed output
13532/// as Value::Str. Avoids the sub-chunk word-emit quoting bug in the
13533/// raw Op::CmdSubst path.
13534pub const BUILTIN_CMD_SUBST_TEXT: u16 = 313;
13535/// Text-based word expansion. Pops \[preserved_text\]: the word with
13536/// quotes preserved (Dnull→`"`, Snull→`'`, Bnull→`\`), runs
13537/// `expand_string` (variable + cmd-sub + arith) then `xpandbraces`
13538/// then `expand_glob`. Returns Value::str (single match) or
13539/// Value::Array (multi-match brace/glob).
13540pub const BUILTIN_EXPAND_TEXT: u16 = 314;
13541
13542/// `[[ a -ef b ]]` — same-inode test. Stack: [a, b]. Pushes Bool true iff
13543/// both paths resolve to the same `(dev, inode)` pair (zsh + bash semantics).
13544pub const BUILTIN_SAME_FILE: u16 = 315;
13545
13546/// `[[ a -nt b ]]` — file `a` newer than file `b` (mtime strict).
13547/// Stack: [path_a, path_b]. Pushes Bool. zsh-compatible "missing"
13548/// rules: if both exist, compare mtime; if only `a` exists → true;
13549/// otherwise false.
13550pub const BUILTIN_FILE_NEWER: u16 = 324;
13551
13552/// `[[ a -ot b ]]` — mirror of `-nt`. If both exist, compare mtime;
13553/// if only `b` exists → true; otherwise false.
13554pub const BUILTIN_FILE_OLDER: u16 = 325;
13555
13556/// `[[ -k path ]]` — sticky bit (S_ISVTX) set on path.
13557pub const BUILTIN_HAS_STICKY: u16 = 326;
13558/// `[[ -u path ]]` — setuid bit (S_ISUID).
13559pub const BUILTIN_HAS_SETUID: u16 = 327;
13560/// `[[ -g path ]]` — setgid bit (S_ISGID).
13561pub const BUILTIN_HAS_SETGID: u16 = 328;
13562/// `[[ -O path ]]` — owned by effective UID.
13563pub const BUILTIN_OWNED_BY_USER: u16 = 329;
13564/// `[[ -G path ]]` — owned by effective GID.
13565pub const BUILTIN_OWNED_BY_GROUP: u16 = 330;
13566/// `[[ -N path ]]` — file modified since last accessed (atime <= mtime).
13567pub const BUILTIN_FILE_MODIFIED_SINCE_ACCESS: u16 = 341;
13568
13569/// `name+=val` (no parens) — runtime-dispatched append.
13570/// If name is an indexed array → push val as element.
13571/// If name is an assoc array → error (zsh requires `(k v)` form).
13572/// Else → scalar concat (existing SET_VAR behavior).
13573pub const BUILTIN_APPEND_SCALAR_OR_PUSH: u16 = 331;
13574
13575/// `[[ -c path ]]` — character device.
13576pub const BUILTIN_IS_CHARDEV: u16 = 332;
13577/// `[[ -b path ]]` — block device.
13578pub const BUILTIN_IS_BLOCKDEV: u16 = 333;
13579/// `[[ -p path ]]` — FIFO / named pipe.
13580pub const BUILTIN_IS_FIFO: u16 = 334;
13581/// `[[ -S path ]]` — socket.
13582pub const BUILTIN_IS_SOCKET: u16 = 335;
13583/// `BUILTIN_ERREXIT_CHECK` constant.
13584pub const BUILTIN_ERREXIT_CHECK: u16 = 336;
13585/// Fatal-error-only abort check, for use INSIDE an `&&` / `||` chain.
13586///
13587/// A chain suppresses the errexit check (a non-zero status is "consumed"
13588/// by the connector — `false && x` must not fire ERREXIT or the ZERR
13589/// trap). But an errflag — a *fatal* error such as a `[[ ]]` bad pattern
13590/// — is not a status the connector can consume: zsh abandons the whole
13591/// list. Without this, zshrs ran the `||` right-hand side after the
13592/// error (`[[ x = [a- ]] || touch f` created `f`; zsh does not) and the
13593/// aborted builtin then overwrote the cond's status 2 with 1.
13594///
13595/// Same errflag arm as `BUILTIN_ERREXIT_CHECK`, with the errexit/ZERR
13596/// half omitted.
13597pub const BUILTIN_FATAL_ABORT_CHECK: u16 = 641;
13598/// Post-`always`-arm checks for the canonical RETFLAG / BREAKS /
13599/// CONTFLAG atomics that mark try-block escapes. Each returns
13600/// Value::Int(1) when the corresponding atomic is set (and consumes
13601/// it so the next escape doesn't re-fire) and Value::Int(0) otherwise.
13602/// Paired with JumpIfFalse + Jump to outer return_patches /
13603/// break_patches / continue_patches by compile_zsh's `Try` arm.
13604pub const BUILTIN_RETFLAG_CHECK: u16 = 600;
13605/// `BUILTIN_BREAKS_CHECK` constant.
13606pub const BUILTIN_BREAKS_CHECK: u16 = 601;
13607/// `BUILTIN_CONTFLAG_CHECK` constant.
13608pub const BUILTIN_CONTFLAG_CHECK: u16 = 602;
13609/// `loops++` on entry to a compiled for/while/until/repeat
13610/// (c:Src/loop.c:114/427/523).
13611pub const BUILTIN_LOOP_ENTER: u16 = 656;
13612/// `loops--` on exit from a compiled for/while/until/repeat
13613/// (c:Src/loop.c:188/491/546).
13614pub const BUILTIN_LOOP_EXIT: u16 = 657;
13615/// Post-body `if (breaks) { breaks--; … }` drain (c:Src/loop.c:529-534).
13616/// Int(1) = terminate this loop, Int(0) = next iteration.
13617pub const BUILTIN_LOOP_BREAK_DRAIN: u16 = 658;
13618/// Non-consuming `breaks != 0` probe for execlist's per-statement gate
13619/// (c:Src/exec.c:1370).
13620pub const BUILTIN_BREAKS_PENDING: u16 = 659;
13621/// `shtokenize` the top-of-stack string in place — c:Src/subst.c:4419-4420
13622/// `if (globsubst) shtokenize(y)`, the step that makes a `${~spec}` /
13623/// `$~spec` value's metachars PATTERN-ACTIVE.
13624///
13625/// zshrs expands a `[[ ]]` operand at the VM level and hands `cond_str`
13626/// (c:Src/cond.c:525) a finished string, so the token state C carries in
13627/// the word itself has to be re-applied at the point of use. Without it a
13628/// module condition compiles the value as a literal: `[[ -prefix $~pat ]]`
13629/// (Completion/Base/Utility/_numbers sh:65) is the one shipped completer
13630/// that depends on it.
13631pub const BUILTIN_COND_SHTOKENIZE: u16 = 660;
13632/// Fire the DEBUG trap (SIGDEBUG) before each statement.
13633/// c:Src/exec.c:1357-1500 DEBUGBEFORECMD — when a "DEBUG" entry is
13634/// installed via `trap '...' DEBUG`, run the body just before the
13635/// next command. Cheap when no DEBUG trap is set (one hashmap lookup
13636/// returns None and we early-out).
13637pub const BUILTIN_DEBUG_TRAP: u16 = 603;
13638/// `set -n` / `set -o noexec` — parse but don't execute. Returns
13639/// Value::Int(1) when the noexec option is set so the caller's
13640/// JumpIfTrue skips the statement body. c:Src/exec.c:1390 main loop
13641/// check.
13642pub const BUILTIN_NOEXEC_CHECK: u16 = 604;
13643/// Block-level redirect-failure gate. Reads exec.redirect_failed
13644/// (set by host.redirect when a redirect open fails); returns
13645/// Value::Int(1) AND clears the flag if set, else 0. Emit-side at
13646/// compile_zsh.rs::compile_command's Redirected arm pairs with a
13647/// JumpIfTrue → WithRedirectsEnd to abandon the body. Without this,
13648/// a multi-statement block after a failed redir kept running every
13649/// statement after the first (the first builtin consumed the flag,
13650/// subsequent statements ran unimpeded).
13651pub const BUILTIN_REDIRECT_FAILED_CHECK: u16 = 605;
13652/// Drop-in replacement for fusevm's Op::Exec for the dynamic-first-
13653/// word path (`$cmd`, `$(cmd)`, `~/bin/foo`). Returns
13654/// Value::Status(vm.last_status) when post-expansion argv is empty
13655/// (preserves the inner cmd-subst's exit), Value::Status(126) with
13656/// "permission denied" when `argv[0]` is empty, otherwise routes
13657/// through executor.host_exec_external like Op::Exec did.
13658pub const BUILTIN_EXEC_DYNAMIC: u16 = 606;
13659/// Reset `use_cmdoutval` to 0 at the START of a dynamic command (before
13660/// its words expand), so a command substitution from a PREVIOUS command
13661/// can't leak into this command's null-command status decision
13662/// (c:Src/exec.c:3009 `use_cmdoutval = !args`). See BUILTIN_EXEC_DYNAMIC.
13663pub const BUILTIN_USE_CMDOUTVAL_RESET: u16 = 637;
13664/// Tilde-expand a match pattern's leading `~`, the way `singsub` does
13665/// before the pattern reaches `patcompile`.
13666///
13667/// c:Src/cond.c:299-307 — `right = dupstring(ecrawstr(…)); singsub(&right);
13668/// … patcompile(right, …)`. `singsub` is `prefork(PREFORK_SINGLE)`
13669/// (c:Src/subst.c:520), and prefork runs `filesub` on every word, so an
13670/// unquoted `~` in a `[[ … == ~/* ]]` pattern — or one that arrives via
13671/// `${~var}` — is a home directory, not a literal character. `case`
13672/// patterns take the same route (c:Src/loop.c:610 `singsub(&pat)`).
13673/// zshrs untokenizes the pattern at compile time and re-tokenizes it in
13674/// the matcher, so the expansion has to happen here.
13675///
13676/// Only a LEADING `~` is considered, which is all `filesubstr`
13677/// (c:Src/subst.c:741) ever expands: a `~` elsewhere is EXTENDED_GLOB's
13678/// "except" operator and must survive untouched, and a quoted one has
13679/// already been backslash-escaped by `escape_quoted_glob_metas` so it
13680/// fails the leading-char test.
13681///
13682/// powerlevel10k's directory segment is the visible consumer: its
13683/// `_POWERLEVEL9K_DIR_CLASSES` walk matches `$PWD` against `~` and `~/*`
13684/// via `[[ $_p9k__cwd == ${~a} ]]` (internal/p10k.zsh:2029). Without the
13685/// expansion both classes missed, every path under `$HOME` fell through
13686/// to the `*` DEFAULT class, and the prompt showed the generic folder
13687/// icon in place of the home / home-subfolder one.
13688fn pattern_filesub(pattern: &str) -> String {
13689    let first = pattern.chars().next();
13690    if first != Some('~') && first != Some(crate::ported::zsh_h::Tilde) {
13691        return pattern.to_string();
13692    }
13693    // filesubstr keys on the Tilde TOKEN, so shtokenize first (a raw `~`
13694    // becomes Tilde; an already-tokenized one passes through), then
13695    // untokenize the surviving glob metas back for the matcher.
13696    let mut tok = pattern.to_string();
13697    crate::ported::glob::shtokenize(&mut tok);
13698    crate::ported::lex::untokenize(&crate::ported::subst::filesub(&tok, 0))
13699}
13700
13701/// `[[ lhs == pat ]]` / `!=` glob compare — cond-specific so the
13702/// bad-pattern diagnostic follows Src/cond.c:308-316: zwarnnam
13703/// "bad pattern: %s" WITHOUT errflag (the script continues) and the
13704/// cond statement exits 2. Stack: [lhs, pat] → Bool. On compile
13705/// failure pushes Bool(false) and arms COND_BAD_PATTERN so
13706/// BUILTIN_COND_STATUS_FROM_BOOL reports 2.
13707pub const BUILTIN_COND_STRMATCH: u16 = 624;
13708/// Pops the cond result Bool → Int status per Src/cond.c: true→0,
13709/// false→1, but 2 when COND_BAD_PATTERN was armed during this cond
13710/// (covers `!=` where LogNot flips the Bool before status time).
13711pub const BUILTIN_COND_STATUS_FROM_BOOL: u16 = 625;
13712/// `[[ ]]` unknown condition. Pops \[op_name\], emits `zerr("unknown
13713/// condition: %s")` and sets ERRFLAG_ERROR so the next BUILTIN_ERREXIT_CHECK
13714/// (trigger 4) aborts the input — matching zsh's COND_MODI "unknown condition"
13715/// path (Src/cond.c:150-188) for a `-X` op with no matching loadable module.
13716/// Returns Bool(false). Replaces a compile-time `eprintln!` hack that printed
13717/// the message but never set errflag (so the line didn't abort).
13718pub const BUILTIN_COND_UNKNOWN: u16 = 632;
13719/// Bare-`exec` redirect epilogue. Consumes `exec.redirect_failed` and
13720/// applies the C `done:` tail of execcmd_exec:
13721///   - c:Src/exec.c:252-259 execerr — `redir_err = lastval = 1` (the
13722///     failed redirect makes the exec statement exit 1, NOT fatal by
13723///     itself);
13724///   - c:Src/exec.c:4367-4386 — `if (isset(POSIXBUILTINS) && (cflags
13725///     & (BINF_PSPECIAL|BINF_EXEC)) ...) { if (redir_err || errflag)
13726///     { if (!isset(INTERACTIVE)) exit(1); } }` — POSIX_BUILTINS makes
13727///     a failed exec redirect fatal in a non-interactive shell.
13728/// Returns Value::Status(0|1) for the trailing SetStatus.
13729pub const BUILTIN_EXEC_REDIR_DONE: u16 = 626;
13730/// Assignment-prefix epilogue for bare `exec` redirects
13731/// (`x=$(cmd) exec >file`). c:Src/exec.c:3969-3976 — nullexec==1
13732/// runs addvars THEN, without POSIX_BUILTINS, restores the params
13733/// (`save_params` / `restore_params`): the RHS side effects fire but
13734/// the values don't persist. With POSIX_BUILTINS the assignments
13735/// stick. Pops the BEGIN_INLINE_ENV frame either way.
13736pub const BUILTIN_EXEC_INLINE_ENV_DONE: u16 = 627;
13737
13738/// `< file` / `> file` with no command word (NULLCMD path).
13739/// Resolves NULLCMD (default "cat") / READNULLCMD (default "more")
13740/// at runtime per Src/exec.c:3386-3419, then dispatches that word
13741/// exactly as execcmd's fall-through does: shell function (c:3485)
13742/// → builtin (c:3489) → external. Argc is 1: the int (0 or 1) on the
13743/// stack indicates whether this is a single REDIR_READ redirect
13744/// (selects READNULLCMD when set + non-empty).
13745pub const BUILTIN_NULLCMD_EXEC: u16 = 607;
13746/// `.` (dot) — alias of source/bin_dot but dispatches with the
13747/// literal name "." so the diagnostic prefix matches zsh's
13748/// (`zsh:.:1: …` vs source's `zsh:source:1: …`).
13749/// c:Src/builtin.c:9308 — `BUILTIN(".", BINF_PSPECIAL, bin_dot, …)`.
13750pub const BUILTIN_DOT: u16 = 608;
13751/// `logout` — fusevm maps this to BUILTIN_EXIT alongside `exit`/`bye`,
13752/// which drops the name and dispatches with BIN_EXIT funcid. zsh's
13753/// `logout` outside a login shell must emit "not login shell" + exit 1,
13754/// which only fires when bin_break sees BIN_LOGOUT funcid. Dedicated
13755/// opcode dispatches via BUILTINS table by literal name "logout".
13756pub const BUILTIN_LOGOUT: u16 = 610;
13757/// `BUILTIN_PARAM_SUBSTRING_EXPR` constant.
13758pub const BUILTIN_PARAM_SUBSTRING_EXPR: u16 = 337;
13759/// `BUILTIN_XTRACE_LINE` constant.
13760pub const BUILTIN_XTRACE_LINE: u16 = 338;
13761/// `BUILTIN_XTRACE_ARRAY_LINE` — xtrace an `arr=(...)` assignment from the
13762/// whole assembled `Value::Array` (see compile_zsh array-literal codegen).
13763pub const BUILTIN_XTRACE_ARRAY_LINE: u16 = 649;
13764/// `BUILTIN_MAKE_ARRAY_COUNTED` — like `Op::MakeArray(u16)` but the element
13765/// count is a runtime `Int` on the stack, so it is not capped at 65535. Used
13766/// by the array-literal codegen only when the literal has > u16::MAX elements
13767/// (e.g. a .zcompdump's ~103k-element `_comps=(...)`).
13768pub const BUILTIN_MAKE_ARRAY_COUNTED: u16 = 650;
13769/// `BUILTIN_ARGV_RFLATTEN` — pop one `Op::MakeArray`-packed argv bundle and
13770/// push it back as ONE recursively-flattened `Value::Array` of scalars. Emitted
13771/// by the simple-command codegen ONLY on the >255-arg overflow path: the
13772/// `Call`/`CallFunction`/`CallBuiltin` opcodes carry argc as a u8, so a command
13773/// with more than 255 args is packed into a single Array (dispatched with
13774/// argc=1) instead. But those call ops flatten their argv only ONE level, which
13775/// would stringify a nested Array (a brace/glob/`$arr` word contributes a
13776/// `Value::Array`). Pre-flattening here — same descent as
13777/// [`flatten_array_value`], the array-assignment path — makes the bundle flat
13778/// so the call op's single-level splat restores every positional arg. Bit
13779/// compsys: a completer's `_arguments <specs…>` with a large brace-form option
13780/// set (curl ships 59 `{-x,--long}` specs) dropped the long forms.
13781pub const BUILTIN_ARGV_RFLATTEN: u16 = 653;
13782/// `BUILTIN_ARRAY_JOIN_STAR` constant.
13783pub const BUILTIN_ARRAY_JOIN_STAR: u16 = 339;
13784/// `BUILTIN_SET_RAW_OPT` constant.
13785pub const BUILTIN_SET_RAW_OPT: u16 = 340;
13786
13787/// `time { compound; ... }` — wall-clock-time the sub-chunk and print
13788/// elapsed seconds. Stack: [sub_chunk_idx as Int]. Runs the sub-chunk
13789/// on the current VM (so positional/local state is shared) and prints
13790/// the timing summary to stderr in zsh's format. Pushes Status.
13791pub const BUILTIN_TIME_SUBLIST: u16 = 316;
13792
13793/// `{name}>file` / `{name}<file` / `{name}>>file` — named-fd allocation.
13794/// Stack: [path, varid, op_byte]. Opens `path` per `op_byte`, gets the
13795/// new fd (≥10 in zsh; we use libc::open with O_CLOEXEC bit cleared so
13796/// the inherited fd survives Command::new spawns), stores the fd number
13797/// as a string in `$varid`. Pushes Status (0 success, 1 error).
13798pub const BUILTIN_OPEN_NAMED_FD: u16 = 317;
13799
13800/// Word-segment concat that does cartesian-product distribution over
13801/// arrays. Stack: [lhs, rhs]. Used for RC_EXPAND_PARAM `${arr}` and
13802/// explicit-distribute forms (`${^arr}`, `${(@)…}`).
13803///
13804/// - both scalar: `Value::str(a + b)` (fast path, identical to Op::Concat)
13805/// - lhs Array, rhs scalar: `Value::Array([a + rhs for a in lhs])`
13806/// - lhs scalar, rhs Array: `Value::Array([lhs + b for b in rhs])`
13807/// - both Array: cartesian product `[a + b for a in lhs for b in rhs]`
13808pub const BUILTIN_CONCAT_DISTRIBUTE: u16 = 318;
13809
13810/// Forced-distribute concat — like `BUILTIN_CONCAT_DISTRIBUTE` but
13811/// always distributes cartesian regardless of the `rcexpandparam`
13812/// option. Emitted by the segments fast-path when an
13813/// `is_distribute_expansion` segment is present (`${^arr}`,
13814/// `${(@)arr}`, `${(s.…)arr}` etc.) per zsh: the source-level
13815/// distribution flag overrides the option default.
13816/// Direct port of Src/subst.c:1875 `case Hat: nojoin = 1` and the
13817/// `rcexpandparam` test bypass for the explicit-distribute flags.
13818pub const BUILTIN_CONCAT_DISTRIBUTE_FORCED: u16 = 522;
13819
13820/// Capture current `last_status` into the `TRY_BLOCK_ERROR` variable.
13821/// Emitted between the try block and the always block of `{ … } always
13822/// { … }` so the finally arm can read $TRY_BLOCK_ERROR.
13823pub const BUILTIN_SET_TRY_BLOCK_ERROR: u16 = 320;
13824/// `BUILTIN_RESTORE_TRY_BLOCK_STATUS` constant.
13825pub const BUILTIN_RESTORE_TRY_BLOCK_STATUS: u16 = 432;
13826/// `BUILTIN_BEGIN_INLINE_ENV` constant.
13827pub const BUILTIN_BEGIN_INLINE_ENV: u16 = 433;
13828/// `BUILTIN_END_INLINE_ENV` constant.
13829pub const BUILTIN_END_INLINE_ENV: u16 = 434;
13830/// Closes the current inline-env frame's save list. Emitted right
13831/// after the prefix assignments of `X=foo cmd` have committed and
13832/// before `cmd` dispatches, so assignments performed BY the command
13833/// are not recorded into (and therefore not reverted with) the frame.
13834/// c:Src/exec.c:4410 `save_params` snapshots only the parsed
13835/// WC_ASSIGN chain; the list is closed before the builtin/shell
13836/// function runs. Without the seal, `X=y . file` reverted every
13837/// global the sourced file assigned — which emptied git's
13838/// `git-completion.bash` option tables (`__git_log_common_options`
13839/// et al.) that `_git` sources via `GIT_SOURCING_ZSH_COMPLETION=y . …`.
13840pub const BUILTIN_SEAL_INLINE_ENV: u16 = 654;
13841
13842/// End-of-sublist `waitonejob` for a sublist that ran wholly in the
13843/// current shell. Emitted by `compile_zsh::compile_sublist` after each
13844/// element of the `&&`/`||` chain whose parse-time `cmplx` flag is set
13845/// (see `compile_zsh::sublist_elem_is_cmplx`) and whose top level is
13846/// NOT a multi-stage pipeline.
13847///
13848/// c:Src/exec.c:1489-1492 — `execlist` routes each sublist element on
13849/// the parse-time flag: `if (WC_SUBLIST_FLAGS(code) & WC_SUBLIST_SIMPLE)
13850/// execsimple(state); else execpline(state, code, ltype, ...);`. Only
13851/// the `execpline` arm builds a job, and `execpline` ends by calling
13852/// `waitonejob` on it.
13853///
13854/// c:Src/jobs.c:1748-1757 — `waitonejob(Job jn)`:
13855/// ```c
13856/// if (jn->procs || jn->auxprocs) zwaitjob(jn - jobtab, 0);
13857/// else { deletejob(jn, 0); pipestats[0] = lastval; numpipestats = 1; }
13858/// ```
13859/// A sublist that forked (a real multi-stage pipeline) takes the
13860/// `zwaitjob` arm, whose `storepipestats` (c:Src/jobs.c:420) publishes
13861/// the per-stage array — in zshrs that is `BUILTIN_RUN_PIPELINE`'s own
13862/// `set_array("pipestatus", ...)`. Every other cmplx sublist runs with
13863/// an empty proc list and takes the `else` arm, which is what this
13864/// builtin performs. Resolving which arm applies is a compile-time
13865/// decision in C (the parse-time flag) and is a compile-time decision
13866/// here too, so no marker is emitted for the pipeline case at all.
13867///
13868/// This is what makes a compound command publish `$pipestatus`:
13869/// `if ...; fi`, `for ...; done`, `case ... esac`, `while ...; done`,
13870/// `{ ... }`, `( ... )` and a bare command all reach `execpline` when
13871/// their body is cmplx, so zsh leaves `numpipestats == 1`. It also
13872/// makes the OUTER sublist win over an inner pipeline's array —
13873/// `if true; then true|false; fi` is `n=1 p=(1)`, not `(0 1)` — because
13874/// the outer procs-less job overwrites what the inner job stored.
13875///
13876/// Fires BEFORE the `!` negation (`emit_negate_status`): C applies
13877/// `WC_SUBLIST_NOT` inside `execpline` after the wait, so
13878/// `! [[ -z x ]]` records the PRE-negation status — `p=(1)` with `$?`
13879/// of 0.
13880pub const BUILTIN_SUBLIST_FINISH: u16 = 655;
13881
13882/// `[[ -o option ]]` — shell-option-set test. Stack: \[option_name\].
13883/// Normalizes the name (strip underscores, lowercase) and reads
13884/// `exec.options`. Pushes Bool.
13885pub const BUILTIN_OPTION_SET: u16 = 321;
13886/// Tri-state `[[ -o NAME ]]` — same lookup as BUILTIN_OPTION_SET
13887/// but returns a Value::Int (0=set, 1=unset, 3=invalid-name). The
13888/// 3-state code matches zsh's `[[ -o invalid ]]` exit (Src/cond.c
13889/// :502 `optison()`). Used by compile_cond's `-o` arm to skip the
13890/// generic bool→status conversion and preserve the invalid-name
13891/// signal in `$?`.
13892pub const BUILTIN_OPTION_CHECK_TRISTATE: u16 = 609;
13893
13894/// `${var:#pattern}` — array filter: remove elements matching `pattern`.
13895/// Stack: [name, pattern]. For scalar `var`, returns empty if it matches
13896/// the pattern, else the value. For array `var`, returns Array of
13897/// non-matching elements.
13898pub const BUILTIN_PARAM_FILTER: u16 = 322;
13899
13900/// `a[i]=(elements)` / `a[i,j]=(elements)` / `a[i]=()` —
13901/// subscripted-array assign with array-literal RHS. Stack:
13902/// [...elements, name, key]. Empty elements + single-int key `a[i]=()`
13903/// removes that element. Comma-key `a[i,j]=(...)` splices.
13904pub const BUILTIN_SET_SUBSCRIPT_RANGE: u16 = 323;
13905
13906/// `[[ -X file ]]` for unknown unary test op `-X`. Stack: \[op_name\].
13907/// Emits zsh's `unknown condition: -X` diagnostic to stderr and
13908/// pushes Bool(false). Without this, unknown conditions silently
13909/// returned false matching neither zsh's error format nor the
13910/// expected status code (zsh returns 2 for parse error).
13911
13912/// `[[ -t fd ]]` — fd-is-a-tty check. Stack: \[fd_string\].
13913/// Routes through libc::isatty. Pushes Bool.
13914///
13915/// ID 644 (unique, next free above the previous max of 643). This was
13916/// 325, which COLLIDED with BUILTIN_FILE_OLDER (also 325). The VM's
13917/// builtin table is last-registration-wins, and FILE_OLDER registered
13918/// after IS_TTY, so every `[[ -t fd ]]` silently dispatched to the
13919/// file-`-ot` handler: it compared the mtime of a file NAMED by the fd
13920/// string ("0", "1", …) — which never exists — so `[[ -t 0 ]]` was
13921/// always false. That broke interactive detection (`[[ -t 0 && -t 1 ]]`)
13922/// and any config gated on it. c:Src/cond.c:390 `return !isatty(...)`.
13923pub const BUILTIN_IS_TTY: u16 = 644;
13924/// Runtime rejection of a process substitution used inside a `[[ … ]]`
13925/// cond operand. c:Src/exec.c:4918/5040/5069 — `getoutputfile`/`getproc`
13926/// error `"process substitution %s cannot be used here"` when `thisjob ==
13927/// -1`, which is the case during cond evaluation. zshrs's THISJOB never
13928/// distinguishes that context at runtime, so the compiler emits this
13929/// builtin (gated on `in_cond_operand`) instead of the ProcessSubIn/Out
13930/// opcode. Pops the substitution text, zerrs, sets errflag (aborting the
13931/// statement → empty stdout, exit 1, matching zsh), returns empty.
13932pub const BUILTIN_PROCSUB_COND_ERROR: u16 = 645;
13933/// `${^arr}` cross-product concat — RC_EXPAND_PARAM forced ON by the `^` flag.
13934///
13935/// Distinct from BUILTIN_CONCAT_DISTRIBUTE_FORCED, which the other distribute
13936/// shapes (`${(@)a}`, `${(f)v}`, `${a[@]}`) share: those keep the word when the
13937/// array is EMPTY (`x${(@)a}y` → `xy`), but plan9 DELETES it
13938/// (c:Src/subst.c:4362-4365 `if (plan9) { uremnode(l, n); return n; }`), so
13939/// `x${^a}y` with `a=()` produces no word at all. One builtin cannot serve both
13940/// — the plan9-ness is known only at compile time, from the `^` flag itself.
13941/// Routes to `concat_plan9`, which already ports both c:4362's removal and the
13942/// c:4316-4350 cartesian emit, and is what the OPTION path
13943/// (`setopt rcexpandparam`) has always used.
13944pub const BUILTIN_CONCAT_PLAN9: u16 = 646;
13945/// `${^^arr}` concat — RC_EXPAND_PARAM forced OFF by the doubled flag
13946/// (c:Src/subst.c:2553-2555 `plan9 = 0`).
13947///
13948/// The mirror of BUILTIN_CONCAT_PLAN9. Needed because every other concat
13949/// builtin consults `plan9_active()` (the runtime OPTION) and so cross-products
13950/// anyway under `setopt rcexpandparam`, while `^^` must override the option:
13951///     setopt rcexpandparam; a=(a b c); print -rl -- ${^^a}.x
13952///     # zsh: `a`, `b`, `c.x`  — spliced, NOT `a.x b.x c.x`
13953/// The override is computed in paramsubst but the distribution call is made
13954/// here, so — like the `^` flag — the only place that knows is the compiler.
13955/// Routes straight to `concat_splice`, C's non-plan9 join-first-and-last path
13956/// (c:4366-4437).
13957pub const BUILTIN_CONCAT_SPLICE_NOPLAN9: u16 = 647;
13958/// Atomic word assembler for a DQ word that MIXES a plan9 (`^`) segment with a
13959/// non-plan9 (splice/scalar) segment — e.g. `"${(@)^a}${(@)b}"`.
13960///
13961/// The per-pair concat fold (CONCAT_PLAN9 / CONCAT_SPLICE picked ONCE for the
13962/// whole word) cannot express a word where segment A distributes but segment B
13963/// splices: a single operator does full-cross OR first/last-splice, never both,
13964/// and it loses track of which trailing elements are still the "growing edge".
13965/// zsh (Src/subst.c:4316-4437) instead threads a growing edge through the whole
13966/// word — an element is "active" until a splice freezes all but the last.
13967///
13968/// This builtin ports that edge-tracking directly. Stack (bottom→top):
13969///   descriptor, seg0, seg1, …, seg(n-1)     with argc = n + 1
13970/// where `descriptor` is an n-char string, one char per segment: `'1'` = plan9
13971/// (`^`), `'0'` = splice/scalar/literal. Each segment value is an Array (splat)
13972/// or scalar (1 element). Result is the assembled Array (or scalar / deleted).
13973pub const BUILTIN_WORD_ASSEMBLE_PLAN9: u16 = 652;
13974/// `break N`/`continue N` runtime-count validator (see registration).
13975pub const BUILTIN_BREAK_COUNT_VALIDATE: u16 = 648;
13976/// `[[ -r/-w/-x file ]]` via access(2) (doaccess) — see handler.
13977pub const BUILTIN_COND_ACCESS: u16 = 638;
13978
13979/// Evaluate a `[[ ]]` module/completion condition (`-prefix`/`-suffix`/
13980/// `-after`/`-between`). Stack (top-first): argc operand words, then the
13981/// operator word. Dispatches to `complete::eval_mod_cond`. Result pushed as
13982/// Bool (true = condition matched). Used by the `ZshCond::ModCond` compile arm.
13983pub const BUILTIN_COND_MOD: u16 = 651;
13984
13985/// `provenance` — report the lineage of a tracked parameter: where its
13986/// bytes entered the shell (command substitution, glob, heredoc, an
13987/// earlier assignment) and every bytecode-level op that touched them
13988/// since. Handler: `ShellExecutor::builtin_provenance`; engine:
13989/// `src/extensions/provenance.rs`. ID 661 is the first free slot above
13990/// the 653-660 block.
13991pub const BUILTIN_PROVENANCE: u16 = 661;
13992
13993/// PRINT_EXIT_VALUE report for one finished simple command. Direct port
13994/// of c:Src/exec.c:4308-4316 (`execcmd_exec`'s tail):
13995/// ```c
13996///     if (isset(PRINTEXITVALUE) && isset(SHINSTDIN) && lastval && !subsh)
13997///         fprintf(stderr, "zsh: exit %lld\n", lastval);
13998/// ```
13999/// The ported `execcmd_exec` carries the same code (exec.rs), but fusevm —
14000/// not that walker — is what actually runs a command, so the report never
14001/// fired. `compile_simple` emits this call right after the dispatch's
14002/// `Op::SetStatus` (both the builtin and the function/external arm), which
14003/// is exactly where the C line sits. Pushes Status(0), which the emit side
14004/// pops; `vm.last_status` is left untouched.
14005pub const BUILTIN_PRINT_EXIT_VALUE: u16 = 662;
14006
14007/// Update `$LINENO` to track the source line of the next statement.
14008/// Stack: \[n\] (the line number from `ZshPipe.lineno`). Direct port
14009/// of zsh's `lineno` global tracking (Src/input.c:330) — the
14010/// compiler emits one of these per top-level pipe so `$LINENO`
14011/// reflects the source position at runtime. ID 342 picked because
14012/// the previous `326` collided with `BUILTIN_HAS_STICKY` (the 325
14013/// collision between IS_TTY and FILE_OLDER has since been fixed by
14014/// moving IS_TTY to 644).
14015pub const BUILTIN_SET_LINENO: u16 = 342;
14016
14017/// Pop a scalar from the VM stack, run expand_glob on it, push the
14018/// result as Value::Array. Used by the segment-concat compile path
14019/// when var refs concatenate with glob meta literals (`$D/*`,
14020/// `${prefix}*`, etc.) — those skip the bridge's pathname-expansion
14021/// pass and would otherwise leak the glob meta to argv as a literal.
14022pub const BUILTIN_GLOB_EXPAND: u16 = 343;
14023
14024/// MULTIOS-gated glob expansion for redirect-target words
14025/// (c:Src/glob.c:2161-2167 xpandredir: "Globbing is only done for
14026/// multios."). Same stack shape as BUILTIN_GLOB_EXPAND; additionally
14027/// passes the word through literally when `unsetopt multios`.
14028// 624 is BUILTIN_COND_STRMATCH — the VM's builtin table is
14029// last-registration-wins, so a duplicate id silently shadows the
14030// earlier handler.
14031pub const BUILTIN_REDIR_GLOB_EXPAND: u16 = 628;
14032
14033/// Reset the default-word glob-pending carrier at the START of a word
14034/// whose source contains a glob metachar (so the flag never leaks from a
14035/// prior word/statement). Paired with BUILTIN_DEFAULT_WORD_GLOB.
14036pub const BUILTIN_DEFAULT_WORD_GLOB_RESET: u16 = 635;
14037
14038/// Filename-generate the ASSEMBLED word ONLY when the default/alternate
14039/// paramsubst arm took a SOURCE word carrying glob metachars
14040/// (subst::DEFAULT_WORD_GLOB_PENDING). A parameter VALUE never sets the
14041/// flag, so `x='*file'; ${x:-d}` stays literal while `${x:-*file}` /
14042/// `${x:-a*}bar` glob. Reads+clears the flag. c:Src/subst.c → globlist.
14043pub const BUILTIN_DEFAULT_WORD_GLOB: u16 = 636;
14044/// `BUILTIN_SET_LOOP_VAR` constant — for-loop variable binding via
14045/// `setloopvar` (Src/params.c:6362): a PM_NAMEREF loop var REBINDS
14046/// to each word (SETREFNAME + setscope) instead of assigning
14047/// through the chain. Returns Bool(false) when zerr fired
14048/// (read-only reference / invalid self reference) so the loop
14049/// driver aborts, mirroring C execfor's errflag check.
14050pub const BUILTIN_SET_LOOP_VAR: u16 = 629;
14051
14052/// EXTEND step of typeset paren-init packing. Pops `argc` values:
14053/// [base, e1, …, eN] — base is either the opener (`name=(` /
14054/// `name+=(`) or a previous EXTEND result. Pushes base with
14055/// `\u{1f}` + element appended per element. Array values SPLICE
14056/// their items as separate elements (`typeset b=( x $arr )` splat);
14057/// an empty Array contributes nothing (unquoted-empty elision).
14058/// CallBuiltin's argc is u8, so the compiler emits one EXTEND per
14059/// ≤200-element chunk — p10k's 408-element `__p9k_colors=( … )`
14060/// overflowed a single-shot pack (argc wrapped mod 256 and the
14061/// stack spilled into the arg list: "not an identifier: 173…").
14062/// BUILTIN_TYPESET_PAREN_CLOSE appends the final `\u{1f})`,
14063/// yielding the exact REJOIN_SEP-delimited one-arg form
14064/// bin_typeset's single-arg splitter consumes (builtin.rs ~4891,
14065/// empties preserved, leading/trailing sentinel-empties trimmed
14066/// once). One arg in → one arg out: bin_typeset's multi-arg rejoin
14067/// (paren-depth scan, unsafe on EXPANDED paren-literal elements
14068/// like p10k's `')' ''`) never runs.
14069pub const BUILTIN_TYPESET_PAREN_PACK: u16 = 630;
14070
14071/// CLOSE step — pops the EXTEND chain's result, pushes it with
14072/// `\u{1f})` appended. See BUILTIN_TYPESET_PAREN_PACK.
14073pub const BUILTIN_TYPESET_PAREN_CLOSE: u16 = 631;
14074
14075/// Shared body of BUILTIN_GLOB_EXPAND / BUILTIN_REDIR_GLOB_EXPAND.
14076/// c:Src/glob.c:1872 — `zglob` runs per-word in the argv pipeline.
14077/// When the upstream EXPAND_TEXT returned an array (e.g. `${a:e}`
14078/// splat → ["txt","md"]), glob each element separately, not a
14079/// sepjoin'd scalar. `skip_glob` short-circuits to a literal
14080/// pass-through (noglob, or a redirect target under
14081/// `unsetopt multios`).
14082fn glob_expand_word_value(raw: Value, skip_glob: bool) -> Value {
14083    let patterns: Vec<String> = match raw {
14084        Value::Array(items) => items.iter().map(|v| v.to_str()).collect(),
14085        other => vec![other.to_str()],
14086    };
14087    if skip_glob {
14088        return if patterns.is_empty() {
14089            Value::array(Vec::new())
14090        } else if patterns.len() == 1 {
14091            Value::str(patterns.into_iter().next().unwrap())
14092        } else {
14093            Value::array(patterns.into_iter().map(Value::str).collect())
14094        };
14095    }
14096    let mut out: Vec<String> = Vec::with_capacity(patterns.len());
14097    for pattern in &patterns {
14098        // c:Src/subst.c — filename generation runs `filesub` (tilde/`=`
14099        // expansion) BEFORE globbing. A `~`/`=` reaching this word-glob op
14100        // comes from `${~spec}` / GLOB_SUBST marking a substituted VALUE:
14101        // literal and quoted `~` words are filesub'd (or skip glob) upstream
14102        // and never arrive here. filesubstr matches the Tilde TOKEN, so
14103        // shtokenize first (raw `~`->Tilde; already-Tilde `${~a[@]}` results
14104        // pass through), run filesub, then untokenize surviving glob metas
14105        // for expand_glob. Gated on `~`/`=` (raw or token) so ordinary
14106        // substituted words skip the roundtrip. Fixes `${~x}` x="~/foo".
14107        let filesubbed = if pattern.contains('~')
14108            || pattern.contains('=')
14109            || pattern.contains(crate::ported::zsh_h::Tilde)
14110            || pattern.contains(crate::ported::zsh_h::Equals)
14111        {
14112            let mut tok = pattern.clone();
14113            crate::ported::glob::shtokenize(&mut tok);
14114            crate::ported::lex::untokenize(&crate::ported::subst::filesub(&tok, 0))
14115        } else {
14116            pattern.clone()
14117        };
14118        let matches = with_executor(|exec| exec.expand_glob(&filesubbed));
14119        if matches.is_empty() {
14120            // c:1872 nullglob — drop this word, don't emit a hole
14121            continue;
14122        }
14123        for m in matches {
14124            out.push(m);
14125        }
14126    }
14127    if out.is_empty() {
14128        return Value::array(Vec::new());
14129    }
14130    if patterns.len() == 1 && out.len() == 1 && out[0] == patterns[0] {
14131        // No real matches; expand_glob returned the literal. Pass
14132        // back as scalar so downstream ops don't re-flatten.
14133        return Value::str(out.into_iter().next().unwrap());
14134    }
14135    Value::array(out.into_iter().map(Value::str).collect())
14136}
14137
14138/// Push a `CmdState` token onto the command-context stack. Direct
14139/// port of zsh's `cmdpush(int cmdtok)` (Src/prompt.c:1623). The
14140/// stack is consulted by `%_` in PS4/prompt expansion to produce
14141/// the cumulative control-flow-context labels (`if`, `then`,
14142/// `cmdand`, `cmdor`, `cmdsubst`, …) that `zsh -x` xtrace shows
14143/// in the trace prefix. Compile_zsh emits push/pop pairs around
14144/// each compound command (if/while/[[…]]/((…))/$(…) etc.).
14145/// Token is a `CmdState as u8`.
14146pub const BUILTIN_CMD_PUSH: u16 = 344;
14147
14148/// Pop the top of the command-context stack. Direct port of zsh's
14149/// `cmdpop(void)` (Src/prompt.c:1631).
14150pub const BUILTIN_CMD_POP: u16 = 345;
14151
14152/// Emit an xtrace line built from the top `argc` values on the VM
14153/// stack, peeked WITHOUT consuming. Used to trace simple commands
14154/// AFTER expansion, so `echo for $i` shows as `echo for a` / `echo
14155/// for b`. Direct port of Src/exec.c:2055-2066.
14156pub const BUILTIN_XTRACE_ARGS: u16 = 346;
14157
14158/// Trace one assignment: emits `name=<quoted-value> ` (no newline)
14159/// to xtrerr if XTRACE is on. Coalesces with subsequent
14160/// XTRACE_ASSIGN / XTRACE_ARGS calls onto the SAME line via the
14161/// `XTRACE_DONE_PS4` flag so `a=1 b=2 echo $a $b` produces:
14162///   `<PS4>a=1 b=2 echo 1 2\n`
14163/// matching C zsh's `execcmd_exec` body (Src/exec.c:2517-2582):
14164///   xtr = isset(XTRACE);
14165///   if (xtr) { printprompt4(); doneps4 = 1; }
14166///   while (assign) {
14167///       if (xtr) fprintf(xtrerr, "%s=", name);
14168///       ... eval value ...
14169///       if (xtr) { quotedzputs(val, xtrerr); fputc(' ', xtrerr); }
14170///   }
14171///
14172/// Stack contract on entry: [..., name, value]. Both peeked, NOT
14173/// consumed (the matching SET_VAR call pops them after). argc = 2.
14174pub const BUILTIN_XTRACE_ASSIGN: u16 = 525;
14175
14176/// Emit a trailing `\n` + flush iff XTRACE is on AND PS4 was
14177/// emitted by an earlier XTRACE_ASSIGN this line. Used at the end
14178/// of compile_simple's assignment-only path so the trace line gets
14179/// terminated. Mirrors C's exec.c:3397-3399 (the assign-only return
14180/// path through execcmd_exec which does `fputc('\n', xtrerr);
14181/// fflush(xtrerr)`).
14182///
14183/// Stack: untouched. argc = 0.
14184pub const BUILTIN_XTRACE_NEWLINE: u16 = 526;
14185
14186/// Push the live `xtrace` opt-state as `Value::Int(1)` (on) or
14187/// `Value::Int(0)` (off). Used by `compile_cond` to gate the
14188/// trace-string-building block on xtrace state at runtime — without
14189/// this the trace path's `compile_word_str` on each operand re-
14190/// evaluates side-effectful expressions (`$((i++))`) once for the
14191/// trace string and once for the actual condition, doubling the
14192/// effective increment. Bug #159 in docs/BUGS.md.
14193///
14194/// Stack: pushes Int(0|1). argc = 0.
14195pub const BUILTIN_XTRACE_IS_ON: u16 = 611;
14196
14197/// Reset the `DONETRAP` flag at the start of each top-level statement
14198/// (sublist boundary). Mirrors C `Src/exec.c:1455` — `donetrap = 0`.
14199/// Stack: untouched. argc = 0. Bug #303 in docs/BUGS.md.
14200pub const BUILTIN_DONETRAP_RESET: u16 = 612;
14201
14202/// c:Src/exec.c:1417 (`int oldnoerrexit = noerrexit;`) + c:1536-1538
14203/// (`if (isandor || isnot) noerrexit |= NOERREXIT_EXIT|NOERREXIT_RETURN;`).
14204/// Saves the current `noerrexit` on a per-thread stack and ORs in the two
14205/// suppression bits for the duration of one `&&`/`||` chain operand (or a
14206/// `!`-negated command). Stack: untouched. argc = 0.
14207pub const BUILTIN_NOERREXIT_SUPPRESS: u16 = 665;
14208
14209/// c:Src/exec.c:1621 / c:1626 — `noerrexit = oldnoerrexit;`. Pops the
14210/// matching save pushed by [`BUILTIN_NOERREXIT_SUPPRESS`].
14211/// Stack: untouched. argc = 0.
14212pub const BUILTIN_NOERREXIT_RESTORE: u16 = 666;
14213
14214/// c:Src/loop.c:144 + :201 (execfor), :480 (execwhile), :536 (execrepeat) —
14215/// `lastval = 1;` on the `errflag` exit from a loop body. Emitted only on the
14216/// fatal-abort path of a compiled for/while/until/repeat; `execselect` has no
14217/// such assignment in C and never emits it.
14218/// Stack: pushes Int(0). argc = 0.
14219pub const BUILTIN_LOOP_ERRFLAG_STATUS: u16 = 667;
14220
14221thread_local! {
14222    /// c:Src/exec.c:1417 — C keeps `oldnoerrexit` as an execlist-local
14223    /// automatic, so the save/restore pairs nest with the C call stack.
14224    /// zshrs's compiler emits the two halves as separate ops, so the saved
14225    /// values need an explicit stack. Thread-local because `noerrexit`
14226    /// itself is per-shell state and worker threads run their own lists.
14227    static NOERREXIT_SAVES: std::cell::RefCell<Vec<i32>> =
14228        const { std::cell::RefCell::new(Vec::new()) };
14229}
14230
14231/// `[[ -z X ]]` / `[[ -n X ]]` operand-empty test that honours zsh's
14232/// array-splice semantics. C zsh evaluates `[[ -z X ]]` per
14233/// `Src/cond.c:347` (case 'z'): `s` is the SCALAR operand passed
14234/// through `cond_str`'s singsub. For `"${arr[@]}"` zsh expands per
14235/// `Src/subst.c:multsub` which yields each element as its own word
14236/// list node; cond.c then sees the joined-or-single-element form.
14237///
14238/// The compile-side `-z` shortcut at `compile_zsh.rs:5371` used
14239/// `Op::StringLen` which calls `Value::len` — for `Value::Array`
14240/// that returns ARRAY LENGTH, not string length. `b=("")` produced
14241/// `Value::Array([""])` → `len = 1` → `-z` returned false.
14242///
14243/// This builtin pops one `Value` and pushes `1` (empty) or `0`
14244/// (non-empty) per the cond context:
14245///   - `Value::Str(s)` → s.is_empty()
14246///   - `Value::Array([])` → true (zero words → vacuous-empty)
14247///   - `Value::Array([s])` → s.is_empty() (single-word case)
14248///   - `Value::Array([_; n>=2])` → false (multiple non-empty
14249///     words; zsh would raise "unknown condition" but the
14250///     observable test result is non-empty/false)
14251///
14252/// Companion to BUILTIN_COND_STR_NONEMPTY (#185 in docs/BUGS.md).
14253pub const BUILTIN_COND_STR_EMPTY: u16 = 613;
14254
14255/// `[[ -n X ]]` operand-non-empty test (logical complement of
14256/// BUILTIN_COND_STR_EMPTY).
14257pub const BUILTIN_COND_STR_NONEMPTY: u16 = 614;
14258
14259/// `N<<<"str"` / `N<<HERE` — here-string or here-document redirect to
14260/// an explicit fd. Pops `[content, fd, from_heredoc]` from the stack;
14261/// creates a temp file, writes the body, reopens read-only, dup2's to
14262/// `fd`, unlinks the temp path so it disappears on close. Mirrors C
14263/// `Src/exec.c:4655 getherestr` + `addfd(forked, save, mfds, fn->fd1,
14264/// fil, 0, NULL)` at c:3766-3780. Bug #205 in docs/BUGS.md.
14265///
14266/// `from_heredoc` is C's `REDIRF_FROM_HEREDOC` (set at
14267/// c:Src/parse.c:2970-2971): 0 appends the trailing newline of
14268/// c:4671-4672, 1 passes the body through byte-for-byte.
14269///
14270/// The fd itself is parked in the enclosing redirect scope via
14271/// `save_fd_for_scope` unless a bare `exec` is being applied
14272/// (c:3978-3986 nullexec==1), so `cmd N<<E` is undone at the end of
14273/// the command while `exec N<<E` persists.
14274///
14275/// Stack: pushes `Value::Status(0)` on success, `Status(1)` on
14276/// failure. argc = 3.
14277pub const BUILTIN_EXEC_HERESTR_FD: u16 = 615;
14278
14279/// MULTIOS write/append fan-out for `cmd > a > b` / `cmd > a >> b`
14280/// style redirects (Bug #36 in docs/BUGS.md). zsh's MULTIOS option
14281/// (Src/exec.c:2418 `mfds[fd1]` check + addfd splice) creates a
14282/// pipe at fd1, spawns an internal "tee" process that copies
14283/// stdin → every collected target file. Without this, only the
14284/// LAST redirect target survives because each dup2 overwrites the
14285/// previous binding.
14286///
14287/// Stack layout (pushed by compile_zsh's compile_redirs coalescing
14288/// pass): `[target_1, op_byte_1, target_2, op_byte_2, …, target_N,
14289/// op_byte_N, fd]`. Pops 2N+1 elements; `argc = 2*N + 1`. A target
14290/// may be a Value::Array of glob matches (spliced into one member
14291/// per match, c:Src/glob.c:2195-2203); an op may be DUP_WRITE for a
14292/// numeric `>&N` member (c:Src/exec.c:3895-3917).
14293///
14294/// Runtime (MULTIOS set):
14295///   1. Seed the member list with `dup(1)` when this command's
14296///      stdout is the pipeline output (c:Src/exec.c:3722-3724).
14297///   2. Open/dup all targets per their op_byte in redirect order
14298///      (WRITE truncate + noclobber gate / APPEND / DUP_WRITE live
14299///      dup); the first member replaces the fd (c:2448-2450).
14300///   3. Save `dup(fd)` onto the active redirect_scope_stack so
14301///      `host_redirect_scope_end` restores the original fd.
14302///   4. Create a pipe; spawn a thread that reads from the pipe
14303///      read-end and writes every chunk to every opened target.
14304///   5. dup2 the pipe write-end onto `fd` so the command's writes
14305///      go through the splitter.
14306///   6. Track `(pipe_write_fd, JoinHandle)` so scope-end can close
14307///      the pipe (draining the thread) and join before restoring.
14308///
14309/// MULTIOS unset (c:2418 `unset(MULTIOS)` replace arm): each entry
14310/// is applied as a plain sequential replace via host_apply_redirect
14311/// — every file still opened/truncated, last one wins.
14312pub const BUILTIN_MULTIOS_REDIRECT: u16 = 617;
14313
14314/// MULTIOS input-side concatenation for `cmd < a < b` shapes
14315/// (Bug #36 input arm). C zsh's `Src/exec.c:2418` mfds dispatch
14316/// also covers the read direction — when multiple `<` redirects
14317/// target the same fd, mfds[fd] grows and addfd splices a
14318/// concatenating cat into the pipe.
14319///
14320/// Stack layout (mirrors the write side): `[source_1, op_1,
14321/// source_2, op_2, …, source_N, op_N, fd]`. Pops 2N + 1 elements
14322/// (argc = 2N + 1). op is READ for file sources, DUP_READ for
14323/// numeric `<&N` members; a source may be a Value::Array of glob
14324/// matches (spliced, c:Src/glob.c:2195-2203).
14325///
14326/// Runtime (MULTIOS set):
14327///   1. Open/dup every source in redirect order; first member
14328///      replaces the fd (c:Src/exec.c:2448-2450).
14329///   2. Save `dup(fd)` onto the redirect_scope_stack.
14330///   3. Create a pipe; spawn a thread that reads each source in
14331///      order and writes every chunk to the pipe write-end. Close
14332///      write-end when done so the consumer sees EOF.
14333///   4. dup2 the pipe read-end onto `fd`.
14334///   5. Track the JoinHandle so scope-end joins (no fd-close needed
14335///      here — the producer thread closes its own pipe write-end
14336///      on exit).
14337///
14338/// MULTIOS unset: sequential replace via host_apply_redirect — last
14339/// source wins (c:2418).
14340pub const BUILTIN_MULTIOS_READ: u16 = 618;
14341
14342/// Toggle `ShellExecutor::exec_redirs_permanent`. Emitted by
14343/// compile_zsh's bare-`exec`-with-redirects arm tightly around each
14344/// `Op::Redirect`: `LoadInt(1); CallBuiltin; …Redirect…; LoadInt(0);
14345/// CallBuiltin`. While set, `host_apply_redirect` skips pushing the
14346/// saved fd into the enclosing redirect scope, making the fd change
14347/// permanent.
14348///
14349/// c:Src/exec.c:3978-3986 — nullexec==1 (`exec` carrying only
14350/// redirections): "If nullexec is 1 we specifically *don't* restore
14351/// the original fd's before returning" — the per-execcmd `save[]`
14352/// dups are closed unrestored. An ENCLOSING group's own saves are a
14353/// different execcmd's `save[]` and still restore (verified:
14354/// `{ exec 2>/dev/null; } 2>&1; ls /nope` prints the ls error in zsh).
14355pub const BUILTIN_EXEC_PERM_REDIRS: u16 = 619;
14356
14357/// Set `ShellExecutor::pipe_output_pending`. Emitted by compile_pipe
14358/// at the head of a NON-LAST pipeline-stage sub-chunk when that
14359/// stage's top-level command carries redirects (`Simple` with redirs
14360/// or `Redirected` compound). The forked stage child runs the chunk
14361/// with stdout already dup2'd onto the pipe; the first
14362/// `host_redirect_scope_begin` (the stage command's own redirect
14363/// list) consumes the flag into `pipe_output_scope`, enabling the
14364/// MULTIOS stream-split for fd-1 write redirects in that list.
14365///
14366/// c:Src/exec.c:3722-3724 — `addfd(forked, save, mfds, 1, output, 1,
14367/// NULL)` registers the pipe in mfds[1] in the SAME execcmd that
14368/// walks the stage command's redirect list; mfds is per-execcmd, so
14369/// nested body commands (`{ echo a > f; } | cat`) never see it.
14370pub const BUILTIN_PIPE_OUTPUT_MARK: u16 = 620;
14371
14372/// Install the pipeline stage's parked fds onto 0/1.
14373///
14374/// c:Src/exec.c:3720-3724 — `addfd(forked, save, mfds, 0, input, 0,
14375/// NULL)` / `addfd(..., 1, output, 1, NULL)`. Runs after prefork
14376/// (c:3304) and globlist (c:3702) have expanded the stage's argument
14377/// words, which is why a `$(...)` in those words reads the shell's
14378/// original stdin rather than the pipe. Emitted by
14379/// `compile_zsh.rs::emit_stage_fds_install`; the fds themselves are
14380/// parked by `BUILTIN_RUN_PIPELINE`.
14381pub const BUILTIN_PIPE_FDS_INSTALL: u16 = 642;
14382
14383/// Magic-equals prefork for a single arg word of a
14384/// `BINF_MAGICEQUALS` builtin head (`alias`). Direct port of
14385/// c:Src/exec.c:3298-3304 — `esprefork = PREFORK_TYPESET;
14386/// prefork(args, esprefork, NULL)` runs on the argv BEFORE the addfd
14387/// redirect loop at c:3720, so an expansion zerr (`alias bad===` →
14388/// equalsubstr "= not found" at Src/subst.c:726) prints to the
14389/// command's UN-redirected stderr. argc=1: pops the just-pushed
14390/// word value, runs shtokenize → prefork(PREFORK_TYPESET) →
14391/// untokenize on it (each element for Array splices), pushes the
14392/// result back. Emitted by compile_simple per arg word when the
14393/// dispatch head is `alias`; BUILTIN_ALIAS itself no longer
14394/// preforks (it would double-fire the diagnostic).
14395pub const BUILTIN_MAGIC_EQUALS_PREFORK: u16 = 621;
14396
14397/// Bare (unbraced) `$name[idx]` subscript — same dispatch as
14398/// `BUILTIN_ARRAY_INDEX` while KSHARRAYS is unset, but under
14399/// KSHARRAYS the unbraced form does NOT subscript (c:Src/subst.c:
14400/// 2800-2802 + 2867): `$name` expands bare and `[idx]` stays literal
14401/// trailing text that undergoes filename generation. Operands:
14402/// [name, idx, suffix, quoted].
14403pub const BUILTIN_ARRAY_INDEX_UNBRACED: u16 = 622;
14404
14405/// Assignment-only simple-command exit status. Direct port of
14406/// `lv = (errflag ? errflag : cmdoutval)` (c:Src/exec.c:1322,
14407/// execsimple's WC_ASSIGN arm) / `if (errflag) lastval = 1; else
14408/// lastval = cmdoutval;` (c:Src/exec.c:3393-3396, execcmd_exec's
14409/// no-command-word varspc path; redir variant at c:3977). Pops
14410/// [had_cmd_subst]; cmdoutval is the live vm.last_status when a
14411/// `$()` ran in any RHS of the chain, 0 otherwise. Writes the
14412/// canonical LASTVAL (C's single `lastval` global) so the
14413/// non-interactive errflag abort exits with this value per
14414/// Src/init.c:234. Caller pairs with SetStatus.
14415pub const BUILTIN_ASSIGN_ONLY_STATUS: u16 = 623;
14416
14417/// c:Src/exec.c addvars — `if (!pm) { lastval = 1; if (!cmdoutval)
14418/// cmdoutval = 1; }`. Set by BUILTIN_SET_VAR on assignsparam
14419/// failure, consumed by BUILTIN_ASSIGN_ONLY_STATUS so the
14420/// assignment-only command reports status 1. Process-global like
14421/// C's `cmdoutval` (function bodies may run on a different thread
14422/// than the opcode that reads the status back).
14423pub static ASSIGN_FAILED_FLAG: std::sync::atomic::AtomicBool =
14424    std::sync::atomic::AtomicBool::new(false);
14425
14426/// `redirection with no command` parse-time error for bare
14427/// `builtin 2>&1` / `command < file` / `exec >&-` precmd-keyword
14428/// shapes with a redirect but no following command. Direct port
14429/// of `Src/exec.c:3342 zerr("redirection with no command")`.
14430/// argc=0; pushes Value::Status(1).
14431pub const BUILTIN_REDIR_NO_CMD: u16 = 616;
14432
14433/// GLOB_SUBST guard for `[[ x == $pat ]]` pattern RHS coming from
14434/// parameter / command substitution. C-zsh's `[[ == ]]` semantics
14435/// (Src/options.c GLOB_SUBST default OFF + Src/cond.c:552
14436/// `cond_match` + Src/pattern.c patcompile tokenization-based
14437/// meta detection) treat chars from substitution as LITERAL
14438/// unless GLOB_SUBST is on. The Rust patcompile accepts both
14439/// tokenized and raw-ASCII meta chars, losing the distinction,
14440/// so `pat="h*"; [[ hello == $pat ]]` matched in zshrs but not
14441/// in zsh. Bug #116 in docs/BUGS.md.
14442///
14443/// Compile-time signal: emitted by `compile_cond_expr` ONLY when
14444/// the RHS contains `$` or backtick. Runtime checks the live
14445/// option state. If GLOB_SUBST is OFF, the popped string has
14446/// its glob metachars escaped with `\` so the downstream StrMatch
14447/// → patcompile treats them as literals. If GLOB_SUBST is ON,
14448/// the value passes through unchanged so `setopt glob_subst`
14449/// restores zsh's pattern-on-expansion behavior.
14450///
14451/// Stack: pops one string, pushes the (possibly escaped) result.
14452/// argc = 1.
14453pub const BUILTIN_GLOB_SUBST_GUARD: u16 = 528;
14454
14455/// `${~spec}` / `$~spec` pattern-data guard — the `strcatsub`
14456/// `if (glbsub) shtokenize(dest)` step (c:Src/subst.c:822/830) applied
14457/// to a value that is about to become a `[[ … == pat ]]` RHS or a
14458/// `case` arm pattern.
14459///
14460/// `BUILTIN_GLOB_SUBST_GUARD` covers the option-driven leg (GLOB_SUBST
14461/// off → escape the value's metas). It cannot cover the FLAG leg,
14462/// because `${~spec}` forces the substitution's metachars ACTIVE and
14463/// the compiler therefore emits no guard at all — the value reached
14464/// `patcompile` as raw bytes, which made a DATA backslash
14465/// indistinguishable from a SOURCE-level quote. c:Src/glob.c:3651
14466/// leaves a backslash before a non-`ztokens` char in the string as
14467/// ordinary data (`p='a\ b'; [[ 'a b' == ${~p} ]]` must NOT match,
14468/// `[[ 'a\ b' == ${~p} ]]` must), so the value has to be respelled in
14469/// the normalizer's literal-backslash form before it is compiled.
14470/// docs/BUGS.md #1090, globsubst leg.
14471///
14472/// Stack: pops one string, pushes the respelled result. argc = 1.
14473pub const BUILTIN_PAT_DATA_BACKSLASH: u16 = 668;
14474/// Stage an ALREADY-EXPANDED here-document body as pending stdin,
14475/// verbatim — no trailing newline appended.
14476///
14477/// c:Src/exec.c:4671-4672 — `getherestr` appends the newline only when
14478/// the here-string did NOT come from a here-document:
14479///     if (!(fn->flags & REDIRF_FROM_HEREDOC))
14480///         t[len++] = '\n';
14481/// The quoted form already had a non-appending sink (`Op::HereDoc`),
14482/// but the UNQUOTED form has to run `BUILTIN_EXPAND_TEXT` first, and
14483/// the only stack-consuming sink available afterwards was
14484/// `Op::HereString` — which appends unconditionally, because `<<<`
14485/// genuinely must. The lowering compensated with
14486/// `trim_end_matches('\n')`, and strip-all-then-append-one is lossy in
14487/// both directions: it ADDED a newline to a body that ended without one
14488/// (`cat <<EOF` + `hello` with no final newline printed `hello\n` where
14489/// zsh prints `hello`), and COLLAPSED N trailing newlines to one
14490/// (`hello\n\n\n` printed as `hello\n`). argc = 1.
14491pub const BUILTIN_HEREDOC_BODY_SINK: u16 = 669;
14492
14493
14494/// Coerce a string parameter value to a math number (Int or Float)
14495/// for arithmetic-context reads, mirroring C-zsh's `getmathparam`
14496/// (Src/math.c:337). When the variable holds a string like "hello"
14497/// that isn't numeric, C falls back to recursively evaluating the
14498/// raw string as an arith expression; if that fails too, returns 0.
14499///
14500/// Used by the ArithCompiler pre-load path so `(( y = x ))` with
14501/// `x="hello"` reads `x` as integer 0, then assigns y as integer 0
14502/// — matching zsh's behaviour. The previous Rust port used
14503/// BUILTIN_GET_VAR which returned the raw string "hello"; the
14504/// ArithCompiler stored it verbatim in y's slot, and the post-sync
14505/// BUILTIN_SET_VAR wrote y="hello" as scalar instead of y=0 as
14506/// integer. Bug #118 in docs/BUGS.md.
14507///
14508/// Stack: pops `name` (string), pushes coerced numeric Value.
14509/// argc = 1.
14510pub const BUILTIN_GET_MATH_VAR: u16 = 529;
14511
14512/// GLOB_SUBST runtime gate for words containing parameter / command
14513/// substitution. C-zsh's `prefork` (Src/subst.c) runs `shtokenize`
14514/// on the substituted value when `GLOB_SUBST` is set, making the
14515/// substituted chars eligible for filename generation. With the
14516/// option off, substituted chars stay literal.
14517///
14518/// The Rust port's compile_zsh emits `compile_word_str` for words
14519/// like `/tmp/X/$pat`, which returns the post-expansion string but
14520/// never runs glob expansion (no path here triggers
14521/// BUILTIN_GLOB_EXPAND). Bug #119 in docs/BUGS.md: with `setopt
14522/// glob_subst`, `for f in /tmp/X/$pat` (pat="*.txt") never matched
14523/// `*.txt` files.
14524///
14525/// This opcode wraps the substitution result and dispatches at
14526/// runtime: when GLOB_SUBST is OFF, return unchanged; when ON,
14527/// pass the value through `expand_glob` so glob metas become
14528/// active. Emitted by `compile_for_words` (and similar sites)
14529/// after WORD_SPLIT for words with unquoted expansion.
14530///
14531/// Stack: pops a Value (Str or Array of Str), pushes the glob-
14532/// expanded result (still Str or Array depending on input shape).
14533/// argc = 1.
14534pub const BUILTIN_GLOB_SUBST_EXPAND: u16 = 530;
14535/// `BUILTIN_ASSOC_HAS_KEY` constant — `${(k)assoc[name]}` key-existence
14536/// query. Returns the key text on hit, empty string on miss. Bug #145.
14537pub const BUILTIN_ASSOC_HAS_KEY: u16 = 531;
14538/// `BUILTIN_ARRAY_DROP_EMPTY` constant — filter empty elements from
14539/// an Array on the stack. Used by `for x in $@` / `for x in $*`
14540/// unquoted forms. Bug #166.
14541pub const BUILTIN_ARRAY_DROP_EMPTY: u16 = 532;
14542/// `BUILTIN_QUOTED_STAR_ONE_WORD` — normalize the result of a QUOTED
14543/// `"$*"` / `"${*}"` expansion to EXACTLY ONE word.
14544///
14545/// c:Src/subst.c:3032 — the quoted (`qt`) branch of paramsubst ends in
14546/// `val = sepjoin(aval, sep, 1)`, a plain string join. Joining the
14547/// EMPTY positional list yields `""`, so `"$*"` with no positionals is
14548/// one empty word, exactly like `"$empty"` — which is why
14549/// `set --; printf '%d|%s|%d\n' $# "$*" 7` prints `0||7` in zsh, bash,
14550/// dash and ksh alike.
14551///
14552/// zshrs routes `"$*"` through `BUILTIN_EXPAND_TEXT` mode 1, whose
14553/// `multsub` returns a ZERO-node list for the empty case (correct for
14554/// `"$@"`, which really does vanish) and the bridge turns that into
14555/// `Value::Array(vec![])` — so the word was elided and printf saw one
14556/// argument fewer. This op restores the join's single-word shape at the
14557/// one call site that knows the splat was `*` and not `@`.
14558///
14559/// Stack: pops the expansion result, pushes `Value::str("")` when it
14560/// was an empty Array, and the value unchanged otherwise. argc = 1.
14561pub const BUILTIN_QUOTED_STAR_ONE_WORD: u16 = 663;
14562/// `BUILTIN_KSH_FUNSUB` — zsh NOFORK command substitution
14563/// (`Src/subst.c:1904-2100`), which also covers the ksh93 funsub
14564/// `${ list; }` and mksh valsub `${| list; }`: a command substitution that
14565/// runs in the CURRENT shell environment rather than a subshell.
14566///
14567/// Three forms, selected by the character right after `${`
14568/// (c:Src/subst.c:1924/1930):
14569///   * blank → `${ cmd }`, value is the body's STDOUT. c:2026-2029 scopes
14570///     it under `.zsh.cmdsubst`; c:2035-2044 captures via a temp-file
14571///     redirect so the body still runs in the current shell.
14572///   * `|` → `${| cmd }`, value is `$REPLY`, which is LOCAL to the body
14573///     (c:2018-2024 `createparam("REPLY", PM_LOCAL|PM_UNSET|PM_HIDE)`).
14574///   * `{VAR}` → `${{VAR} cmd }`, value is `$VAR`, NOT localised, and an
14575///     array stays an array (c:2082-2083 re-enters the parameter path).
14576///
14577/// ksh(1), Command Substitution: "${ command;} … the command is executed
14578/// in the current shell environment", and the value is the standard output
14579/// with trailing newlines removed (zsh strips ONE newline unquoted and
14580/// none quoted — c:1908 `trim = (!EMULATION(EMULATE_ZSH)) ? 2 : !qt`).
14581/// mksh(1)'s valsub matches zsh's `${| … }` exactly.
14582///
14583/// Stack (bottom→top): body, rplyvar, kind, qt — kind 0 = stdout capture,
14584/// 1 = REPLY form, 2 = named-variable form; qt = 1 when the word was
14585/// double-quoted. argc = 4. Pushes the resulting string, or an Array when
14586/// the named variable holds one / when the unquoted result is word-split.
14587pub const BUILTIN_KSH_FUNSUB: u16 = 664;
14588/// `BUILTIN_QUOTEDZPUTS` constant — run top-of-stack value through
14589/// `crate::ported::utils::quotedzputs` and push the quoted result.
14590/// Used by the cond xtrace path so non-printable bytes (e.g.
14591/// `$'\C-[OP'` expanded ESC+OP) get re-wrapped in `$'…'` form for
14592/// the trace prefix line, matching zsh's `Src/exec.c` cond trace
14593/// which calls `quotedzputs(operand, xtrerr)` on each side. Bug
14594/// surfaced when `[[ -n $'\C-[OP' ]]` traced as `[[ -n OP ]]`
14595/// (raw bytes leaked through the terminal) vs zsh's
14596/// `[[ -n $'\C-[OP' ]]` source-form preservation.
14597pub const BUILTIN_QUOTEDZPUTS: u16 = 533;
14598/// `BUILTIN_QUOTE_TOKENIZED_OUTPUT` — port of
14599/// `crate::ported::exec::quote_tokenized_output` (Src/exec.c:2114)
14600/// applied to top-of-stack scalar. Used by cond xtrace for the RHS
14601/// of pattern-context comparisons (`=` / `==` / `!=`) where C zsh
14602/// emits the SOURCE form: untokenize lexer tokens (Star → `*`,
14603/// Inpar → `(`, …) and backslash-escape special chars, but
14604/// preserve literal ASCII unchanged. Distinct from quotedzputs
14605/// which wraps the whole string in `'…'` / `$'…'` based on
14606/// non-printability — that's wrong for `[[ x = a* ]]` which must
14607/// render as `[[ x = a* ]]`, not `'a*'`.
14608pub const BUILTIN_QUOTE_TOKENIZED_OUTPUT: u16 = 534;
14609
14610/// Bridge into subst_port::substitute_brace_array for nested forms
14611/// that need to PRESERVE array shape across the expand_string
14612/// boundary. Stack: `[content_string]`. Returns Value::Array of the
14613/// per-element words. Used by the compile path for
14614/// `${(@)<nested>...##pat}` shapes — the standard substitute_brace
14615/// returns String which collapses array→scalar; this builtin
14616/// preserves the multi-word output via paramsubst's third return
14617/// (`nodes` vec, the C source's `aval` thread).
14618pub const BUILTIN_BRIDGE_BRACE_ARRAY: u16 = 347;
14619
14620/// Word-segment concat with FIRST/LAST sticking. Stack: [lhs, rhs].
14621/// Used for default unquoted splice forms (`${arr[@]}`, `$@`, `$*`)
14622/// where prefix sticks to first element only and suffix to last only.
14623///
14624/// Distribution table:
14625/// - both scalar: `Value::str(a + b)` (fast path)
14626/// - lhs scalar, rhs Array(b₀..bₙ): `Value::Array([lhs+b₀, b₁, …, bₙ])`
14627/// - lhs Array(a₀..aₙ), rhs scalar: `Value::Array([a₀, …, aₙ₋₁, aₙ+rhs])`
14628/// - both Array: `Value::Array([a₀, …, aₙ₋₁, aₙ+b₀, b₁, …, bₙ])`
14629///   (last of lhs merges with first of rhs; the rest stay separate)
14630///
14631/// This is the default zsh semantics for `print -l X${arr[@]}Y` →
14632/// "Xa", "b", "cY" — three distinct args, surrounding text only on ends.
14633pub const BUILTIN_CONCAT_SPLICE: u16 = 319;
14634
14635/// `${(flags)name}` — zsh parameter expansion flags. Stack: [name, flags].
14636/// Flags applied left-to-right. Supported subset (high-value, used by zpwr):
14637///
14638///   `L` — lowercase the value (scalar; or each element if array)
14639///   `U` — uppercase
14640///   `j:sep:` — join array with `sep` (delim is the char after `j`)
14641///   `s:sep:` — split scalar on `sep` (returns Value::Array)
14642///   `f` — split on newlines (shorthand for `s.\n.`)
14643///   `o` — sort array ascending
14644///   `O` — sort array descending
14645///   `P` — indirect: read name's value as another var name, return that's value
14646///   `@` — keep as array (returns Value::Array — useful before `j` etc.)
14647///   `k` — keys of assoc array
14648///   `v` — values of assoc array
14649///   `#` — word count (array length as scalar)
14650///
14651/// Flags can stack: `(jL)` joins then lowercases; `(s.,.U)` splits on `,`
14652/// then uppercases each element. The long-tail flags (`q`, `qq`, `qqq` for
14653/// quoting, `A` for assoc, `%` for prompt expansion, `e`/`g` for re-eval,
14654/// `n`/`p` for numeric, `t` for type, etc.) are deferred — they hit the
14655/// runtime fallback via the catch-all expansion path.
14656pub const BUILTIN_PARAM_FLAG: u16 = 297;
14657
14658/// `ShellHost` implementation that delegates to the current `ShellExecutor`
14659/// via the `with_executor` thread-local.
14660///
14661/// Construct fresh on each VM run (it carries no state itself). The VM
14662/// dispatches host method calls during `vm.run()`, and `with_executor`
14663/// resolves to the executor pointer set by `ExecutorContext::enter`.
14664/// fusevm-host implementation tying bytecode ops to the
14665/// shell executor.
14666/// zshrs-original — no C counterpart. C zsh has no bytecode VM
14667/// to host; everything runs through `execlist()`/`execpline()`
14668/// directly (Src/exec.c lines 1349/1668).
14669pub struct ZshrsHost;
14670
14671/// Short label for a sub-chunk, used as a provenance origin. A `Chunk`
14672/// keeps no original source text (only ops, constants and a source
14673/// *file* name), so the readable handle is reconstructed from the
14674/// leading string constants — for `$(date +%s)` that is `date +%s`.
14675fn prov_chunk_label(sub: &fusevm::Chunk) -> String {
14676    sub.constants
14677        .iter()
14678        .filter_map(|c| match c {
14679            Value::Str(s) if !s.is_empty() => Some(s.as_str()),
14680            _ => None,
14681        })
14682        .take(3)
14683        .collect::<Vec<_>>()
14684        .join(" ")
14685}
14686
14687impl fusevm::ShellHost for ZshrsHost {
14688    fn glob(&mut self, pattern: &str, _recursive: bool) -> Vec<String> {
14689        let matches = with_executor(|exec| exec.expand_glob(pattern));
14690        if crate::provenance::active() {
14691            crate::provenance::on_glob(pattern, &matches);
14692        }
14693        matches
14694    }
14695
14696    fn tilde_expand(&mut self, s: &str) -> String {
14697        with_executor(|exec| s.to_string())
14698    }
14699
14700    fn brace_expand(&mut self, s: &str) -> Vec<String> {
14701        // Direct call to the canonical brace expander
14702        // (Src/glob.c::xpandbraces port at glob.rs:1678). Was
14703        // routing through singsub which uses PREFORK_SINGLE — that
14704        // flag explicitly suppresses brace expansion in subst.c:166,
14705        // so `print X{1,2,3}Y` returned the literal string.
14706        //
14707        // brace_ccl: respect the BRACE_CCL option which the bracket-
14708        // class form `{a-z}` requires. Pull from executor options.
14709        let brace_ccl = with_executor(|exec| opt_state_get("braceccl").unwrap_or(false));
14710        crate::ported::glob::xpandbraces(s, brace_ccl)
14711    }
14712
14713    fn str_match(&mut self, s: &str, pattern: &str) -> bool {
14714        let pattern: &str = &pattern_filesub(pattern);
14715        // bash `shopt -s nocasematch` — bash(1): "bash matches patterns in a
14716        // case-insensitive fashion when performing matching while executing
14717        // CASE or [[ conditional commands". This host method is the `case`
14718        // arm dispatch, the half BUILTIN_COND_STRMATCH (which already does
14719        // this, ~4.4k lines up) does not cover, so `shopt -s nocasematch;
14720        // case ABC in abc)` wrongly took the `*)` arm. Same treatment: fold
14721        // BOTH sides for the match decision — glob metacharacters are not
14722        // letters, so `*` / `?` / `[…]` structure survives `to_lowercase`.
14723        // No-op unless the bash shopt is active; --zsh unaffected.
14724        let (folded_s, folded_pat);
14725        let (s, pattern): (&str, &str) = if crate::dash_mode::nocasematch() {
14726            folded_s = s.to_lowercase();
14727            folded_pat = pattern.to_lowercase();
14728            (&folded_s, &folded_pat)
14729        } else {
14730            (s, pattern)
14731        };
14732        // Shell glob match — `*`, `?`, `[...]`, alternation. After the
14733        // cond path moved to BUILTIN_COND_STRMATCH, the consumer here
14734        // is the `case` arm dispatch, whose bad-pattern semantics are
14735        // Src/loop.c:663-667: `if (!(pprog = patcompile(pat, ...)))
14736        // zerr("bad pattern: %s", pat);` — errflag set, the arm
14737        // doesn't match, and the script aborts at the next command
14738        // boundary (matching `zsh -fc 'case x in [a-) ...'` printing
14739        // the diagnostic with exit 0 = untouched lastval).
14740        let mut pat_tok = pattern.to_string();
14741        crate::ported::glob::tokenize(&mut pat_tok);
14742        if crate::ported::pattern::patcompile(
14743            &pat_tok,
14744            crate::ported::zsh_h::PAT_STATIC as i32,
14745            None,
14746        )
14747        .is_none()
14748        {
14749            crate::ported::utils::zerr(&format!("bad pattern: {}", pattern)); // c:667
14750            return false;
14751        }
14752        glob_match_static(s, pattern)
14753    }
14754
14755    fn expand_param(&mut self, name: &str, _modifier: u8, _args: &[Value]) -> Value {
14756        // Sole funnel: route through `getsparam` matching C zsh's
14757        // `getsparam(name)` → `getvalue` → `getstrvalue` →
14758        // `Param.gsu->getfn` dispatch (Src/params.c:3076 / 2335).
14759        //
14760        // The lookup chain (GSU dispatch + variables + env + array-
14761        // join) lives in `params::getsparam`; subst.rs and this
14762        // bridge both call into it so the logic is in exactly one
14763        // place — mirroring C's "every read goes through getsparam"
14764        // architecture. fuseVM bytecode triggers this bridge when
14765        // the VM hits a PARAM opcode, equivalent to C's wordcode VM
14766        // resolving a parameter read during `exec.c` execution.
14767        //
14768        // Modifier handling: the `_modifier` / `_args` parameters
14769        // are populated by the bytecode compiler but applied by
14770        // separate VM opcodes (LENGTH/STRIP/SUBST/etc.) downstream
14771        // of this fetch — matching C's split between getsparam
14772        // (value fetch) and paramsubst's modifier-walk loop. This
14773        // bridge is the value-fetch step only.
14774        let val_str = crate::ported::params::getsparam(name).unwrap_or_default();
14775        let value = Value::str(val_str);
14776        // Provenance: a read of a tracked parameter hands the
14777        // parameter's chain to the produced value, by `Arc` identity
14778        // for the rest of this chunk and by content for the host
14779        // boundaries downstream that only see `String`.
14780        if crate::provenance::active() {
14781            crate::provenance::on_param_read(name, &value);
14782        }
14783        value
14784    }
14785
14786    fn process_sub_in(&mut self, sub: &fusevm::Chunk) -> String {
14787        // c:Src/exec.c:4906 getoutputfile — `=(cmd)` (marked "equalsubst" by the
14788        // compiler) is the TEMP-FILE flavor: create a real regular file, fork a
14789        // writer whose stdout is the file, WAIT for it (so the file is complete
14790        // and seekable before the consumer runs), and return the file path. It
14791        // is unlinked at job end. This differs from `<(cmd)` below, which is a
14792        // /dev/fd pipe that is never waited on.
14793        if sub.source == "equalsubst" {
14794            let nam = crate::ported::utils::gettempname(None, true)
14795                .unwrap_or_else(|| format!("/tmp/zshrs_eqsub_{}", std::process::id()));
14796            let cpath = match std::ffi::CString::new(nam.as_str()) {
14797                Ok(c) => c,
14798                Err(_) => return String::from("/dev/null"),
14799            };
14800            // c:4945 — O_WRONLY|O_CREAT|O_EXCL|O_NOCTTY, 0600.
14801            let fd = unsafe {
14802                libc::open(
14803                    cpath.as_ptr(),
14804                    libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_NOCTTY,
14805                    0o600,
14806                )
14807            };
14808            if fd < 0 {
14809                return String::from("/dev/null");
14810            }
14811            let sub_for_child = sub.clone();
14812            match unsafe { libc::fork() } {
14813                -1 => {
14814                    unsafe { libc::close(fd) };
14815                    let _ = fs::remove_file(&nam);
14816                    return String::from("/dev/null");
14817                }
14818                0 => {
14819                    // c:4985 — child: stdout → the temp file, run the body, exit.
14820                    // Clear the inherited pending-file list so this child never
14821                    // unlinks the PARENT's =() temp files when its own commands
14822                    // dispatch (fork copies the list; unlink hits the shared fs).
14823                    PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
14824                    unsafe {
14825                        libc::dup2(fd, libc::STDOUT_FILENO);
14826                        libc::close(fd);
14827                    }
14828                    let mut vm = fusevm::VM::new(sub_for_child);
14829                    register_builtins(&mut vm);
14830                    vm.set_shell_host(Box::new(ZshrsHost));
14831                    let _ = vm.run();
14832                    let _ = std::io::stdout().flush();
14833                    unsafe { libc::_exit(0) };
14834                }
14835                child_pid => {
14836                    // c:4976-4980 — parent: close the write fd and WAIT so the
14837                    // file is fully written before the consumer opens it.
14838                    unsafe {
14839                        libc::close(fd);
14840                        let mut status: libc::c_int = 0;
14841                        libc::waitpid(child_pid, &mut status, 0);
14842                    }
14843                    let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
14844                    PSUB_PENDING_FILES.with(|v| v.borrow_mut().push((depth, nam.clone())));
14845                    return nam;
14846                }
14847            }
14848        }
14849        // c:Src/exec.c::getproc — `<(cmd)` uses pipe + fork + the
14850        // `/dev/fd/N` filesystem entry (where N is the read end of
14851        // the pipe held open in the parent). Consumer opens
14852        // `/dev/fd/N`, reads the cmd's stdout through the pipe.
14853        // Both macOS and Linux expose `/dev/fd` for held-open file
14854        // descriptors. Previous Rust port captured stdout into
14855        // `/tmp/zshrs_psub_*` tempfiles synchronously — works for
14856        // `diff <(a) <(b)` style readers that scan once but diverges
14857        // from zsh's observable path string and breaks any consumer
14858        // that introspects the path or expects a non-seekable pipe.
14859        let mut fds: [libc::c_int; 2] = [-1, -1];
14860        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
14861            // Pipe creation failed — fall back to tempfile so we at
14862            // least return SOMETHING.
14863            let fifo_path = format!(
14864                "/tmp/zshrs_psub_fallback_{}_{}",
14865                std::process::id(),
14866                with_executor(|e| {
14867                    let n = e.process_sub_counter;
14868                    e.process_sub_counter += 1;
14869                    n
14870                })
14871            );
14872            let _ = fs::remove_file(&fifo_path);
14873            return fifo_path;
14874        }
14875        let (read_end, write_end) = (fds[0], fds[1]);
14876        let sub_for_child = sub.clone();
14877        match unsafe { libc::fork() } {
14878            -1 => {
14879                unsafe {
14880                    libc::close(read_end);
14881                    libc::close(write_end);
14882                }
14883                return String::from("/dev/null");
14884            }
14885            0 => {
14886                // Child: close read end, dup write end to stdout,
14887                // run the sub-chunk, exit. The exit closes the
14888                // write end automatically, so the parent's reader
14889                // gets EOF when the cmd finishes.
14890                PSUB_PENDING_FILES.with(|v| v.borrow_mut().clear());
14891                unsafe {
14892                    libc::close(read_end);
14893                    libc::dup2(write_end, libc::STDOUT_FILENO);
14894                    libc::close(write_end);
14895                }
14896                // c:Src/exec.c:5101/5150 — `execode(prog, 0, 1, out ?
14897                // "outsubst" : "insubst");`. execode (c:1245-1266) APPENDS its
14898                // context for the duration of the body, so `<(cmd)` — whose child WRITES (out=1) — runs as `…:outsubst`.
14899                // zshrs's ported getproc carries these citations but is NOT the
14900                // live path (established in #1062) — the VM forks here instead,
14901                // so the push belongs in this child. No pop needed: this runs
14902                // INSIDE the forked child and dies with it. Bug #1069 (procsub
14903                // legs).
14904                if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
14905                    ctx.push("outsubst".to_string());
14906                    let joined = ctx.join(":");
14907                    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
14908                        if let Some(pm) = tab.get_mut("zsh_eval_context") {
14909                            pm.u_arr = Some(ctx.clone());
14910                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
14911                        }
14912                        if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
14913                            pm.u_str = Some(joined);
14914                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
14915                        }
14916                    }
14917                }
14918                crate::fusevm_disasm::maybe_print_stdout("process_subst_in", &sub_for_child);
14919                let mut vm = fusevm::VM::new(sub_for_child);
14920                register_builtins(&mut vm);
14921                vm.set_shell_host(Box::new(ZshrsHost));
14922                let _ = vm.run();
14923                let _ = std::io::stdout().flush();
14924                unsafe { libc::_exit(0) };
14925            }
14926            child_pid => {
14927                // c:Src/exec.c:5092 `procsubstpid = pid;` — record the
14928                // forked child's PID so `${sysparams[procsubstpid]}`
14929                // returns it (was reading the never-updated atomic, so it
14930                // always came back 0). p10k's gitstatus daemon reads
14931                // `sysparams[procsubstpid]` right after `sysopen <(cmd)`
14932                // to track its worker PID; with 0 the daemon's self-check
14933                // failed and gitstatus fell back to re-downloading
14934                // gitstatusd — surfacing as "no prebuilt gitstatusd".
14935                crate::ported::exec::procsubstpid
14936                    .store(child_pid, std::sync::atomic::Ordering::Relaxed);
14937                // Parent: close write end, keep read end open under
14938                // the same fd value so `/dev/fd/N` resolves to the
14939                // pipe's read side. NOTE: FD_CLOEXEC must STAY clear
14940                // — consumers like `cat <(cmd)` and `diff <(a) <(b)`
14941                // discover the fd via exec inheritance, so closing
14942                // on exec defeats the whole point. C zsh's getproc
14943                // (Src/exec.c:5045+) leaves the fd open across exec.
14944                unsafe {
14945                    libc::close(write_end);
14946                }
14947                // Park read_end for close-after-consuming-command,
14948                // exactly like process_sub_out does for its write_end
14949                // (c:Src/exec.c addfilelist(NULL, fd) → deletefilelist).
14950                // WITHOUT this the parent's read_end stayed open for
14951                // the whole shell lifetime: p10k's async worker /
14952                // realtime clock do `exec {fd}< <(cmd)` on every prompt,
14953                // so each keystroke/redraw leaked a pipe fd until the
14954                // ~256-fd limit was hit and the shell locked up
14955                // (107 leaked pipes + 107 unreaped children observed).
14956                let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
14957                PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, read_end)));
14958                // Reap the forked child so it doesn't linger as a
14959                // zombie. `<(cmd)` children are fire-and-forget (their
14960                // output flows through the pipe); C reaps them via the
14961                // job machinery. A non-blocking reap here is scheduled;
14962                // do a best-effort WNOHANG now and the rest drain on
14963                // subsequent proc-subs / prompt cycles.
14964                crate::fusevm_bridge::note_psub_child(child_pid);
14965            }
14966        }
14967        let path = format!("/dev/fd/{}", read_end);
14968        if crate::provenance::active() {
14969            crate::provenance::on_process_subst(&prov_chunk_label(sub), &path);
14970        }
14971        path
14972    }
14973
14974    fn process_sub_out(&mut self, sub: &fusevm::Chunk) -> String {
14975        // c:Src/exec.c:5025 getproc, PATH_DEV_FD branch — `>(cmd)`
14976        // (out == 0): `mpipe(pipes)`, fork; the CHILD `redup(pipes[0],
14977        // 0)` (pipe read end onto stdin) and `closem` drops the write
14978        // end; the PARENT closes pipes[0] and hands the consumer
14979        // `/dev/fd/<pipes[1]>` (the write end). The previous Rust port
14980        // used mkfifo + a child that BLOCKED in open(FIFO, O_RDONLY)
14981        // before running cmd — with no writer the child never started,
14982        // never exited, and kept its inherited stdout (e.g. a `$()`
14983        // capture pipe) open forever: `a=$(print -r -- >(true))` hung.
14984        // With the pipe shape the child runs immediately and exits,
14985        // releasing inherited fds exactly like zsh (verified: zsh
14986        // blocks ~2s on `a=$(print -r -- >(sleep 2))`, then EOFs).
14987        let mut fds: [libc::c_int; 2] = [-1, -1];
14988        if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
14989            // Pipe creation failed — fall back to a plain temp file so
14990            // the consumer at least has a writable path.
14991            let fallback = format!(
14992                "/tmp/zshrs_psub_out_{}_{}",
14993                std::process::id(),
14994                with_executor(|e| {
14995                    let n = e.process_sub_counter;
14996                    e.process_sub_counter += 1;
14997                    n
14998                })
14999            );
15000            let _ = fs::write(&fallback, "");
15001            return fallback;
15002        }
15003        let (read_end, write_end) = (fds[0], fds[1]);
15004        let sub_for_child = sub.clone();
15005        match unsafe { libc::fork() } {
15006            -1 => {
15007                unsafe {
15008                    libc::close(read_end);
15009                    libc::close(write_end);
15010                }
15011                String::from("/dev/null")
15012            }
15013            0 => {
15014                // Child: close the write end (c: closem after redup),
15015                // dup the read end onto stdin (c: redup(pipes[0], 0)),
15016                // run the sub-chunk, exit. Other std fds stay
15017                // inherited — zsh's child keeps the surrounding
15018                // command's stdout/stderr.
15019                unsafe {
15020                    libc::close(write_end);
15021                    libc::dup2(read_end, libc::STDIN_FILENO);
15022                    libc::close(read_end);
15023                }
15024                // c:Src/exec.c:5101/5150 — `execode(prog, 0, 1, out ?
15025                // "outsubst" : "insubst");`. execode (c:1245-1266) APPENDS its
15026                // context for the duration of the body, so `>(cmd)` — whose child READS (out=0) — runs as `…:insubst`.
15027                // zshrs's ported getproc carries these citations but is NOT the
15028                // live path (established in #1062) — the VM forks here instead,
15029                // so the push belongs in this child. No pop needed: this runs
15030                // INSIDE the forked child and dies with it. Bug #1069 (procsub
15031                // legs).
15032                if let Ok(mut ctx) = crate::ported::exec::zsh_eval_context.lock() {
15033                    ctx.push("insubst".to_string());
15034                    let joined = ctx.join(":");
15035                    if let Ok(mut tab) = crate::ported::params::paramtab().write() {
15036                        if let Some(pm) = tab.get_mut("zsh_eval_context") {
15037                            pm.u_arr = Some(ctx.clone());
15038                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
15039                        }
15040                        if let Some(pm) = tab.get_mut("ZSH_EVAL_CONTEXT") {
15041                            pm.u_str = Some(joined);
15042                            pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
15043                        }
15044                    }
15045                }
15046                crate::fusevm_disasm::maybe_print_stdout("process_subst_out:child", &sub_for_child);
15047                let mut vm = fusevm::VM::new(sub_for_child);
15048                register_builtins(&mut vm);
15049                vm.set_shell_host(Box::new(ZshrsHost));
15050                let _ = vm.run();
15051                unsafe { libc::_exit(0) };
15052            }
15053            child_pid => {
15054                // c:Src/exec.c:5143 `procsubstpid = pid;` — same fix as
15055                // the `<(cmd)` in-path above: record the forked child's
15056                // PID for `${sysparams[procsubstpid]}` (was always 0).
15057                crate::ported::exec::procsubstpid
15058                    .store(child_pid, std::sync::atomic::Ordering::Relaxed);
15059                // Parent: close the read end, keep the write end open
15060                // under its fd value so `/dev/fd/N` resolves to the
15061                // pipe's write side. FD_CLOEXEC must STAY clear —
15062                // consumers (`tee >(cmd)`) discover the fd via exec
15063                // inheritance, matching process_sub_in above and C's
15064                // fdtable[fd] = FDT_PROC_SUBST bookkeeping. Park the
15065                // fd for close-after-consuming-command (c: addfilelist
15066                // (NULL, fd) → deletefilelist) so the child's reader
15067                // EOFs — without this `tee >(wc -c) </dev/null` left
15068                // wc blocked until shell exit.
15069                unsafe {
15070                    libc::close(read_end);
15071                }
15072                let depth = PSUB_SCOPE_DEPTH.with(|d| d.get());
15073                PSUB_PENDING_FDS.with(|v| v.borrow_mut().push((depth, write_end)));
15074                let path = format!("/dev/fd/{}", write_end);
15075                if crate::provenance::active() {
15076                    crate::provenance::on_process_subst(&prov_chunk_label(sub), &path);
15077                }
15078                path
15079            }
15080        }
15081    }
15082
15083    fn subshell_begin(&mut self) {
15084        with_executor(|exec| {
15085            // Special parameters whose value lives in a process GLOBAL behind a
15086            // GSU (`Src/params.c`'s `ifs`, `wordchars`, `home`, `histsiz`, …)
15087            // rather than in the param table. Mirrors the getfn dispatch list at
15088            // params.rs:12548. C isolates these for free by forking `(...)`;
15089            // zshrs's in-process subshell has to snapshot them by hand, or a
15090            // subshell-local `IFS=,` rewrites the parent's word-splitting.
15091            const SUBSHELL_SPECIAL_GLOBALS: &[&str] = &[
15092                "IFS",
15093                "HOME",
15094                "TERM",
15095                "USERNAME",
15096                "WORDCHARS",
15097                "TERMINFO",
15098                "TERMINFO_DIRS",
15099                "KEYBOARD_HACK",
15100                "histchars",
15101                "HISTSIZE",
15102                "SAVEHIST",
15103            ];
15104            // An UNSET special yields None and is skipped: the paramtab snapshot
15105            // restores its PM_UNSET flag, and the getfn dispatch refuses to read
15106            // the (stale) global while PM_UNSET is set (params.rs:12552).
15107            let special_globals_snap: Vec<(String, String)> = SUBSHELL_SPECIAL_GLOBALS
15108                .iter()
15109                .filter_map(|n| crate::ported::params::getsparam(n).map(|v| ((*n).to_string(), v)))
15110                .collect();
15111            // libc::umask returns the previous mask AND sets the new
15112            // one; call with current value to read without changing.
15113            let cur_umask = unsafe {
15114                let m = libc::umask(0o022);
15115                libc::umask(m);
15116                m as u32
15117            };
15118            // Snapshot paramtab + hashed-storage too (step 1 of the
15119            // store unification mirrors writes there; restoring only
15120            // the HashMaps leaks subshell-scoped writes to the parent
15121            // via paramtab readers like `paramsubst → vars_get`).
15122            let paramtab_snap = crate::ported::params::paramtab()
15123                .read()
15124                .ok()
15125                .map(|t| t.clone())
15126                // c:Src/params.c:854 — a fresh table is 151 buckets, not
15127                // the 17-bucket `Default`.
15128                .unwrap_or_else(|| crate::ported::hashtable::hashtable_nodes::newhashtable(151));
15129            let paramtab_hashed_snap = crate::ported::params::paramtab_hashed_storage()
15130                .lock()
15131                .ok()
15132                .map(|m| m.clone())
15133                .unwrap_or_default();
15134            let loop_flags_snap = {
15135                use std::sync::atomic::Ordering::SeqCst;
15136                (
15137                    crate::ported::builtin::LOOPS.load(SeqCst),
15138                    crate::ported::builtin::BREAKS.load(SeqCst),
15139                    crate::ported::builtin::CONTFLAG.load(SeqCst),
15140                )
15141            };
15142            exec.subshell_snapshots.push(SubshellSnapshot {
15143                // c:Src/Modules/zutil.c:106 `static HashTable zstyletab` —
15144                // fork-copied for `(...)` in C. See SubshellSnapshot::zstyles.
15145                zstyles: crate::ported::modules::zutil::zstyletab
15146                    .lock()
15147                    .map(|t| t.clone())
15148                    .unwrap_or_default(),
15149                // c:Src/utils.c:2111 `addlockfd` — the fds carrying
15150                // `zsystem flock` locks. Recorded so subshell_end can close
15151                // the ones the subshell itself opened (C's fork does it for
15152                // free). See SubshellSnapshot::flock_fds.
15153                flock_fds: current_flock_fds(),
15154                loop_flags: loop_flags_snap,
15155                paramtab: paramtab_snap,
15156                paramtab_hashed_storage: paramtab_hashed_snap,
15157                special_globals: special_globals_snap,
15158                positional_params: exec.pparams(),
15159                env_vars: env::vars().collect(),
15160                // Save the LOGICAL pwd ($PWD env), not `current_dir()`'s
15161                // symlink-resolved path. zsh's subshell isolation per
15162                // Src/exec.c at the `entersubsh` path treats `pwd` (the
15163                // shell-tracked logical PWD) as the carrier — see
15164                // `Src/builtin.c:1239-1242` where cd writes the logical
15165                // dest into `pwd`. Falling back to current_dir() only
15166                // when PWD is unset matches `setupvals` at
15167                // `Src/init.c:1100+`.
15168                cwd: env::var("PWD")
15169                    .ok()
15170                    .map(PathBuf::from)
15171                    .or_else(|| env::current_dir().ok()),
15172                umask: cur_umask,
15173                // Snapshot canonical `traps_table` — bin_trap writes
15174                // there (`Src/builtin.c`).
15175                traps: crate::ported::builtin::traps_table()
15176                    .lock()
15177                    .map(|t| t.clone())
15178                    .unwrap_or_default(),
15179                // Snapshot option store so `(set -e)` /
15180                // `(setopt extendedglob)` don't leak to parent.
15181                opts: crate::ported::options::opt_state_snapshot(),
15182                // c:Src/exec.c — fork() copies the alias table to
15183                // the subshell. `(alias x=y)` inside the subshell
15184                // dies with the child; the parent doesn't see x.
15185                // Snapshot here so subshell_end can restore.
15186                // Bug #209 in docs/BUGS.md.
15187                aliases: crate::ported::hashtable::aliastab_lock()
15188                    .read()
15189                    .ok()
15190                    .map(|t| {
15191                        t.iter()
15192                            .map(|(k, v)| (k.clone(), v.text.clone(), v.node.flags))
15193                            .collect()
15194                    })
15195                    .unwrap_or_default(),
15196                // c:Src/exec.c::entersubsh — same fork-copy
15197                //   semantics for shfunctab. `(f() { ... })` defined
15198                //   inside the subshell dies with the child; parent's
15199                //   `type f` reports "not found". Bug #208 in
15200                //   docs/BUGS.md.
15201                shfuncs: crate::ported::hashtable::shfunctab_lock()
15202                    .read()
15203                    .ok()
15204                    .map(|t| t.snapshot())
15205                    .unwrap_or_default(),
15206                functions_compiled: exec.functions_compiled.clone(),
15207                function_source: exec.function_source.clone(),
15208                // c:Src/exec.c::entersubsh — subshell forks its own
15209                // modulestab. A `(zmodload zsh/X)` inside the
15210                // subshell flips MOD_INIT_B on the CHILD's
15211                // modulestab; when the child exits the change
15212                // dies with it. zshrs's in-process subshell would
15213                // otherwise leak the load to the parent.
15214                // Bug #210 in docs/BUGS.md. Snapshot just the
15215                // (name → flags) pairs since the only mutating
15216                // field is the flags bitmask (MOD_INIT_B for
15217                // loaded, MOD_UNLOAD for unloaded).
15218                modules: crate::ported::module::MODULESTAB
15219                    .lock()
15220                    .ok()
15221                    .map(|t| {
15222                        t.modules
15223                            .iter()
15224                            .map(|(k, v)| (k.clone(), v.node.flags))
15225                            .collect()
15226                    })
15227                    .unwrap_or_default(),
15228                // c:Src/exec.c::entersubsh — fork-copy semantics for
15229                // THINGYTAB (ZLE widget registry). A subshell `zle -N`
15230                // / `zle -D` mutation dies with the child in C zsh;
15231                // mirror via in-process snapshot. Bug #453.
15232                thingytab: crate::ported::zle::zle_thingy::thingytab()
15233                    .lock()
15234                    .ok()
15235                    .map(|t| t.clone())
15236                    .unwrap_or_default(),
15237                // c:Src/exec.c::entersubsh — same fork-copy for the
15238                // KEYMAPNAMTAB (named keymap registry). `bindkey -N km`
15239                // / `bindkey -D km` inside a subshell dies with the
15240                // child. Bug #454.
15241                keymapnamtab: crate::ported::zle::zle_keymap::keymapnamtab()
15242                    .lock()
15243                    .ok()
15244                    .map(|t| t.clone())
15245                    .unwrap_or_default(),
15246                // c:Src/exec.c::entersubsh fork semantics — `$!`
15247                // (clone::lastpid) set by a `&` INSIDE the subshell
15248                // dies with the child: `( : & ); echo $!` -> 0.
15249                lastpid: crate::ported::modules::clone::lastpid
15250                    .load(std::sync::atomic::Ordering::Relaxed),
15251                // c:Src/exec.c::entersubsh fork semantics — the
15252                // subshell gets a COPY of the job table; its disown/
15253                // wait/`&` mutations die with it. Bug #462.
15254                jobtab: crate::ported::jobs::JOBTAB
15255                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
15256                    .lock()
15257                    .map(|t| t.clone())
15258                    .unwrap_or_default(),
15259                curjob: *crate::ported::jobs::CURJOB
15260                    .get_or_init(|| std::sync::Mutex::new(-1))
15261                    .lock()
15262                    .unwrap(),
15263                prevjob: *crate::ported::jobs::PREVJOB
15264                    .get_or_init(|| std::sync::Mutex::new(-1))
15265                    .lock()
15266                    .unwrap(),
15267                maxjob: *crate::ported::jobs::MAXJOB
15268                    .get_or_init(|| std::sync::Mutex::new(0))
15269                    .lock()
15270                    .unwrap(),
15271                thisjob: *crate::ported::jobs::THISJOB
15272                    .get_or_init(|| std::sync::Mutex::new(-1))
15273                    .lock()
15274                    .unwrap(),
15275                // c:Src/exec.c entersubsh — fork copies the fd table;
15276                // the child's `exec >file` / `exec N<&-` mutations die
15277                // with it. Dup each user-range fd to >= 10 so
15278                // subshell_end can restore the parent's exact table.
15279                saved_fds: (0..10)
15280                    .map(|fd| {
15281                        let dup = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
15282                        (fd, dup)
15283                    })
15284                    .collect(),
15285                // c:Src/signals.c:39 `sigtrapped` — saved so End restores the
15286                // parent's per-signal trap flags (see the field docs).
15287                sigtrapped: crate::ported::signals::sigtrapped
15288                    .lock()
15289                    .map(|g| g.clone())
15290                    .unwrap_or_default(),
15291                // c:Src/exec.c:160 `int subsh;` — saved so End can put the
15292                // parent's value back (subshells nest).
15293                subsh: crate::ported::exec::subsh.load(std::sync::atomic::Ordering::Relaxed),
15294                // c:Src/builtin.c:541-547 — `enable`/`disable` flip the
15295                // DISABLED bit on the `builtintab` node; c's fork for
15296                // `( … )` gives the child a private copy of the table.
15297                // See SubshellSnapshot::builtins_disabled.
15298                builtins_disabled: crate::ported::builtin::BUILTINS_DISABLED
15299                    .lock()
15300                    .map(|s| s.clone())
15301                    .unwrap_or_default(),
15302                // c:Src/builtin.c:541-547 — same for `disable -r` on the
15303                // `reswdtab` node. See SubshellSnapshot::reswds_disabled.
15304                reswds_disabled: crate::ported::hashtable::reswdtab_lock()
15305                    .read()
15306                    .map(|t| {
15307                        t.iter()
15308                            .filter(|(_, r)| {
15309                                (r.node.flags & crate::ported::zsh_h::DISABLED as i32) != 0
15310                            })
15311                            .map(|(n, _)| n.clone())
15312                            .collect()
15313                    })
15314                    .unwrap_or_default(),
15315            });
15316            // c:Src/exec.c:1192-1193 — `if (!(flags & ESUB_FAKE)) subsh = 1;`
15317            // A `( … )` is a real subshell, so the body runs with subsh set.
15318            // The forked child carries it in C; the in-process body needs it
15319            // set explicitly or per-command checks that read it — notably
15320            // PRINT_EXIT_VALUE (c:4309 `&& !subsh`) — behave as if the
15321            // command ran in the parent.
15322            crate::ported::exec::subsh.store(1, std::sync::atomic::Ordering::Relaxed);
15323            // C forks for `(...)` — count the fork-equivalent so
15324            // `time (builtin)` reports like zsh (see FORK_EVENTS).
15325            crate::vm_helper::FORK_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
15326            // c:Src/exec.c:1088-1092 — entersubsh resets traps in the child:
15327            //     if (!(flags & ESUB_KEEPTRAP))
15328            //         for (sig = 0; sig <= SIGCOUNT; sig++)
15329            //             if (!(sigtrapped[sig] & ZSIG_FUNC) &&
15330            //                 !(isset(POSIXTRAPS) && (sigtrapped[sig] & ZSIG_IGNORED)))
15331            //                 unsettrap(sig);
15332            //
15333            // A subshell does NOT inherit string-form traps. Two exemptions:
15334            // FUNCTION-form traps (`TRAPUSR1() { … }`, ZSIG_FUNC) survive, and
15335            // under POSIX_TRAPS an IGNORED trap (`trap '' SIG`) survives.
15336            //
15337            // The ZSIG_FUNC exemption is structural here rather than a flag
15338            // test: zshrs keeps string-form bodies in traps_table and
15339            // function-form ones in shfunctab as TRAPxxx, so filtering only
15340            // traps_table leaves the function form untouched by construction.
15341            // ZSIG_IGNORED is `trap '' SIG`, which stores an empty body.
15342            //
15343            // The LOOP BOUND is also part of the spec, not an implementation
15344            // detail: `sig <= SIGCOUNT` never reaches the PSEUDO-signals,
15345            // which zsh numbers above the real ones —
15346            //     #define SIGZERR   (SIGCOUNT+1)
15347            //     #define SIGDEBUG  (SIGCOUNT+2)      (c:Src/signals.h:34-35)
15348            // so ERR/ZERR and DEBUG traps SURVIVE a subshell, while SIGEXIT
15349            // (sig 0) is inside the loop and is cleared. Verified against the
15350            // oracle: `trap 'print e' ERR; (trap)` lists the ERR trap;
15351            // `trap 'print u' USR1; (trap)` lists nothing.
15352            //
15353            // Without this a subshell kept the parent's traps: `(trap)` listed
15354            // them where zsh lists nothing, and — the part that isn't
15355            // cosmetic — an inherited trap FIRED inside the child, so
15356            // `trap 'print p' USR1; (kill -USR1 $$; print after)` printed
15357            // p before after instead of after…p (the signal is meant to reach
15358            // the parent, whose trap runs there).
15359            //
15360            // The snapshot pushed above restores the parent's table at
15361            // subshell_end, which is what makes clearing safe for zshrs's
15362            // in-process subshell.
15363            {
15364                // Record the PARENT's `sigtrapped[]` and the parent's
15365                // `limits[]` BEFORE the reset below wipes the first and
15366                // before the body can call `ulimit` on the second. C
15367                // gets both from the fork: the child mutates private
15368                // copies (`Src/exec.c:315` for the limits, the
15369                // `sigtrapped[]` of `Src/signals.c:39` for the traps)
15370                // and the parent's stay put.
15371                subshell_signal_enter();
15372                #[cfg(unix)]
15373                {
15374                    crate::ported::builtins::rlimits::ensure_limits_initialized();
15375                    let saved_limits = crate::ported::builtins::rlimits::LIMITS
15376                        .get()
15377                        .and_then(|l| l.lock().ok().map(|g| g.clone()))
15378                        .unwrap_or_default();
15379                    let saved_current = crate::ported::builtins::rlimits::CURRENT_LIMITS
15380                        .get()
15381                        .and_then(|l| l.lock().ok().map(|g| g.clone()))
15382                        .unwrap_or_default();
15383                    SUBSH_SAVED_LIMITS
15384                        .with(|s| s.borrow_mut().push((saved_limits, saved_current)));
15385                }
15386                entersubsh_reset_traps();
15387            }
15388            // c:Src/exec.c:2862 — subshell fork flags carry ESUB_PGRP,
15389            // so entersubsh runs `clearjobtab(monitor)` (c:1219): the
15390            // child gets an EMPTY job table plus the procless control
15391            // job grabbed at Src/jobs.c:1828 (`thisjob = initjob()`).
15392            // That's why zsh's `(jobs)` prints nothing and `(kill %1)`
15393            // hits the empty control job instead of the parent's job 1.
15394            // The snapshot pushed above restores the parent's table at
15395            // subshell_end. Bug #462.
15396            let monitor = crate::ported::zsh_h::isset(crate::ported::zsh_h::MONITOR) as i32;
15397            crate::ported::jobs::clearjobtab(&mut exec.jobs, monitor);
15398            // clearjobtab left THISJOB on the control job (Src/jobs.c:
15399            // 1828). In C the very next pipeline's execpline reassigns
15400            // thisjob (Src/exec.c:1700 `thisjob = newjob = initjob()`),
15401            // so by the time any builtin runs, thisjob never aliases
15402            // the control job. zshrs has no per-pipeline job slot —
15403            // model the between-pipelines state (-1) so getjob's
15404            // `jobnum != thisjob` (c:jobs.c:2107) doesn't reject %1 and
15405            // setcurjob doesn't demote an inherited curjob that
15406            // collides with the control slot.
15407            *crate::ported::jobs::THISJOB
15408                .get_or_init(|| std::sync::Mutex::new(-1))
15409                .lock()
15410                .unwrap() = -1;
15411            // Subshell starts with EXIT trap cleared so the parent's
15412            // EXIT handler doesn't fire when the subshell ends. zsh:
15413            // each subshell has its own trap context. Other signals
15414            // are inherited (well, parent's are still in place — but
15415            // a trap set INSIDE the subshell shouldn't leak out).
15416            if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
15417                t.remove("EXIT");
15418            }
15419            let level = exec
15420                .scalar("ZSH_SUBSHELL")
15421                .and_then(|s| s.parse::<i32>().ok())
15422                .unwrap_or(0);
15423            // c:Src/exec.c — ZSH_SUBSHELL carries PM_READONLY (declared
15424            // in params.rs special_params); setsparam would be rejected
15425            // by assignstrvalue's PM_READONLY guard. Write u_val
15426            // directly — same bypass pattern as BUILTIN_SET_LINENO at
15427            // line 2784. C zsh's PM_SPECIAL GSU vtable handles this
15428            // implicitly via the setfn callback.
15429            let new_level = (level + 1) as i64;
15430            if let Ok(mut tab) = crate::ported::params::paramtab().write() {
15431                if let Some(pm) = tab.get_mut("ZSH_SUBSHELL") {
15432                    pm.u_val = new_level;
15433                    pm.u_str = Some(new_level.to_string());
15434                    pm.node.flags &= !(crate::ported::zsh_h::PM_UNSET as i32);
15435                }
15436            }
15437        });
15438        // Bump SUBSHELL_DEPTH so zexit defers process::exit (see
15439        // SUBSHELL_DEPTH declaration in src/ported/builtin.rs for
15440        // rationale).
15441        crate::ported::builtin::SUBSHELL_DEPTH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
15442        // c:Src/exec.c::entersubsh — C zsh's subshell is a forked
15443        // child process: signals sent to the parent (via `kill $$`
15444        // inside the subshell, where `$$` is the parent's pid)
15445        // never reach the child's signal handlers. zshrs's
15446        // in-process subshell shares the process pid with the
15447        // parent, so without queueing the subshell's trap handler
15448        // fires for signals that zsh would deliver only to the
15449        // parent. Queue signals across the subshell body so the
15450        // parent's restored trap table sees them after
15451        // subshell_end's unqueue drain. Bug #450.
15452        crate::ported::signals_h::queue_signals();
15453    }
15454
15455    fn subshell_end(&mut self) -> Option<i32> {
15456        // Fire subshell's EXIT trap BEFORE restoring parent state so
15457        // the trap body sees the subshell's vars and exit status. zsh
15458        // forks for `(...)` so the trap runs in the child process,
15459        // before exit. We mirror by running it here, just before the
15460        // pop+restore. REMOVE the trap before firing so the inner
15461        // execute_script doesn't fire it again at its own end.
15462        let exit_trap_body = crate::ported::builtin::traps_table()
15463            .lock()
15464            .ok()
15465            .and_then(|mut t| t.remove("EXIT"));
15466        if let Some(body) = exit_trap_body {
15467            // Execute the trap body. Errors during trap execution
15468            // don't bubble — zsh ignores trap-body errors.
15469            with_executor(|exec| {
15470                let _ = exec.execute_script(&body);
15471            });
15472        }
15473        with_executor(|exec| {
15474            if let Some(snap) = exec.subshell_snapshots.pop() {
15475                // c:Src/exec.c::entersubsh fork semantics — `loops` /
15476                // `breaks` / `contflag` are process globals the child
15477                // owns a private copy of, so `(break)` inside a loop
15478                // cannot end the PARENT's loop. See
15479                // SubshellSnapshot::loop_flags.
15480                {
15481                    use std::sync::atomic::Ordering::SeqCst;
15482                    let (loops, breaks, contflag) = snap.loop_flags;
15483                    crate::ported::builtin::LOOPS.store(loops, SeqCst);
15484                    crate::ported::builtin::BREAKS.store(breaks, SeqCst);
15485                    crate::ported::builtin::CONTFLAG.store(contflag, SeqCst);
15486                }
15487                // c:Src/Modules/zutil.c:106 — restore the fork-copied
15488                // zstyle table. See SubshellSnapshot::zstyles.
15489                if let Ok(mut t) = crate::ported::modules::zutil::zstyletab.lock() {
15490                    *t = snap.zstyles;
15491                }
15492                // c:Src/utils.c:2155-2164 `zcloselockfd` — release the
15493                // `zsystem flock` locks the subshell itself took. Under C
15494                // the forked child's fds close on exit; here we close the
15495                // fds that appeared while the subshell was running.
15496                // See SubshellSnapshot::flock_fds.
15497                for fd in current_flock_fds() {
15498                    if !snap.flock_fds.contains(&fd) {
15499                        crate::ported::utils::zcloselockfd(fd);
15500                    }
15501                }
15502                // c:Src/exec.c:160 / :1192-1193 — the child's `subsh = 1`
15503                // dies with the fork in C; restore the parent's value here.
15504                crate::ported::exec::subsh.store(snap.subsh, std::sync::atomic::Ordering::Relaxed);
15505                // c:Src/builtin.c:541-547 — the child's `enable`/`disable`
15506                // only touched its forked copy of `builtintab` /
15507                // `reswdtab`. Put the parent's DISABLED sets back.
15508                // See SubshellSnapshot::builtins_disabled.
15509                if let Ok(mut s) = crate::ported::builtin::BUILTINS_DISABLED.lock() {
15510                    *s = snap.builtins_disabled;
15511                }
15512                if let Ok(mut t) = crate::ported::hashtable::reswdtab_lock().write() {
15513                    let names: Vec<String> = t.iter().map(|(n, _)| n.clone()).collect();
15514                    for n in names {
15515                        if snap.reswds_disabled.contains(&n) {
15516                            t.disable(&n);
15517                        } else {
15518                            t.enable(&n);
15519                        }
15520                    }
15521                }
15522                // c:Src/signals.c:39 — same fork-copy reasoning for the
15523                // per-signal trap flags cleared at subshell entry.
15524                //
15525                // The `sigaction` DISPOSITIONS the body installed leak
15526                // out too, and the flag restore alone does not undo
15527                // them: `( trap 'echo IN' USR1 )` runs `settrap` →
15528                // `install_handler` (c:Src/signals.c:730) on the shared
15529                // process, so afterwards a `kill -USR1 $$` in the parent
15530                // hit zshrs's handler, found no trap, and was swallowed
15531                // where zsh — whose parent still had SIG_DFL, the child
15532                // having installed the handler on its own copy — dies.
15533                // Roll the dispositions back for exactly the signals
15534                // whose flags differ.
15535                let child_sigtrapped = match crate::ported::signals::sigtrapped.lock() {
15536                    Ok(mut st) => {
15537                        let prev = st.clone();
15538                        *st = snap.sigtrapped.clone();
15539                        prev
15540                    }
15541                    Err(_) => Vec::new(),
15542                };
15543                subshell_restore_signal_dispositions(&snap.sigtrapped, &child_sigtrapped);
15544                // c:Src/exec.c::entersubsh — restore parent's
15545                // modulestab so a subshell `(zmodload zsh/X)` doesn't
15546                // leak to the parent. Bug #210 in docs/BUGS.md.
15547                // Restore via per-module flag write since the
15548                // snapshot is `(name → flags)` only.
15549                if let Ok(mut t) = crate::ported::module::MODULESTAB.lock() {
15550                    // A `zmodload zsh/X` for a module with no modulestab
15551                    // node yet takes load_module's allocate-on-miss branch
15552                    // (c:Src/module.c:2223-2251) and CREATES the node. In C
15553                    // that node is allocated in the forked child and dies
15554                    // with it; here it survives, and the flag-only restore
15555                    // below never touched it because the parent's snapshot
15556                    // has no entry for that name. So `(zmodload zsh/datetime)`
15557                    // left the parent with a MOD_INIT_B node and
15558                    // `zmodload -e zsh/datetime` answered 0 where zsh
15559                    // answers 1 (V04features.ztst %prep loads the module in
15560                    // exactly that shape). Drop nodes the parent didn't have
15561                    // FIRST, then restore the flags of the ones it did.
15562                    // Rolling the node out is not enough on its own: a
15563                    // module's feature-enable state lives in ITS OWN
15564                    // statics (C: the `bintab[]` BINF_ADDED bits and
15565                    // `patab[]` `d->pm` slots the load flipped —
15566                    // `setfeatureenables`, c:Src/module.c:3445), which the
15567                    // fork made private to the child. Run the same rollback
15568                    // C runs on a real unload — `cleanup_module` (c:1918) →
15569                    // `finish_module` (c:1926) — so the parent's view of
15570                    // those tables matches the "module was never loaded"
15571                    // state it had before the subshell.
15572                    let strays: Vec<String> = t
15573                        .modules
15574                        .keys()
15575                        .filter(|n| !snap.modules.contains_key(*n))
15576                        .cloned()
15577                        .collect();
15578                    for name in &strays {
15579                        let loaded = t
15580                            .modules
15581                            .get(name)
15582                            .map(|m| (m.node.flags & crate::ported::zsh_h::MOD_INIT_B) != 0)
15583                            .unwrap_or(false);
15584                        if loaded {
15585                            let _ = crate::ported::module::cleanup_module(&mut t, name);
15586                            let _ = crate::ported::module::finish_module(&mut t, name);
15587                        }
15588                    }
15589                    t.modules.retain(|name, _| snap.modules.contains_key(name));
15590                    for (name, saved_flags) in &snap.modules {
15591                        if let Some(m) = t.modules.get_mut(name) {
15592                            m.node.flags = *saved_flags;
15593                        }
15594                    }
15595                }
15596                // NOTE: this runs BEFORE the paramtab restore below.
15597                // `cleanup_module` -> `setfeatureenables(m, f, NULL)`
15598                // (c:Src/module.c:3445) -> `deleteparamdef` (c:1128) looks
15599                // its parameter up in the LIVE paramtab, so rolling the
15600                // module back after the parent's paramtab was reinstated
15601                // found nothing and left the module's `patab[]` slots
15602                // marked enabled forever.
15603                // Restore paramtab + hashed storage so subshell-scoped
15604                // writes via setsparam/setaparam/sethparam don't leak
15605                // to the parent via paramtab readers.
15606                if let Some(tab) = crate::ported::params::paramtab()
15607                    .write()
15608                    .ok()
15609                    .as_deref_mut()
15610                {
15611                    *tab = snap.paramtab;
15612                }
15613                // Restore the global-backed specials (see
15614                // SubshellSnapshot::special_globals). MUST run after the
15615                // paramtab restore above: setsparam writes through the GSU setfn
15616                // to BOTH the process global and the param node, so the paramtab
15617                // overwrite would otherwise clobber the node half of it.
15618                for (name, val) in &snap.special_globals {
15619                    crate::ported::params::setsparam(name, val);
15620                }
15621                // c:Src/exec.c::entersubsh fork semantics — restore
15622                // the parent's `$!`; a background job inside `(...)`
15623                // dies with the child in C zsh.
15624                crate::ported::modules::clone::lastpid
15625                    .store(snap.lastpid, std::sync::atomic::Ordering::Relaxed);
15626                // c:Src/exec.c::entersubsh fork semantics — restore the
15627                // parent's job table + curjob/prevjob/maxjob/thisjob.
15628                // The subshell mutated only its own copy. Bug #462.
15629                if let Ok(mut t) = crate::ported::jobs::JOBTAB
15630                    .get_or_init(|| std::sync::Mutex::new(Vec::new()))
15631                    .lock()
15632                {
15633                    *t = snap.jobtab;
15634                }
15635                *crate::ported::jobs::CURJOB
15636                    .get_or_init(|| std::sync::Mutex::new(-1))
15637                    .lock()
15638                    .unwrap() = snap.curjob;
15639                *crate::ported::jobs::PREVJOB
15640                    .get_or_init(|| std::sync::Mutex::new(-1))
15641                    .lock()
15642                    .unwrap() = snap.prevjob;
15643                *crate::ported::jobs::MAXJOB
15644                    .get_or_init(|| std::sync::Mutex::new(0))
15645                    .lock()
15646                    .unwrap() = snap.maxjob;
15647                *crate::ported::jobs::THISJOB
15648                    .get_or_init(|| std::sync::Mutex::new(-1))
15649                    .lock()
15650                    .unwrap() = snap.thisjob;
15651                if let Some(m) = crate::ported::params::paramtab_hashed_storage()
15652                    .lock()
15653                    .ok()
15654                    .as_deref_mut()
15655                {
15656                    *m = snap.paramtab_hashed_storage;
15657                }
15658                exec.set_pparams(snap.positional_params);
15659                // Restore the OS env to its pre-subshell state.
15660                // Removes any `export` writes the subshell made, and
15661                // restores any vars the subshell unset. Without this
15662                // `(export y=sub)` would leak `y` to the parent shell.
15663                let current: HashMap<String, String> = env::vars().collect();
15664                for k in current.keys() {
15665                    if !snap.env_vars.contains_key(k) {
15666                        env::remove_var(k);
15667                    }
15668                }
15669                for (k, v) in &snap.env_vars {
15670                    if current.get(k) != Some(v) {
15671                        env::set_var(k, v);
15672                    }
15673                }
15674                if let Some(cwd) = snap.cwd {
15675                    let _ = env::set_current_dir(&cwd);
15676                    // Resync $PWD env so a parent `pwd` doesn't read
15677                    // the cwd the subshell `cd`'d into.
15678                    env::set_var("PWD", &cwd);
15679                }
15680                // Restore umask. zsh's `(umask 077)` doesn't leak to
15681                // parent because the subshell forks; we run in-process
15682                // so we manually reset.
15683                unsafe {
15684                    libc::umask(snap.umask as libc::mode_t);
15685                }
15686                // Restore parent's traps (the subshell's own traps die
15687                // with it). zsh: `(trap "X" USR1)` doesn't leak the
15688                // USR1 trap out of the subshell. Write back to the
15689                // canonical `traps_table` (bin_trap writes there).
15690                if let Ok(mut t) = crate::ported::builtin::traps_table().lock() {
15691                    *t = snap.traps;
15692                }
15693                // Restore parent's option store so `(set -e)` /
15694                // `(setopt extendedglob)` don't leak. zsh forks
15695                // subshells so child option changes die with the
15696                // child; we run in-process and must restore.
15697                crate::ported::options::opt_state_restore(snap.opts);
15698                // c:Src/exec.c — fork() means alias mutations in a
15699                // subshell die with the child. Restore parent's
15700                // alias table from snapshot. Clear current entries
15701                // then re-add parent's. Bug #209 in docs/BUGS.md.
15702                if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
15703                    tab.clear();
15704                    for (name, text, flags) in snap.aliases {
15705                        tab.add(crate::ported::zsh_h::alias {
15706                            node: crate::ported::zsh_h::hashnode {
15707                                next: None,
15708                                nam: name,
15709                                // ALIAS_GLOBAL / DISABLED must survive the
15710                                // round-trip — flags:0 turned every global
15711                                // alias regular on ANY subshell exit.
15712                                flags,
15713                            },
15714                            text,
15715                            inuse: 0,
15716                        });
15717                    }
15718                }
15719                // c:Src/exec.c::entersubsh — same fork-copy
15720                //   semantics for shfunctab. Restore parent's function
15721                //   table from snapshot so `(f() { ... })` definitions
15722                //   inside the subshell don't leak to the parent.
15723                //   Bug #208 in docs/BUGS.md.
15724                if let Ok(mut tab) = crate::ported::hashtable::shfunctab_lock().write() {
15725                    tab.restore(snap.shfuncs);
15726                }
15727                // Restore the runtime dispatch tables (compiled chunks
15728                // + source). Without these, a subshell-defined
15729                // override leaves its bytecode in place even after
15730                // shfunctab is restored — `g` after the subshell would
15731                // still run the override.
15732                exec.functions_compiled = snap.functions_compiled;
15733                exec.function_source = snap.function_source;
15734                // c:Src/exec.c::entersubsh — restore parent's THINGYTAB
15735                // so a subshell's `zle -N w f` / `zle -D w` doesn't
15736                // affect the parent's widget registry. Bug #453.
15737                if let Ok(mut t) = crate::ported::zle::zle_thingy::thingytab().lock() {
15738                    *t = snap.thingytab;
15739                }
15740                // Same for KEYMAPNAMTAB. Bug #454.
15741                if let Ok(mut t) = crate::ported::zle::zle_keymap::keymapnamtab().lock() {
15742                    *t = snap.keymapnamtab;
15743                }
15744                // c:Src/exec.c entersubsh fork semantics — restore the
15745                // parent's user-range fd table. A bare `exec >file` /
15746                // `exec N>&-` inside `(...)` died with the C child;
15747                // the in-process subshell must undo it here. Flush
15748                // Rust's stdout buffer FIRST so bytes the subshell
15749                // printed drain to the SUBSHELL's fd 1, not the
15750                // restored parent fd.
15751                {
15752                    use std::io::Write;
15753                    let _ = std::io::stdout().flush();
15754                }
15755                for (fd, saved) in snap.saved_fds {
15756                    unsafe {
15757                        if saved >= 0 {
15758                            libc::dup2(saved, fd);
15759                            libc::close(saved);
15760                        } else {
15761                            // fd was closed at entry; close whatever
15762                            // the subshell opened on that slot.
15763                            libc::close(fd);
15764                        }
15765                    }
15766                }
15767            }
15768        });
15769        // Decrement SUBSHELL_DEPTH. If a deferred subshell exit
15770        // landed inside (EXIT_PENDING set with depth > 0), promote
15771        // the deferred status into the subshell's exit status now
15772        // that we're at the boundary, then clear so the parent
15773        // continues. Matches C zsh's "subshell-exit-via-fork"
15774        // boundary where the child's process::exit(N) becomes
15775        // $WAITSTATUS / $? in the parent.
15776        crate::ported::builtin::SUBSHELL_DEPTH.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
15777        // c:Src/exec.c — drain the signal queue against the now-
15778        // restored parent trap table. Pairs with the
15779        // queue_signals() call at the end of subshell_begin.
15780        // Any `kill $$` from inside the subshell is processed
15781        // here against OUTER's trap, matching C zsh's
15782        // signal-delivery-to-parent semantics. Bug #450.
15783        crate::ported::signals_h::unqueue_signals();
15784        // c:Src/exec.c:315 / :381-383 — `limits[]` is fork-copied and the
15785        // child applies its copy with `setlimits(NULL)`, so `( ulimit -n
15786        // 256 )` cannot be seen by the parent. Roll both arrays back and
15787        // replay `setrlimit` for exactly the resources whose
15788        // `current_limits[]` the BODY moved — the same test `zsetlimit`
15789        // makes (c:319-320), applied to before/after rather than
15790        // want/have, so a `limit` the PARENT set without `-s` is not
15791        // suddenly installed here. A body that LOWERED a hard limit is
15792        // the one case this cannot undo: the process cannot raise it
15793        // again, and C only escapes that because the child dies with it.
15794        #[cfg(unix)]
15795        {
15796            let saved = SUBSH_SAVED_LIMITS.with(|s| s.borrow_mut().pop());
15797            if let Some((saved_limits, saved_current)) = saved {
15798                if let Some(lock) = crate::ported::builtins::rlimits::CURRENT_LIMITS.get() {
15799                    if let Ok(mut cur) = lock.lock() {
15800                        for (i, want) in saved_current.iter().enumerate() {
15801                            let have = match cur.get(i) {
15802                                Some(h) => *h,
15803                                None => continue,
15804                            };
15805                            // c:319-320 — `if (limits[n].rlim_max != …
15806                            //   || limits[n].rlim_cur != …)`
15807                            if have.rlim_max == want.rlim_max && have.rlim_cur == want.rlim_cur {
15808                                continue;
15809                            }
15810                            // c:321 — `setrlimit(limnum, limits + limnum)`
15811                            if unsafe { libc::setrlimit(i as _, want) } == 0 {
15812                                cur[i] = *want; // c:329
15813                            }
15814                        }
15815                    }
15816                }
15817                if let Some(lock) = crate::ported::builtins::rlimits::LIMITS.get() {
15818                    if let Ok(mut g) = lock.lock() {
15819                        *g = saved_limits;
15820                    }
15821                }
15822            }
15823        }
15824        // Replay the signals that arrived while the body ran and that
15825        // the PARENT traps, now that the parent's `sigtrapped[]` and
15826        // `traps_table` are back. See `subshell_defer_signal`.
15827        subshell_signal_leave();
15828        // c:Src/exec.c — a `( … )` subshell is a FORK in C: an errflag
15829        // abort inside the child ends the child with its lastval as
15830        // the exit status, and the flag dies with the child process.
15831        // The parent's $? picks up the status and the parent's lists
15832        // keep running. zsh 5.9: `(readonly r=1; r=2); echo "after
15833        // $?"` prints `after 1`. zshrs runs the subshell in-process,
15834        // so mirror the fork isolation by clearing ERRFLAG_ERROR at
15835        // the subshell boundary — exec.last_status() already carries
15836        // the child's lastval (synced by ERREXIT_CHECK trigger 4).
15837        //
15838        // ERRFLAG_HARD must die at this boundary too: `${u:?msg}` sets
15839        // errflag |= ERRFLAG_HARD (c:Src/subst.c:3344) and then, in a
15840        // C forked subshell, `_exit(1)` (c:3353) — the parent never
15841        // sees ANY errflag bit. A leaked HARD bit here made every
15842        // subsequent zerr() take the silent arm (c:Src/utils.c:175-177
15843        // `if (errflag || noerrs) { errflag |= ERRFLAG_ERROR; return; }`),
15844        // so the next eval/source's parse silently "failed" and the
15845        // D04 harness shell wedged after chunk 10's
15846        // `(print ${unset1:?exiting1})`.
15847        //
15848        // !!! DASH-FAMILY GATE — see dash_mode::fatal_error_status !!!
15849        // dash's `sh_error()` unwinds via `exraise(EXERROR)`, which sets
15850        // `exitstatus = 2` before `exitshell()`; the `( … )` boundary IS
15851        // one of the two places that unwind lands, so the subshell reports
15852        // 2 rather than zsh's `lastval == ERRFLAG_ERROR == 1`. Read the
15853        // flag BEFORE the clear below; the status is published at the
15854        // deferred-`exit` arm uses (run_chunk otherwise restores
15855        // `vm.last_status` over any write made here).
15856        let dash_fatal_status = {
15857            let ef = crate::ported::utils::errflag.load(std::sync::atomic::Ordering::Relaxed);
15858            let fatal = crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD;
15859            if ef & fatal != 0 {
15860                crate::extensions::dash_mode::fatal_error_status()
15861            } else {
15862                None
15863            }
15864        };
15865        crate::ported::utils::errflag.fetch_and(
15866            !(crate::ported::zsh_h::ERRFLAG_ERROR | crate::ported::zsh_h::ERRFLAG_HARD),
15867            std::sync::atomic::Ordering::Relaxed,
15868        );
15869        // c:Src/builtin.c:5834 / Src/exec.c:1443 — `retflag` dies at the
15870        // fork boundary for the same reason `errflag` above does. In C a
15871        // `return` inside `( … )` sets retflag in the CHILD; the child's
15872        // execlist unwinds, the child _exit()s, and the PARENT's retflag
15873        // was never touched. zshrs runs subshells in-process, so the flag
15874        // survived and returned from the enclosing FUNCTION:
15875        //   f() { ( return 1 ); print IN }; f
15876        // printed nothing where zsh prints `IN`. Storing 0 is exactly a
15877        // restore-to-entry: a non-zero retflag unwinds its list
15878        // immediately (c:1443's `!retflag` gate), so the parent can never
15879        // be sitting at a subshell with the flag already set. Twin of the
15880        // save/restore `$( … )` already does in vm_helper.rs (the
15881        // `saved_retflag` pair around the cmd-subst sub-VM), and of the
15882        // loops/breaks/contflag restore in SubshellSnapshot::loop_flags.
15883        crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
15884        let exit_pending =
15885            crate::ported::builtin::EXIT_PENDING.load(std::sync::atomic::Ordering::Relaxed);
15886        if exit_pending != 0 {
15887            // c:Src/builtin.c — `exit N` masks N to 8 bits because
15888            // POSIX _exit takes the low byte as status. `(exit 256)`
15889            // and `(exit 0)` are indistinguishable to the parent;
15890            // `(exit 257)` exits with 1. Without the mask zshrs's
15891            // in-process subshell propagated the full i32 (256) into
15892            // the parent's $?, diverging from zsh.
15893            let raw = crate::ported::builtin::EXIT_VAL.load(std::sync::atomic::Ordering::Relaxed);
15894            // dash's `exraise(EXERROR)` assigns `exitstatus = 2` at the
15895            // RAISE, so a fatal error wins over whatever deferred-exit
15896            // value the unwind happened to carry. Only an errflag-driven
15897            // unwind reaches this — a real `(exit 5)` never sets the flag,
15898            // so `(exit 5)` still reports 5.
15899            let val = dash_fatal_status.unwrap_or(raw & 0xFF);
15900            with_executor(|exec| exec.set_last_status(val));
15901            crate::ported::builtin::EXIT_PENDING.store(0, std::sync::atomic::Ordering::Relaxed);
15902            crate::ported::builtin::RETFLAG.store(0, std::sync::atomic::Ordering::Relaxed);
15903            crate::ported::builtin::BREAKS.store(0, std::sync::atomic::Ordering::Relaxed);
15904            // Return the deferred-exit status so the VM updates its
15905            // own `last_status`. Otherwise run_chunk's post-script
15906            // `set_last_status(vm.last_status)` would clobber LASTVAL
15907            // back to the stale pre-subshell value.
15908            return Some(val);
15909        }
15910        // Same publication path for a fatal error that did NOT arm a
15911        // deferred exit (e.g. the failed-assignment unwind).
15912        if let Some(st) = dash_fatal_status {
15913            with_executor(|exec| exec.set_last_status(st));
15914            return Some(st);
15915        }
15916        None
15917    }
15918
15919    fn redirect(&mut self, fd: u8, op: u8, target: &str) {
15920        // Apply a redirection at the OS level for the next command/builtin.
15921        // The host tracks saved fds in a per-executor stack so a future
15922        // `with_redirects_end` can restore. For now, this is a thin wrapper
15923        // that performs the dup2; pairing with explicit save/restore is
15924        // delivered by `with_redirects_begin/end`.
15925        with_executor(|exec| exec.host_apply_redirect(fd, op, target));
15926    }
15927
15928    fn with_redirects_begin(&mut self, count: u8) {
15929        with_executor(|exec| exec.host_redirect_scope_begin(count));
15930    }
15931
15932    fn regex_match(&mut self, s: &str, regex: &str) -> bool {
15933        // c:Src/Modules/regex.c:54 `zcond_regex_match` — POSIX ERE
15934        // matching + populate `$MATCH` / `$MBEGIN` / `$MEND` /
15935        // `$match[]` / `$mbegin[]` / `$mend[]` (or `$BASH_REMATCH`
15936        // under BASHREMATCH). Direct delegation to the canonical
15937        // port at src/ported/modules/regex.rs:58.
15938        //
15939        // The bridge passthru path delivers TOKEN-form bytes here
15940        // (Inbrack \u{91}, Outbrack \u{92}, Star \u{87}, Quest
15941        // \u{86}, etc.) since the lexer tokenizes regex meta chars
15942        // inside `[[ ]]`. The host regex engine expects ASCII, so
15943        // untokenize the pattern (and subject, for safety) once at
15944        // this boundary. zsh C reaches its POSIX-ERE engine through
15945        // the same untokenize path inside zcond_regex_match.
15946        let s_clean = crate::lex::untokenize(s);
15947        let regex_clean = crate::lex::untokenize(regex);
15948        // c:Src/cond.c:113-119 — WHICH engine `=~` uses is an option:
15949        //
15950        //   char *modname = isset(REMATCHPCRE) ? "zsh/pcre" : "zsh/regex";
15951        //
15952        // and the two speak different languages (POSIX ERE vs PCRE), so the
15953        // option decides whether `\d` is a digit class or a literal `d`, and
15954        // whether `(?<name>…)` compiles at all. This dispatch was missing:
15955        // `=~` always used the regex module, so `setopt rematchpcre` silently
15956        // did nothing.
15957        if crate::ported::zsh_h::isset(crate::ported::zsh_h::REMATCHPCRE) {
15958            // c:115 — "zsh/pcre" → the `-pcre-match` cond.
15959            crate::ported::modules::pcre::cond_pcre_match(
15960                &[s_clean, regex_clean],
15961                crate::ported::modules::pcre::CPCRE_PLAIN,
15962            ) != 0
15963        } else {
15964            // c:115 — "zsh/regex" → the `-regex-match` cond.
15965            crate::ported::modules::regex::zcond_regex_match(
15966                &[s_clean.as_str(), regex_clean.as_str()],
15967                crate::ported::modules::regex::ZREGEX_EXTENDED,
15968            ) != 0
15969        }
15970    }
15971
15972    fn with_redirects_end(&mut self) {
15973        with_executor(|exec| exec.host_redirect_scope_end());
15974        // c:Src/exec.c:5172 — if any redirect in this scope failed
15975        // (noclobber-blocked, ENOENT for read, etc.), the command's
15976        // exit status is forced to 1 regardless of what the (still-
15977        // executed) command's own exit was. C zsh prevents the
15978        // command from running at all when a redirect fails; the
15979        // Rust port still runs it (sinking output to /dev/null in
15980        // the noclobber arm at host_apply_redirect:5481) and then
15981        // overrides $? here. Same observable effect for the common
15982        // pattern `echo x > existing-file` under noclobber.
15983        let failed = with_executor(|exec| {
15984            let f = exec.redirect_failed;
15985            exec.redirect_failed = false;
15986            f
15987        });
15988        if failed {
15989            with_executor(|exec| exec.set_last_status(1));
15990        }
15991    }
15992
15993    fn heredoc(&mut self, content: &str) {
15994        // C `Src/exec.c:4641` — `parsestr(&buf)` runs parameter +
15995        // command substitution on the heredoc body. The lexer's
15996        // quoted-delimiter detection (`<<'EOF'`) routes through the
15997        // `Op::HereDoc` path in `compile_zsh.rs` which short-circuits
15998        // before reaching here; unquoted forms route through the
15999        // BUILTIN_EXPAND_TEXT mode-4 emit path that calls singsub.
16000        // This handler covers the verbatim/quoted case.
16001        if crate::provenance::active() {
16002            crate::provenance::on_heredoc("heredoc", content);
16003        }
16004        with_executor(|exec| exec.host_set_pending_stdin(content.to_string()));
16005    }
16006
16007    fn herestring(&mut self, content: &str) {
16008        // Shell semantics: herestring appends a newline. `<<<` body
16009        // substitution (`Src/exec.c:4655 getherestr` calls
16010        // `quotesubst` + `untokenize`) lands here verbatim; the
16011        // upstream compiler routes through `Op::HereString` after
16012        // BUILTIN_EXPAND_TEXT for the substitution pass, so callers
16013        // of `host.herestring` see the already-expanded form.
16014        let mut s = content.to_string();
16015        s.push('\n');
16016        if crate::provenance::active() {
16017            crate::provenance::on_heredoc("herestring", &s);
16018        }
16019        with_executor(|exec| exec.host_set_pending_stdin(s));
16020    }
16021
16022    fn exec(&mut self, args: Vec<String>) -> i32 {
16023        // c:Src/exec.c getproc + Src/jobs.c deletefilelist — close
16024        // any `>(cmd)` write ends owned by this command once it
16025        // finishes (drops on every return path below).
16026        let _psub_fds = PsubFdGuard;
16027        // c:Src/subst.c paramsubst — when `${var:?msg}` or `${var?msg}`
16028        // triggered the "parameter null or not set" error, errflag
16029        // is raised and zsh aborts the simple command without
16030        // attempting exec. The expansion may have produced empty
16031        // argv[0] which falls into the c:?/permission-denied path
16032        // below, masking the real diagnostic with a spurious
16033        // "permission denied:" line and rc=126 instead of rc=1.
16034        // Honour errflag here so the script ends with the
16035        // paramsubst error as the sole diagnostic. Bug #86.
16036        //
16037        // c:Src/exec.c — C's execlist loop clears ERRFLAG_ERROR
16038        // between sublists when the error came from a NOMATCH-style
16039        // command failure (glob no-match, etc.) so subsequent
16040        // sublists run. zshrs's vm dispatch handles this at the
16041        // post-command-boundary HERE: if THIS command has its
16042        // `current_command_glob_failed` cell set (meaning the glob
16043        // NOMATCH happened during this command's argv prep), surface
16044        // status 1 and clear BOTH the cell AND ERRFLAG_ERROR so the
16045        // NEXT exec call sees a clean state. The errflag from
16046        // genuine script-fatal errors (parse, redirect, paramsubst
16047        // `${:?msg}`) does NOT come paired with glob_failed, so
16048        // those still short-circuit + propagate.
16049        consume_tilde_globsubst_carrier();
16050        let glob_failed = with_executor(|exec| {
16051            let f = exec.current_command_glob_failed.get();
16052            exec.current_command_glob_failed.set(false);
16053            f
16054        });
16055        if glob_failed {
16056            crate::ported::utils::errflag.fetch_and(
16057                !crate::ported::zsh_h::ERRFLAG_ERROR,
16058                std::sync::atomic::Ordering::Relaxed,
16059            );
16060            with_executor(|exec| exec.set_last_status(1));
16061            return 1;
16062        }
16063        // c:Src/subst.c:505-507 — CSH_NULL_GLOB external-path
16064        // boundary: command skipped with `no match` but the NEXT
16065        // sublist runs (zsh -fc 'setopt cshnullglob; ls *nope*;
16066        // print after' prints the error then `after` — verified
16067        // zsh 5.9.1), so clear ERRFLAG like the glob_failed arm.
16068        if consume_badcshglob() {
16069            crate::ported::utils::errflag.fetch_and(
16070                !crate::ported::zsh_h::ERRFLAG_ERROR,
16071                std::sync::atomic::Ordering::Relaxed,
16072            );
16073            with_executor(|exec| exec.set_last_status(1));
16074            return 1;
16075        }
16076        if (crate::ported::utils::errflag.load(std::sync::atomic::Ordering::SeqCst)
16077            & crate::ported::zsh_h::ERRFLAG_ERROR)
16078            != 0
16079        {
16080            return 1;
16081        }
16082        // c:Src/exec.c — two distinct empty-command cases:
16083        //
16084        // 1. args=[""]  — an explicit empty-string command word
16085        //    (`""`, `"\$unset"`, `\$'\$x'`). zsh attempts exec(2)
16086        //    on the empty path → EACCES → "permission denied", \$?
16087        //    = 126.
16088        //
16089        // 2. args=[]    — the WORD LIST is empty (unquoted \$(\$cmd)
16090        //    that produced empty, or an unquoted unset \$var that
16091        //    elided). zsh: no exec is attempted; \$? becomes the
16092        //    last cmd-subst's exit status (the inner sub-VM
16093        //    already set last_status), and the line completes
16094        //    silently. Critically NOT 126.
16095        if args.is_empty() {
16096            // c:Src/exec.c — empty word list passes through to a
16097            // no-op; preserve whatever the inner cmd-subst's exit
16098            // is. Return last_status so the caller's SetStatus
16099            // round-trips correctly.
16100            return with_executor(|exec| exec.last_status());
16101        }
16102        if args[0].is_empty() {
16103            let script_name =
16104                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
16105            let lineno: u64 = with_executor(|exec| {
16106                exec.scalar("LINENO")
16107                    .and_then(|s| s.parse::<u64>().ok())
16108                    .unwrap_or(1)
16109            });
16110            eprintln!("{}:{}: permission denied: ", script_name, lineno);
16111            return 126;
16112        }
16113        // c:Src/exec.c — when any redirect in the current scope
16114        // failed (e.g. noclobber blocked a `>` overwrite), zsh
16115        // refuses to execute the command and exits with status 1.
16116        // The Rust port still applied the command (writing to the
16117        // /dev/null sink installed by host_apply_redirect's
16118        // noclobber arm), but the success status overwrote the
16119        // intended `1`. Short-circuit here so the exec returns 1
16120        // without running the body.
16121        let redir_failed = with_executor(|exec| {
16122            let f = exec.redirect_failed;
16123            exec.redirect_failed = false;
16124            f
16125        });
16126        if redir_failed {
16127            return 1;
16128        }
16129        // c:Src/exec.c:3545-3547 — `setunderscore(lastnode(args))` for the
16130        // command about to run. Write the canonical `zunderscore` global,
16131        // NOT the paramtab node: `_`'s setfn is `nullstrsetfn`
16132        // (c:Src/params.c:252-253), so a table write has no counterpart in
16133        // C and clobbers the PM_UNSET bit that `unset _` relies on.
16134        if let Some(last) = args.last() {
16135            crate::ported::params::set_zunderscore(std::slice::from_ref(last)); // c:3546
16136        }
16137        // Provenance: record which argv slot each tracked value landed
16138        // in, before the command consumes it.
16139        if crate::provenance::active() {
16140            crate::provenance::on_exec("exec", &args);
16141        }
16142        // Route external command spawning through `executor.execute_external`
16143        // so intercepts (AOP before/after/around), command_hash lookups,
16144        // pre/postexec hooks, and zsh-specific fork-then-exec all apply.
16145        // Without this override, fusevm's default `host.exec` calls
16146        // `Command::new` directly, bypassing zshrs's dispatch logic.
16147        let status = with_executor(|exec| exec.host_exec_external(&args));
16148        // c:Src/jobs.c:1748 waitonejob (no-procs else-branch). zshrs's
16149        // exec model routes external commands through host_exec_external
16150        // (which already waitpid'd in-line); the canonical waitonejob
16151        // expects a Job to derive lastval, but here we already know
16152        // it. Synthesize a procs-less job so waitonejob's no-procs
16153        // branch fires the `pipestats[0]=lastval; numpipestats=1;`
16154        // update via the canonical port.
16155        crate::ported::builtin::LASTVAL.store(status, std::sync::atomic::Ordering::Relaxed);
16156        let mut synth = crate::ported::zsh_h::job::default();
16157        crate::ported::jobs::waitonejob(&mut synth);
16158        status
16159    }
16160
16161    fn cmd_subst(&mut self, sub: &fusevm::Chunk) -> String {
16162        // Run the sub-chunk on a nested VM with the same host wired up,
16163        // capturing stdout. The current executor remains active via the
16164        // thread-local — the nested VM uses CallBuiltin to dispatch shell
16165        // ops back through `with_executor`.
16166        let (read_end, write_end) = match os_pipe::pipe() {
16167            Ok(p) => p,
16168            Err(_) => return String::new(),
16169        };
16170        let saved_stdout = unsafe { libc::fcntl(libc::STDOUT_FILENO, libc::F_DUPFD, 10) };
16171        if saved_stdout < 0 {
16172            return String::new();
16173        }
16174        let saved_stderr = unsafe { libc::fcntl(libc::STDERR_FILENO, libc::F_DUPFD, 10) };
16175        let write_fd = AsRawFd::as_raw_fd(&write_end);
16176        unsafe {
16177            libc::dup2(write_fd, libc::STDOUT_FILENO);
16178        }
16179        drop(write_end);
16180
16181        // c:Bug #56 — publish the saved outer fds so a trap firing
16182        // during the nested VM run can route its body output to the
16183        // PARENT's stdout instead of the cmdsub's pipe-bound fd 1.
16184        // zsh's forked cmdsub gets this for free (trap runs in the
16185        // parent process whose fd 1 is untouched). zshrs's
16186        // in-process cmdsub needs this thread-local stack so the
16187        // trap dispatcher can find the right destination fd.
16188        CMDSUBST_OUTER_FDS.with(|s| s.borrow_mut().push((saved_stdout, saved_stderr)));
16189
16190        // Nested scope for `>(cmd)` fd ownership — commands inside
16191        // the cmdsub must not drain the enclosing command's pending
16192        // psub fds (see PSUB_SCOPE_DEPTH).
16193        let _psub_scope = PsubScope::enter();
16194
16195        // c:Src/exec.c:1161 — forked cmdsub child runs entersubsh()
16196        // which does `zsh_subshell++`; in-process equivalent.
16197        let _subshell_bump = CmdSubstSubshellBump::enter();
16198
16199        crate::fusevm_disasm::maybe_print_stdout("host.cmd_subst", sub);
16200        let mut vm = fusevm::VM::new(sub.clone());
16201        register_builtins(&mut vm);
16202        vm.set_shell_host(Box::new(ZshrsHost));
16203        let _ = vm.run();
16204        let cmd_status = vm.last_status;
16205
16206        CMDSUBST_OUTER_FDS.with(|s| {
16207            s.borrow_mut().pop();
16208        });
16209
16210        unsafe {
16211            libc::dup2(saved_stdout, libc::STDOUT_FILENO);
16212            libc::close(saved_stdout);
16213            if saved_stderr >= 0 {
16214                libc::close(saved_stderr);
16215            }
16216        }
16217
16218        // Inner cmd's status not propagated for the same reason as
16219        // run_command_substitution — see GAPS.md.
16220        let _ = cmd_status;
16221
16222        let mut buf = String::new();
16223        let mut reader = read_end;
16224        let _ = reader.read_to_string(&mut buf);
16225        // Strip trailing newlines (POSIX command substitution semantics)
16226        while buf.ends_with('\n') {
16227            buf.pop();
16228        }
16229        // Provenance: a command substitution is a lineage ORIGIN — the
16230        // bytes did not exist in the shell before this ran.
16231        if crate::provenance::active() {
16232            crate::provenance::on_cmd_subst(&prov_chunk_label(sub), &buf);
16233        }
16234        buf
16235    }
16236
16237    fn call_function(&mut self, name: &str, args: Vec<String>) -> Option<i32> {
16238        // c:Src/exec.c — when the command word is empty (e.g. `""`
16239        // or `"$nonexistent"`), zsh attempts the exec(2) which
16240        // returns EACCES ("permission denied") and exits 126. The
16241        // Rust port silently treated empty as a no-op (status 0).
16242        // Match zsh by emitting the diagnostic and returning 126.
16243        if name.is_empty() {
16244            let script_name =
16245                crate::ported::utils::scriptname_get().unwrap_or_else(|| "zshrs".to_string());
16246            let lineno: u64 = with_executor(|exec| {
16247                exec.scalar("LINENO")
16248                    .and_then(|s| s.parse::<u64>().ok())
16249                    .unwrap_or(1)
16250            });
16251            eprintln!("{}:{}: permission denied: ", script_name, lineno);
16252            with_executor(|exec| exec.set_last_status(126));
16253            return Some(126);
16254        }
16255        // c:Src/exec.c — redirect failure in this scope means the
16256        // command should NOT run. The Host::exec path already has
16257        // this gate (at fn exec above); call_function takes external
16258        // commands like `cat <&3` through a different code path, so
16259        // gate here too. Without this, bad-fd redirects produced
16260        // the diagnostic but the external command still ran, so $?
16261        // came out from the command's natural exit instead of the
16262        // forced 1.
16263        let redir_failed = with_executor(|exec| {
16264            let f = exec.redirect_failed;
16265            exec.redirect_failed = false;
16266            f
16267        });
16268        if redir_failed {
16269            with_executor(|exec| exec.set_last_status(1));
16270            return Some(1);
16271        }
16272        // Provenance: same argv record as `exec`, but ONLY when the name
16273        // really resolves to a shell function — an external command
16274        // reaches `exec` further down and would otherwise be recorded
16275        // twice for the same call site.
16276        if crate::provenance::active() && with_executor(|exec| exec.function_exists(name)) {
16277            let mut argv = Vec::with_capacity(args.len() + 1);
16278            argv.push(name.to_string());
16279            argv.extend(args.iter().cloned());
16280            crate::provenance::on_exec("call", &argv);
16281        }
16282        // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
16283        // functions, NOT builtins. zshrs ships fast native impls, but they
16284        // must behave like the zsh functions — command-not-found until
16285        // `autoload -Uz <name>` creates a function entry. When autoloaded we
16286        // run the native impl here (short-circuiting the fpath source, which
16287        // can hang zshrs's parser on zsh-specific syntax); when NOT autoloaded
16288        // we fall through (return None → resolution ends in command-not-found),
16289        // matching `zsh -f; zmv` → "command not found: zmv".
16290        if matches!(name, "zmv" | "zcp" | "zln" | "zcalc")
16291            && !with_executor(|exec| exec.function_exists(name))
16292        {
16293            return None;
16294        }
16295        match name {
16296            "zmv" => {
16297                return Some(crate::extensions::ext_builtins::zmv(&args, "mv"));
16298            }
16299            "zcp" => {
16300                return Some(crate::extensions::ext_builtins::zmv(&args, "cp"));
16301            }
16302            "zln" => {
16303                return Some(crate::extensions::ext_builtins::zmv(&args, "ln"));
16304            }
16305            "zcalc" => {
16306                return Some(crate::extensions::ext_builtins::zcalc(&args));
16307            }
16308            // znative — the plugin package manager (src/extensions/pkg/). Installs
16309            // + loads zsh script and native (Rust cdylib) plugins from a global
16310            // content-addressed store. `znative add owner/repo`, `znative load`, ...
16311            "znative" => {
16312                return Some(crate::extensions::pkg::builtin::znative(&args));
16313            }
16314            // ztest framework (src/extensions/ztest.rs — port of
16315            // ../strykelang's unit-test framework). All zassert_*/
16316            // ztest_* names route through the single try_dispatch
16317            // helper so adding/removing assertions only touches
16318            // ztest.rs.
16319            n if crate::extensions::ztest::try_dispatch_known(n) => {
16320                let status = with_executor(|exec| {
16321                    crate::extensions::ztest::try_dispatch(exec, n, &args).unwrap_or(1)
16322                });
16323                return Some(status);
16324            }
16325            // Daemon-managed z* builtins — thin IPC wrappers. Short-circuit BEFORE
16326            // the function-lookup path so a missing daemon doesn't fall through to
16327            // "command not found". The name list is owned by the daemon crate
16328            // (zshrs_daemon::builtins::ZSHRS_BUILTIN_NAMES); routing through
16329            // try_dispatch keeps this site zero-touch as new z* builtins land.
16330            n if crate::daemon::builtins::is_zshrs_builtin(n) => {
16331                let argv: Vec<String> = std::iter::once(name.to_string()).chain(args).collect();
16332                return Some(crate::daemon::builtins::try_dispatch(n, &argv).unwrap_or(1));
16333            }
16334            _ => {}
16335        }
16336
16337        // c:Src/exec.c:3050-3068 — module-provided builtins (registered
16338        // via each module's `bintab` and folded into the canonical
16339        // `builtintab` by `createbuiltintable`) must dispatch BEFORE
16340        // PATH lookup. fusevm's `shell_builtins::builtin_id` doesn't
16341        // know about per-module entries like `log`
16342        // (Src/Modules/watch.c:693) — they reach call_function as
16343        // CallFunction ops. Consult the merged builtintab here so
16344        // `log` runs the canonical `bin_log` instead of falling
16345        // through to `/usr/bin/log` on macOS. Bug #72 in docs/BUGS.md.
16346        //
16347        // User-defined functions still take precedence over builtins
16348        // (zsh's `alias → function → builtin → external` resolution
16349        // order, c:Src/exec.c:3038-3068). Check `functions_compiled`
16350        // first so a user `log() { ... }` shadows the module bin_log.
16351        // c:Src/exec.c — shfunctab->getnode (the DISABLED-filtering
16352        // accessor) returns NULL for entries flipped to DISABLED via
16353        // `disable -f NAME`. functions_compiled holds the body
16354        // independently of the DISABLED flag, so check shfunctab first
16355        // and mask the lookup when the entry is disabled. Bug #221
16356        // in docs/BUGS.md.
16357        let user_fn_disabled = crate::ported::hashtable::shfunctab_lock()
16358            .read()
16359            .ok()
16360            .and_then(|t| {
16361                let entry = t.get_including_disabled(name)?;
16362                Some((entry.node.flags as u32 & crate::ported::zsh_h::DISABLED as u32) != 0)
16363            })
16364            .unwrap_or(false);
16365        let has_user_fn =
16366            !user_fn_disabled && with_executor(|exec| exec.functions_compiled.contains_key(name));
16367        if !has_user_fn {
16368            // c:Src/exec.c:3056 — `builtintab->getnode(builtintab,
16369            // cmdarg)` returns NULL for DISABLED entries, falling
16370            // execcmd through to PATH lookup. Mirror by gating the
16371            // bn_in_tab match on the BUILTINS_DISABLED set. Bug #106
16372            // in docs/BUGS.md.
16373            let disabled = crate::ported::builtin::BUILTINS_DISABLED
16374                .lock()
16375                .map(|s| s.contains(name))
16376                .unwrap_or(false);
16377            let bn_in_tab =
16378                !disabled && crate::ported::builtin::createbuiltintable().contains_key(name);
16379            if bn_in_tab {
16380                // c:Src/exec.c:4287 — `lastval = execbuiltin(args, assigns,
16381                // (Builtin) hn);`. The store happens BEFORE any errflag
16382                // handling, so a builtin that BOTH raises errflag (zerr) and
16383                // returns non-zero still publishes its status. zshrs relied on
16384                // the VM's trailing `SetStatus` op to publish it, and that op
16385                // is skipped once the builtin set ERRFLAG_ERROR — so
16386                // `() { private SECONDS }` (makeprivate's zerrnam + return 1)
16387                // reported 0 where zsh reports 1 (V10private.ztst:22).
16388                let __st = dispatch_builtin_raw(name, args);
16389                crate::ported::builtin::LASTVAL.store(__st, std::sync::atomic::Ordering::Relaxed); // c:4287
16390                with_executor(|exec| exec.set_last_status(__st)); // c:4287
16391                return Some(__st);
16392            }
16393            // zshrs-original opcode builtins (async, doctor, peach, …) are not
16394            // in builtintab, so a run-time-resolved name (`$var`) never reaches
16395            // them. Dispatch by name here — after ported builtins, before
16396            // external — matching the shell's function -> builtin -> external
16397            // order (`has_user_fn` was checked above, so functions still win).
16398            if let Some(status) = try_run_registered_builtin(name, &args) {
16399                return Some(status);
16400            }
16401        }
16402
16403        // c:Src/lex.c — alias expansion is a LEXER-TIME pass, not a
16404        // run-time lookup. zsh parses the whole `-c` argument (or
16405        // script) before executing, so aliases defined in the same
16406        // parse unit don't apply to commands parsed earlier. Only at
16407        // an INTERACTIVE prompt does each line parse separately with
16408        // the latest aliastab visible.
16409        //
16410        // Gate the run-time alias-rewrite path on `interactive` so
16411        // `alias hi='echo hello'; hi` in `-c` mode falls through to
16412        // "command not found" (matching zsh) while interactive REPL
16413        // input still re-parses with the live aliastab.
16414        let interactive = crate::ported::zsh_h::isset(crate::ported::zsh_h::INTERACTIVE);
16415        let already_expanding = if interactive {
16416            crate::ported::hashtable::aliastab_lock()
16417                .read()
16418                .ok()
16419                .and_then(|tab| tab.get(name).map(|a| a.inuse != 0))
16420                .unwrap_or(false)
16421        } else {
16422            true // suppress lookup entirely in non-interactive mode
16423        };
16424        let alias_body = if already_expanding {
16425            None
16426        } else {
16427            with_executor(|exec| exec.alias(name))
16428        };
16429        if let Some(body) = alias_body {
16430            let combined = if args.is_empty() {
16431                body
16432            } else {
16433                let quoted: Vec<String> = args
16434                    .iter()
16435                    .map(|a| {
16436                        let escaped = a.replace('\'', "'\\''");
16437                        format!("'{}'", escaped)
16438                    })
16439                    .collect();
16440                format!("{} {}", body, quoted.join(" "))
16441            };
16442            // Bump inuse → run → clear, matching C's lexer behavior.
16443            if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
16444                if let Some(a) = tab.get_mut(name) {
16445                    a.inuse += 1;
16446                }
16447            }
16448            let status = with_executor(|exec| exec.execute_script(&combined).unwrap_or(1));
16449            if let Ok(mut tab) = crate::ported::hashtable::aliastab_lock().write() {
16450                if let Some(a) = tab.get_mut(name) {
16451                    a.inuse = (a.inuse - 1).max(0);
16452                }
16453            }
16454            return Some(status);
16455        }
16456
16457        // $_ pre-body bump and pending-underscore tracking are
16458        // ZshrsHost-only concerns (prompt rendering). Apply BEFORE
16459        // delegating to dispatch_function_call so the body sees the
16460        // bumped value.
16461        //
16462        // c:Src/exec.c:3491 — `setunderscore((args && nonempty(args))
16463        // ? ((char *) getdata(lastnode(args))) : "")`. C sets $_ to
16464        // the LAST node of the WHOLE args list (which includes argv[0]
16465        // == the function name). So for a no-arg `f`, $_ becomes "f"
16466        // inside the function body. The Rust port at the CallFunction
16467        // op-handler receives `args` WITHOUT the command name
16468        // (compile_zsh.rs:1571 only pushes simple.words[1..]). The
16469        // last() fallback `|| fn_name.clone()` already covers the
16470        // no-arg case, but `exec.set_scalar("_", ...)` writes paramtab
16471        // — the canonical `$_` read goes through `underscoregetfn`
16472        // (params.rs:7836) which reads the `zunderscore` Mutex.
16473        // setsparam("_") doesn't update that mutex, so the body's
16474        // `${_}` returned empty. Bug #279 in docs/BUGS.md. Mirror the
16475        // C `setunderscore` by writing via `set_zunderscore` directly.
16476        let fn_name = name.to_string();
16477        {
16478            let dollar_underscore = args.last().cloned().unwrap_or_else(|| fn_name.clone());
16479            // c:3546 — zunderscore is the only store; the paramtab write
16480            // that used to accompany this cleared PM_UNSET (see pop_args).
16481            crate::ported::params::set_zunderscore(std::slice::from_ref(&dollar_underscore));
16482        }
16483
16484        // Delegate the actual function dispatch to the canonical
16485        // `dispatch_function_call` (which itself wraps the canonical
16486        // `doshfunc` port from `Src/exec.c:5823`). Single doshfunc
16487        // call-site keeps scope-mgmt invariants in one place.
16488        let status = with_executor(|exec| exec.dispatch_function_call(&fn_name, &args));
16489
16490        // Anonymous functions (`() { … } args`, compiled by
16491        // parse_anon_funcdef as `_zshrs_anon_N` / `_zshrs_anon_kw_N`)
16492        // execute exactly ONCE and must not persist. zsh runs the body and
16493        // frees the function, so `${functions}` / `typeset -f` never show
16494        // it. Remove every trace right after the single invocation —
16495        // AFTER `status` is captured, so the body's exit code is preserved
16496        // ($? — calling `unfunction` here would reset it to 0 instead).
16497        // Without this, real plugins that use `() { … }` (fzf-tab, zinit,
16498        // p10k, …) leaked dozens of `_zshrs_anon_N` into `$functions`,
16499        // diverging from zsh's function table on every such config.
16500        if fn_name.starts_with("_zshrs_anon_") {
16501            // `${functions}` / `typeset -f` enumerate the canonical
16502            // `shfunctab` (via scanpmfunctions); the bytecode call path
16503            // also keeps the body in the executor's compiled-fn maps. Clear
16504            // BOTH so no trace of the one-shot anon survives.
16505            crate::ported::hashtable::removeshfuncnode(&fn_name);
16506            with_executor(|exec| {
16507                exec.functions_compiled.remove(&fn_name);
16508                exec.function_source.remove(&fn_name);
16509                exec.function_line_base.remove(&fn_name);
16510                exec.function_def_file.remove(&fn_name);
16511            });
16512        }
16513
16514        // c:Src/exec.c:6207-6265 — doshfunc saves `ou = zunderscore`
16515        // around the body and runs `setunderscore(ou)` (c:6257) on the way
16516        // out, so a function call leaves `$_` at the CALL's last argument
16517        // rather than at whatever the body's last command set. The value
16518        // saved there is the one execcmd_exec installed just before the
16519        // call (c:3546), i.e. exactly `args.last()`.
16520        {
16521            let last_call_arg = args.last().cloned().unwrap_or_else(|| fn_name.clone());
16522            crate::ported::params::set_zunderscore(std::slice::from_ref(&last_call_arg));
16523            // c:6257
16524        }
16525
16526        status
16527    }
16528}
16529
16530// ───────────────────────────────────────────────────────────────────────────
16531/// Render a failed-redirect open error the way C's `zerrmsg` `%e` format
16532/// code does (Src/utils.c): `strerror(errno)` with the first character
16533/// lowercased, except `EIO` (kept capitalized) and `EINTR` (→ "interrupt").
16534/// C's redirect open failures call `zwarn("%e: %s", errno, fname)`
16535/// (Src/exec.c:3741); zshrs's `zwarning` takes a pre-built string, so the
16536/// `%e` part is built here. Replaces the prior hardcoded `ErrorKind` match
16537/// that fell back to a generic "redirect failed" for `EROFS`/`EACCES`/etc.
16538fn redir_errno_msg(err: &std::io::Error) -> String {
16539    let errno = match err.raw_os_error() {
16540        Some(n) if n != 0 => n,
16541        _ => return "redirect failed".to_string(),
16542    };
16543    if errno == libc::EINTR {
16544        return "interrupt".to_string(); // c:zerrmsg %e — EINTR special-case
16545    }
16546    let cptr = unsafe { libc::strerror(errno) };
16547    if cptr.is_null() {
16548        return "redirect failed".to_string();
16549    }
16550    let msg = unsafe { std::ffi::CStr::from_ptr(cptr) }.to_string_lossy();
16551    if errno == libc::EIO {
16552        return msg.into_owned(); // c:zerrmsg %e — EIO keeps capitalization
16553    }
16554    // c:zerrmsg %e — `fputc(tulower(errmsg[0])); fputs(errmsg + 1)`.
16555    let mut chars = msg.chars();
16556    match chars.next() {
16557        Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
16558        None => "redirect failed".to_string(),
16559    }
16560}
16561
16562// Host-routed shell ops: ShellExecutor methods invoked by ZshrsHost from the
16563// fusevm VM. Not a port of Src/exec.c (see file-level docs above) — they're
16564// the bridge between fusevm opcodes and ShellExecutor state.
16565// ───────────────────────────────────────────────────────────────────────────
16566impl ShellExecutor {
16567    // ─── Host-routed shell ops (called by ZshrsHost from fusevm) ────────────
16568
16569    /// Apply a single redirection. The current scope's saved-fd vec gets a
16570    /// dup of the original fd so it can be restored by `host_redirect_scope_end`.
16571    /// `op_byte` matches `fusevm::op::redirect_op::*`.
16572    /// Park the CURRENT contents of `fd` in the enclosing redirect
16573    /// scope so `host_redirect_scope_end` can restore them.
16574    ///
16575    /// c:Src/exec.c:2421-2443 — `addfd`'s "starting a new multio" arm:
16576    ///
16577    /// ```c
16578    /// if (!forked && save[fd1] == -2) {
16579    ///     if (fd1 == fd2) save[fd1] = -1;
16580    ///     else {
16581    ///         int fdN = movefd(fd1);
16582    ///         /* fd1 may already be closed here, so
16583    ///          * ignore bad file descriptor error */
16584    ///         if (fdN < 0) { if (errno != EBADF) { … } }
16585    ///         …
16586    ///         save[fd1] = fdN;
16587    ///     }
16588    /// }
16589    /// ```
16590    ///
16591    /// A `save[]` slot of -1 therefore means "fd1 was CLOSED before
16592    /// this redirection", and `fixfds` (c:4522-4532) feeds it to
16593    /// `redup(-1, i)`, whose first arm is `zclose(y)` (c:Src/utils.c:
16594    /// 2047-2048). So zsh CLOSES the fd again at teardown rather than
16595    /// leaving the redirection behind — `print x 3<file; cat <&3`
16596    /// reports "3: bad file descriptor" in zsh.
16597    ///
16598    /// c:Src/exec.c:3978-3986 — a bare `exec` (nullexec==1)
16599    /// "specifically *doesn't* restore the original fd's", so nothing
16600    /// is parked for it.
16601    pub fn save_fd_for_scope(&mut self, fd: i32) {
16602        if self.exec_redirs_permanent {
16603            return;
16604        }
16605        // c:2425 `movefd(fd1)` — zshrs keeps the original fd open and
16606        // dups it aside instead (the caller's dup2 overwrites it), so
16607        // F_DUPFD stands in for movefd's dup-then-close.
16608        let saved = unsafe { libc::fcntl(fd, libc::F_DUPFD, 10) };
16609        // c:2422-2423 / c:2430-2436 — a closed fd1 parks -1, not a dup.
16610        let slot = if saved >= 0 { saved } else { -1 };
16611        if let Some(top) = self.redirect_scope_stack.last_mut() {
16612            top.push((fd, slot));
16613        } else if saved >= 0 {
16614            // No scope — leave saved fd open and let the next scope
16615            // reclaim it. (Caller without a scope leaks the dup; this
16616            // matches `WithRedirects` parser construction always wrapping.)
16617            unsafe { libc::close(saved) };
16618        }
16619    }
16620
16621    /// Apply a file-open result to a redirect fd; on error, emit
16622    /// zsh-format diagnostic, set redirect_failed, sink fd to /dev/null.
16623    /// Shared between WRITE/APPEND/READ/CLOBBER arms in
16624    /// host_apply_redirect to keep the error-handling identical.
16625    fn redir_open_or_fail(
16626        fd: i32,
16627        result: std::io::Result<fs::File>,
16628        target: &str,
16629        redirect_failed: &mut bool,
16630    ) -> bool {
16631        match result {
16632            Ok(file) => {
16633                let new_fd = file.into_raw_fd();
16634                unsafe {
16635                    // When the target fd was already closed (e.g. `exec 0<&-;
16636                    // cmd < file`), open() returns the lowest free fd, which is
16637                    // `fd` itself. Then `dup2(fd, fd)` is a no-op: closing new_fd
16638                    // would CLOSE the fd we just opened, AND — since Rust's
16639                    // File::open sets O_CLOEXEC and a no-op dup2 does NOT clear
16640                    // it — an exec'd child would lose the descriptor. So in the
16641                    // reuse case, keep the fd and clear its close-on-exec flag;
16642                    // otherwise dup2 (which clears cloexec on the copy) + close.
16643                    if new_fd != fd {
16644                        libc::dup2(new_fd, fd);
16645                        libc::close(new_fd);
16646                    } else {
16647                        libc::fcntl(fd, libc::F_SETFD, 0);
16648                    }
16649                }
16650                true
16651            }
16652            Err(e) => {
16653                // c:Src/exec.c:3741 — zwarn("%e: %s", errno, fname) with the
16654                // real lineno prefix; redir_errno_msg builds the `%e` errno
16655                // message for all errnos (not just the few hardcoded before).
16656                let msg = redir_errno_msg(&e);
16657                crate::ported::utils::zwarn(&format!("{}: {}", msg, target));
16658                *redirect_failed = true;
16659                // The /dev/null sink keeps a failed scoped redirect
16660                // from leaking the aborted command's output to the
16661                // wrong fd until scope-end restores it. For a bare
16662                // `exec` redirect (permanent, no scope restore) C
16663                // leaves the fd UNTOUCHED — execerr() aborts the
16664                // statement and the original fd 1 keeps flowing
16665                // (A04redirect: `exec >./nonexistent/x` then `echo
16666                // output` still prints). c:Src/exec.c:3735-3742.
16667                let permanent = with_executor(|exec| exec.exec_redirs_permanent);
16668                if !permanent {
16669                    if let Ok(devnull) = fs::OpenOptions::new()
16670                        .read(true)
16671                        .write(true)
16672                        .open("/dev/null")
16673                    {
16674                        let new_fd = devnull.into_raw_fd();
16675                        unsafe {
16676                            if new_fd != fd {
16677                                libc::dup2(new_fd, fd);
16678                                libc::close(new_fd);
16679                            } else {
16680                                libc::fcntl(fd, libc::F_SETFD, 0);
16681                            }
16682                        }
16683                    }
16684                }
16685                false
16686            }
16687        }
16688    }
16689    /// `host_apply_redirect` — see implementation.
16690    pub fn host_apply_redirect(&mut self, fd: u8, op_byte: u8, target: &str) {
16691        // `&>` / `&>>` always target both fd 1 and fd 2 regardless of the
16692        // fd byte the parser supplied (the lexer's tokfd clamp makes the
16693        // raw value unreliable for these forms).
16694        let fd: i32 = if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
16695            1
16696        } else {
16697            fd as i32
16698        };
16699        // c:Src/exec.c — for DUP_READ / DUP_WRITE forms (<&N / >&N),
16700        // validate the source fd is open BEFORE the save-and-dup
16701        // dance below. The save's `dup(fd)` reclaims the lowest free
16702        // fd, which on closed-fd reuse would let dup2(src=N, …)
16703        // succeed against the freshly-claimed slot — masking the
16704        // user's "bad file descriptor" error. Check src_fd first.
16705        if matches!(op_byte, r::DUP_READ | r::DUP_WRITE) {
16706            let n_check = target.trim_start_matches('&');
16707            if n_check != "-" {
16708                if let Ok(src_fd) = n_check.parse::<i32>() {
16709                    // c:Src/exec.c:3884-3897 — a descriptor above 9 that
16710                    // the shell knows about is NOT the script's to
16711                    // duplicate:
16712                    //
16713                    //   else if (fn->fd2 > 9 &&
16714                    //            (fn->fd2 <= max_zsh_fd &&
16715                    //             ((fdtable[fn->fd2] != FDT_UNUSED &&
16716                    //               fdtable[fn->fd2] != FDT_EXTERNAL) ||
16717                    //              fn->fd2 == coprocin ||
16718                    //              fn->fd2 == coprocout))) {
16719                    //       fil = -1;
16720                    //       errno = EBADF;
16721                    //
16722                    // `FDT_EXTERNAL` is exempt because that is a
16723                    // descriptor the script itself asked for (`{v}>file`,
16724                    // c:2409) and 0/1/2 (c:Src/init.c:1900). Anything
16725                    // past `max_zsh_fd` is left alone on purpose —
16726                    // c:3886-3891: "the shell doesn't know about it. Just
16727                    // assume the user knows what they're doing."
16728                    //
16729                    // Only the open-ness of the descriptor was checked
16730                    // here, so `>&11` happily duplicated the shell's own
16731                    // history database and `>&10` its log. The `exec
16732                    // N>&-` half of this pair was already ported
16733                    // (fusevm_bridge.rs:7047, c:3830-3835); this half was
16734                    // not, and the ported copy in `ported/exec.rs:11466`
16735                    // is not on the VM's redirection path.
16736                    let shell_owned = src_fd > 9 && {
16737                        let max_fd =
16738                            crate::ported::utils::MAX_ZSH_FD.load(std::sync::atomic::Ordering::Relaxed);
16739                        let cin = crate::ported::modules::clone::coprocin
16740                            .load(std::sync::atomic::Ordering::Relaxed);
16741                        let cout = crate::ported::modules::clone::coprocout
16742                            .load(std::sync::atomic::Ordering::Relaxed);
16743                        src_fd <= max_fd && {
16744                            let kind = crate::ported::utils::fdtable_get(src_fd)
16745                                & crate::ported::zsh_h::FDT_TYPE_MASK;
16746                            (kind != crate::ported::zsh_h::FDT_UNUSED
16747                                && kind != crate::ported::zsh_h::FDT_EXTERNAL)
16748                                || src_fd == cin
16749                                || src_fd == cout
16750                        }
16751                    };
16752                    if unsafe { libc::fcntl(src_fd, libc::F_GETFD) } == -1 || shell_owned {
16753                        // c:Src/exec.c — zwarn with real lineno prefix.
16754                        crate::ported::utils::zwarn(&format!("{}: bad file descriptor", src_fd));
16755                        self.set_last_status(1);
16756                        self.redirect_failed = true;
16757                        return;
16758                    }
16759                }
16760            }
16761        }
16762        // c:Src/exec.c:3978-3986 — bare `exec` redirects (nullexec==1)
16763        // skip the save entirely: "we specifically *don't* restore the
16764        // original fd's". C's save[] is per-execcmd, so exec's redirs
16765        // never enter an enclosing group's save list either; pushing
16766        // into `redirect_scope_stack.last_mut()` here (the enclosing
16767        // group's scope) made `{ exec 1>&-; … } 2>/dev/null` restore
16768        // stdout at group end — diverging from zsh, which keeps fd 1
16769        // closed for the rest of the script.
16770        if !self.exec_redirs_permanent {
16771            self.save_fd_for_scope(fd);
16772            // For `&>` / `&>>` also save fd 2 so the scope restores it after
16773            // the body. Otherwise stderr stays redirected past the command.
16774            if matches!(op_byte, r::WRITE_BOTH | r::APPEND_BOTH) {
16775                self.save_fd_for_scope(2);
16776            }
16777        }
16778        // c:Src/exec.c:3722-3724 + 2447-2480 — MULTIOS split when this
16779        // command's stdout IS the pipeline output. C registers the pipe
16780        // in mfds[1] (`addfd(forked, save, mfds, 1, output, 1, NULL)`)
16781        // BEFORE walking the explicit redirect list, so a write-side
16782        // redirect of fd 1 finds mfds[1] occupied and, with MULTIOS
16783        // set, "split[s] the stream": fd 1 becomes the write end of an
16784        // internal pipe whose reader tees every chunk to BOTH the
16785        // pipeline pipe and the new target. That is why
16786        // `{ echo a; echo b >&2; } 3>&1 1>&2 2>&3 3>&- | cat` sends
16787        // `a` to the pipe (via the tee) AND to stderr — plain dup2
16788        // replacement loses the pipe stream. The scope-depth gate
16789        // mirrors mfds being per-execcmd: only the redirect list
16790        // attached to the stage's own command joins the pipe; nested
16791        // commands inside the body (`{ echo a > f; } | cat`) get a
16792        // fresh "mfds" and replace as usual.
16793        if fd == 1
16794            && self
16795                .pipe_output_scope
16796                .is_some_and(|d| d + 1 == self.redirect_scope_stack.len())
16797            && crate::ported::options::opt_state_get("multios").unwrap_or(true)
16798        {
16799            // Resolve the new write target exactly as the plain arms
16800            // below would, but as a raw fd for the tee.
16801            let new_target_fd: i32 = match op_byte {
16802                r::DUP_WRITE => {
16803                    // Numeric `>&N` only; `-` (close) and `p` (coproc)
16804                    // fall through to the plain arms.
16805                    target
16806                        .trim_start_matches('&')
16807                        .parse::<i32>()
16808                        .map(|src| unsafe { libc::fcntl(src, libc::F_DUPFD, 10) })
16809                        .unwrap_or(-1)
16810                }
16811                r::WRITE | r::CLOBBER => fs::File::create(target)
16812                    .map(|f| f.into_raw_fd())
16813                    .unwrap_or(-1),
16814                r::APPEND => fs::OpenOptions::new()
16815                    .create(true)
16816                    .append(true)
16817                    .open(target)
16818                    .map(|f| f.into_raw_fd())
16819                    .unwrap_or(-1),
16820                _ => -1,
16821            };
16822            if new_target_fd >= 0 {
16823                let pipe_dup = unsafe { libc::fcntl(1, libc::F_DUPFD, 10) };
16824                match (pipe_dup >= 0).then(os_pipe::pipe) {
16825                    Some(Ok((read_end, write_end))) => {
16826                        // c:Src/exec.c:5222 / Src/utils.c:1990-2012 —
16827                        // `mpipe()` runs both pipe ends through
16828                        // `movefd()`, which lifts any fd below 10 out
16829                        // of the user-visible `>&N` range. Same abort
16830                        // hazard as the BUILTIN_MULTIOS_REDIRECT arm.
16831                        let read_end = unsafe {
16832                            <os_pipe::PipeReader as std::os::unix::io::FromRawFd>::from_raw_fd(
16833                                crate::extensions::fds::movefd(read_end.into_raw_fd()),
16834                            )
16835                        };
16836                        // Splitter: same read-loop shape as
16837                        // BUILTIN_MULTIOS_REDIRECT, with one ordering
16838                        // refinement. C's tee is a forked process
16839                        // (closemn → teeproc) whose wakeup latency lets
16840                        // the stage's DIRECT pipe writes land first —
16841                        // observed zsh output for `{ echo a; echo b >&2; }
16842                        // 3>&1 1>&2 2>&3 3>&- | cat` is `b` then `a`,
16843                        // 15/15 runs. A Rust thread wakes faster than
16844                        // the debug-build VM dispatches the next echo,
16845                        // inverting the order. Emulate the C timing
16846                        // observably: stream to the NEW target (file /
16847                        // stderr dup) immediately, but defer the
16848                        // pipe-bound copy until EOF (or a 64KB cap so a
16849                        // long-running stream still flows instead of
16850                        // growing memory unboundedly).
16851                        let write_now = |tfd: i32, data: &[u8]| {
16852                            let mut off = 0;
16853                            while off < data.len() {
16854                                let w = unsafe {
16855                                    libc::write(
16856                                        tfd,
16857                                        data[off..].as_ptr() as *const libc::c_void,
16858                                        data.len() - off,
16859                                    )
16860                                };
16861                                if w <= 0 {
16862                                    break;
16863                                }
16864                                off += w as usize;
16865                            }
16866                        };
16867                        let handle = std::thread::spawn(move || {
16868                            let mut rd = read_end;
16869                            let mut buf = [0u8; 8192];
16870                            let mut pipe_pending: Vec<u8> = Vec::new();
16871                            loop {
16872                                match std::io::Read::read(&mut rd, &mut buf) {
16873                                    Ok(0) | Err(_) => break,
16874                                    Ok(n) => {
16875                                        write_now(new_target_fd, &buf[..n]);
16876                                        pipe_pending.extend_from_slice(&buf[..n]);
16877                                        if pipe_pending.len() >= 65536 {
16878                                            write_now(pipe_dup, &pipe_pending);
16879                                            pipe_pending.clear();
16880                                        }
16881                                    }
16882                                }
16883                            }
16884                            write_now(pipe_dup, &pipe_pending);
16885                            unsafe {
16886                                libc::close(pipe_dup);
16887                                libc::close(new_target_fd);
16888                            }
16889                        });
16890                        let write_raw = AsRawFd::as_raw_fd(&write_end);
16891                        unsafe { libc::dup2(write_raw, 1) };
16892                        drop(write_end);
16893                        // Scope-end closes this dup (the last writer once
16894                        // the saved fd 1 is restored) → EOF → join.
16895                        let close_on_end = unsafe { libc::fcntl(1, libc::F_DUPFD, 10) };
16896                        if let Some(top) = self.multios_scope_stack.last_mut() {
16897                            top.push((close_on_end, handle));
16898                        } else {
16899                            unsafe { libc::close(close_on_end) };
16900                            let _ = handle.join();
16901                        }
16902                        return;
16903                    }
16904                    _ => unsafe {
16905                        // pipe()/dup failure — fall through to plain replace.
16906                        if pipe_dup >= 0 {
16907                            libc::close(pipe_dup);
16908                        }
16909                        libc::close(new_target_fd);
16910                    },
16911                }
16912            }
16913        }
16914        match op_byte {
16915            r::WRITE => {
16916                // Honor `setopt noclobber`: refuse to overwrite an
16917                // existing regular file unless `>!` / `>|` (CLOBBER).
16918                // zsh internally stores the inverted-name `clobber`
16919                // (default ON); `setopt noclobber` writes
16920                // `clobber=false`. Honor both keys.
16921                //
16922                // c:Src/exec.c:2241-2245 clobber_open recover path:
16923                // after O_EXCL fails, reopen and `if (!S_ISREG(...))
16924                // return fd;` — non-regular targets (char/block-
16925                // special, FIFO, socket) bypass the noclobber check.
16926                // Bug #30 in docs/BUGS.md: this bridge-side check did
16927                // a bare `Path::exists()` and treated `/dev/null` as
16928                // a protected file, breaking `setopt no_clobber; echo
16929                // hi > /dev/null` and every `2> /dev/null` idiom.
16930                // Add a regular-file stat gate that matches the C
16931                // semantic. The canonical clobber_open at
16932                // src/ported/exec.rs:2123 already handles this; the
16933                // bridge duplicates a stripped-down version here and
16934                // must mirror the same check.
16935                let noclobber = opt_state_get("noclobber").unwrap_or(false)
16936                    || !opt_state_get("clobber").unwrap_or(true);
16937                let target_meta = std::fs::metadata(target).ok();
16938                let target_is_regular_file = target_meta
16939                    .as_ref()
16940                    .map(|m| m.file_type().is_file())
16941                    .unwrap_or(false);
16942                // c:Src/exec.c:2313 clobber_open — CLOBBER_EMPTY permits
16943                // re-using an EMPTY regular file under noclobber: `setopt
16944                // noclobber clobberempty; : >f; echo hi >f` overwrites f.
16945                // The inline bridge check ignored this and errored.
16946                let clobber_empty_ok = opt_state_get("clobberempty").unwrap_or(false)
16947                    && target_meta.as_ref().map(|m| m.len() == 0).unwrap_or(false);
16948                if noclobber && target_is_regular_file && !clobber_empty_ok {
16949                    eprintln!(
16950                        "{}:{}: file exists: {}",
16951                        shname(),
16952                        crate::ported::lex::lineno(),
16953                        target
16954                    );
16955                    self.set_last_status(1);
16956                    // c:Src/exec.c — set redirect_failed so the scope-end
16957                    // hook (`with_redirects_end` in this file) forces
16958                    // $? to 1 regardless of the still-running command's
16959                    // own exit. Without this the next command (e.g.
16960                    // `echo x` writing to /dev/null below) succeeds
16961                    // and overwrites the redirect-failure status,
16962                    // making noclobber unobservable from $?.
16963                    self.redirect_failed = true;
16964                    // Sink the upcoming command's stdout to /dev/null
16965                    // so we don't leak its output to the terminal.
16966                    // zsh skips the command entirely; we approximate by
16967                    // discarding the output (the redirect target was
16968                    // the user's chosen sink, but with noclobber the
16969                    // file is protected — discarding matches the
16970                    // user's intent better than printing to terminal).
16971                    if let Ok(file) = fs::OpenOptions::new().write(true).open("/dev/null") {
16972                        let new_fd = file.into_raw_fd();
16973                        unsafe {
16974                            libc::dup2(new_fd, fd);
16975                            libc::close(new_fd);
16976                        }
16977                    }
16978                    return;
16979                }
16980                if !Self::redir_open_or_fail(
16981                    fd,
16982                    fs::File::create(target),
16983                    target,
16984                    &mut self.redirect_failed,
16985                ) {
16986                    self.set_last_status(1);
16987                }
16988            }
16989            r::CLOBBER => {
16990                if !Self::redir_open_or_fail(
16991                    fd,
16992                    fs::File::create(target),
16993                    target,
16994                    &mut self.redirect_failed,
16995                ) {
16996                    self.set_last_status(1);
16997                }
16998            }
16999            r::APPEND => {
17000                // c:Src/exec.c:3924-3927 — `>>` honors NO_CLOBBER+!APPENDCREATE
17001                // by opening O_APPEND|O_WRONLY WITHOUT O_CREAT, so missing
17002                // files yield ENOENT. zsh source:
17003                //   if (!isset(CLOBBER) && !isset(APPENDCREATE) &&
17004                //       !IS_CLOBBER_REDIR(fn->type))
17005                //       mode = O_WRONLY|O_APPEND|O_NOCTTY;
17006                //   else mode = O_WRONLY|O_APPEND|O_CREAT|O_NOCTTY;
17007                // (IS_CLOBBER_REDIR — `>>!`/`>>|` — is currently flattened
17008                // to plain APPEND at compile time in
17009                // src/extensions/compile_zsh.rs:1654-1655, so the bang/pipe
17010                // forms can't be distinguished here yet.)
17011                let noclobber = opt_state_get("noclobber").unwrap_or(false)
17012                    || !opt_state_get("clobber").unwrap_or(true);
17013                let append_create = opt_state_get("appendcreate").unwrap_or(false)
17014                    || opt_state_get("append_create").unwrap_or(false);
17015                let open_result = if noclobber && !append_create {
17016                    fs::OpenOptions::new().append(true).open(target) // no create
17017                } else {
17018                    fs::OpenOptions::new()
17019                        .create(true)
17020                        .append(true)
17021                        .open(target)
17022                };
17023                if !Self::redir_open_or_fail(fd, open_result, target, &mut self.redirect_failed) {
17024                    self.set_last_status(1);
17025                }
17026            }
17027            r::READ => {
17028                if !Self::redir_open_or_fail(
17029                    fd,
17030                    fs::File::open(target),
17031                    target,
17032                    &mut self.redirect_failed,
17033                ) {
17034                    self.set_last_status(1);
17035                }
17036            }
17037            r::READ_WRITE => {
17038                if let Ok(file) = fs::OpenOptions::new()
17039                    .create(true)
17040                    .truncate(false) // <> opens existing-or-new without truncating
17041                    .read(true)
17042                    .write(true)
17043                    .open(target)
17044                {
17045                    let new_fd = file.into_raw_fd();
17046                    unsafe {
17047                        // See redir_open_or_fail: when the opened fd IS the
17048                        // destination (target fd was closed), keep it and clear
17049                        // O_CLOEXEC; else dup2 + close.
17050                        if new_fd != fd {
17051                            libc::dup2(new_fd, fd);
17052                            libc::close(new_fd);
17053                        } else {
17054                            libc::fcntl(fd, libc::F_SETFD, 0);
17055                        }
17056                    }
17057                }
17058            }
17059            r::DUP_READ | r::DUP_WRITE => {
17060                // Target is a numeric fd reference like `&3`. The parser
17061                // strips the `&` prefix before we get here in some paths,
17062                // others retain it — accept both. Also support `-` for
17063                // close-fd (`<&-` / `>&-`) per POSIX. The src_fd
17064                // validity check ran above before the save-and-dup.
17065                let n = target.trim_start_matches('&');
17066                if n == "-" {
17067                    unsafe { libc::close(fd) };
17068                } else if n == "p" {
17069                    // c:Src/exec.c — `<&p` / `>&p` route through the
17070                    // coprocin / coprocout globals. zsh's `coproc CMD`
17071                    // launch publishes those fds; the canonical
17072                    // bin_print / bin_read `-p` arms already consume
17073                    // them. The DUP redirect form is the third
17074                    // consumer: it must dup the coproc fd onto the
17075                    // target slot so the next command's stdin/stdout
17076                    // is wired to the running coprocess. Bug #388.
17077                    let coproc_fd = if op_byte == r::DUP_READ {
17078                        crate::ported::modules::clone::coprocin
17079                            .load(std::sync::atomic::Ordering::Relaxed)
17080                    } else {
17081                        crate::ported::modules::clone::coprocout
17082                            .load(std::sync::atomic::Ordering::Relaxed)
17083                    };
17084                    if coproc_fd < 0 {
17085                        eprintln!("{}:1: no coprocess", shname());
17086                        self.set_last_status(1);
17087                        self.redirect_failed = true;
17088                    } else {
17089                        unsafe {
17090                            libc::dup2(coproc_fd, fd);
17091                        }
17092                    }
17093                } else if let Ok(src_fd) = n.parse::<i32>() {
17094                    unsafe { libc::dup2(src_fd, fd) };
17095                } else if op_byte == r::DUP_WRITE {
17096                    // c:Src/glob.c:2184-2187 xpandredir — a MERGEOUT
17097                    // word that expands to a non-number becomes
17098                    // REDIR_ERRWRITE: `cmd >& word` opens `word` and
17099                    // routes BOTH fd 1 and fd 2 there. Reached only
17100                    // for dynamic words (`>&$var`); static filenames
17101                    // were converted at compile time.
17102                    if let Ok(file) = fs::File::create(target) {
17103                        let new_fd = file.into_raw_fd();
17104                        unsafe {
17105                            libc::dup2(new_fd, 1);
17106                            libc::dup2(new_fd, 2);
17107                            libc::close(new_fd);
17108                        }
17109                    }
17110                } else {
17111                    // c:Src/glob.c:2185 — MERGEIN non-number:
17112                    // `zerr("file number expected")`.
17113                    crate::ported::utils::zerr("file number expected");
17114                    self.set_last_status(1);
17115                    self.redirect_failed = true;
17116                }
17117            }
17118            r::WRITE_BOTH => {
17119                if let Ok(file) = fs::File::create(target) {
17120                    let new_fd = file.into_raw_fd();
17121                    unsafe {
17122                        libc::dup2(new_fd, 1);
17123                        libc::dup2(new_fd, 2);
17124                        libc::close(new_fd);
17125                    }
17126                }
17127            }
17128            r::APPEND_BOTH => {
17129                if let Ok(file) = fs::OpenOptions::new()
17130                    .create(true)
17131                    .append(true)
17132                    .open(target)
17133                {
17134                    let new_fd = file.into_raw_fd();
17135                    unsafe {
17136                        libc::dup2(new_fd, 1);
17137                        libc::dup2(new_fd, 2);
17138                        libc::close(new_fd);
17139                    }
17140                }
17141            }
17142            _ => {}
17143        }
17144    }
17145
17146    /// Push a fresh redirect scope. `_count` is informational — the actual
17147    /// saved fds are appended by host_apply_redirect into the top scope.
17148    pub fn host_redirect_scope_begin(&mut self, _count: u8) {
17149        // c:Src/exec.c:3722-3724 — the pipeline child set
17150        // `pipe_output_pending` right after dup2'ing its stdout onto
17151        // the pipe; the FIRST redirect scope opened in that child is
17152        // the stage command's own redirect list (same execcmd as the
17153        // pipe's addfd into mfds[1]). Capture the depth so only THAT
17154        // list's fd-1 write redirects MULTIOS-join the pipe.
17155        if self.pipe_output_pending {
17156            self.pipe_output_pending = false;
17157            self.pipe_output_scope = Some(self.redirect_scope_stack.len());
17158        }
17159        self.redirect_scope_stack.push(Vec::new());
17160        self.multios_scope_stack.push(Vec::new());
17161    }
17162
17163    /// Restore every redirect scope opened above `depth`.
17164    ///
17165    /// c:Src/exec.c:4364 — `fixfds(save)` runs on EVERY exit path out of
17166    /// `execcmd_exec`, the one an early `return` takes out of a compound
17167    /// command carrying redirections included (`while … done < f`,
17168    /// `{ …; return } < f`, `if …; then return; fi < f`). zshrs compiles
17169    /// `return` to a Jump past the body's `WithRedirectsEnd`, so that
17170    /// scope stayed on the stack and its saved fds never came back — the
17171    /// caller inherited the callee's redirected fd.
17172    ///
17173    /// gitstatus hit this: `gitstatus.plugin.zsh`'s daemon sources
17174    /// `gitstatus/install`, whose `_gitstatus_install_main` returns out of
17175    /// `while … done <"$gitstatus_dir"/install.info`. fd 0 stayed on
17176    /// install.info instead of reverting to the request FIFO, so
17177    /// `gitstatusd` read EOF on startup and exited ("EOF. Exiting."),
17178    /// `gitstatus_start` failed, `VCS_STATUS_REMOTE_URL` came back empty
17179    /// and powerlevel10k rendered the generic git icon in place of the
17180    /// per-forge one.
17181    pub fn unwind_redirect_scopes_to(&mut self, depth: usize) {
17182        while self.redirect_scope_stack.len() > depth {
17183            self.host_redirect_scope_end();
17184        }
17185    }
17186
17187    /// Pop the top redirect scope, restoring saved fds.
17188    pub fn host_redirect_scope_end(&mut self) {
17189        // c:Src/exec.c — restore saved fds FIRST so the multios
17190        // pipe-write end is released from `fd`, then close our
17191        // tracked close_on_end (the last surviving writer dup), then
17192        // join the splitter thread. If we closed close_on_end before
17193        // restoring saved, `fd` would still hold a pipe writer and
17194        // the thread would block forever waiting for EOF.
17195        if let Some(saved) = self.redirect_scope_stack.pop() {
17196            for (fd, saved_fd) in saved.into_iter().rev() {
17197                // c:Src/exec.c:4530 `redup(save[i], i)` →
17198                // c:Src/utils.c:2047-2048 `if (x < 0) zclose(y);`.
17199                // A -1 slot means the fd was CLOSED before the
17200                // redirection (c:2422-2423, c:2430-2436), so the
17201                // restore is a close, not a dup2 — otherwise the
17202                // redirection outlives the command it was written on
17203                // (`print x 3<file; cat <&3` printed the file instead
17204                // of "3: bad file descriptor").
17205                if saved_fd < 0 {
17206                    unsafe { libc::close(fd) };
17207                    continue;
17208                }
17209                unsafe {
17210                    libc::dup2(saved_fd, fd);
17211                    libc::close(saved_fd);
17212                }
17213            }
17214        }
17215        if let Some(scope) = self.multios_scope_stack.pop() {
17216            // Close ALL tracked writer dups BEFORE joining any
17217            // thread. When one splitter holds a dup of another's
17218            // pipe write-end (two multios in one scope where a later
17219            // one duped fd 1 while an earlier splitter owned it),
17220            // joining in push order deadlocks: splitter A's EOF
17221            // waits on splitter B's writer dup, which only closes
17222            // after B's thread exits — blocked behind A's join.
17223            let mut handles = Vec::with_capacity(scope.len());
17224            for (write_fd, handle) in scope {
17225                if write_fd >= 0 {
17226                    unsafe {
17227                        libc::close(write_fd);
17228                    }
17229                }
17230                handles.push(handle);
17231            }
17232            for handle in handles {
17233                let _ = handle.join();
17234            }
17235        }
17236        // The scope that captured the pipeline-output marker is gone;
17237        // deeper-nested future scopes must not re-match its depth.
17238        if self.pipe_output_scope == Some(self.redirect_scope_stack.len()) {
17239            self.pipe_output_scope = None;
17240        }
17241    }
17242
17243    /// Set up `content` as stdin (fd 0) for the next command.
17244    /// Used by `Op::HereDoc(idx)` and `Op::HereString`.
17245    ///
17246    /// c:Src/exec.c:4655 getherestr — C writes the body to a TEMP
17247    /// FILE (gettempfile → write_loop → close → reopen O_RDONLY →
17248    /// unlink), NOT a pipe. The previous pipe+writer-thread shape
17249    /// SIGPIPE'd the whole shell when the consumer never read the
17250    /// body (`: <<< ${(F)x/y}` — D04parameter chunk 211, flaky
17251    /// rc=141): the redirect-scope teardown closed the read end
17252    /// while the detached thread was still in write_all, and the
17253    /// shell's SIGPIPE disposition is SIG_DFL. A temp file has no
17254    /// reader/writer coupling — matching C exactly, including
17255    /// lseek-ability of fd 0, which pipes don't give.
17256    pub fn host_set_pending_stdin(&mut self, content: String) {
17257        // c:4673 — `gettempfile(NULL, 1, &s)`.
17258        let mut tmp = std::env::temp_dir();
17259        tmp.push(format!(
17260            "zshrs-herestr-{}-{:x}",
17261            std::process::id(),
17262            std::time::SystemTime::now()
17263                .duration_since(std::time::UNIX_EPOCH)
17264                .map(|d| d.as_nanos())
17265                .unwrap_or(0)
17266        ));
17267        // c:Src/exec.c:4719 — `unmetafy(t, &len);` runs BEFORE
17268        // `write_loop(fd, t, len)`: what lands in the temp file is the RAW
17269        // byte stream, never zshrs's metafied `Meta` + `byte ^ 32` pairs.
17270        // Writing `content.as_bytes()` leaked the metafication onto fd 0,
17271        // so `read -d $'\xa0' <<<$'first\xa0second'` saw `c2 83 c2 80`
17272        // where every other writer (`print`, a pipe) emits the single `a0`.
17273        let raw = crate::ported::utils::unmetafy_str(&content); // c:4719
17274                                                                // c:4675 — `write_loop(fd, t, len); close(fd);`
17275        if std::fs::write(&tmp, &raw).is_err() {
17276            return; // c:4674 — tempfile failure → no redirect
17277        }
17278        // c:Src/utils.c gettempfile → mkstemp creates the temp file mode
17279        // 0600 IGNORING the umask, so the O_RDONLY reopen below always
17280        // succeeds. `std::fs::write` honors the umask, so under `umask
17281        // 0777` the file landed mode 0000 and the reopen failed with
17282        // EACCES — `cat <<<x` then read empty stdin. Force 0600 to match
17283        // mkstemp's umask-independent permissions.
17284        let _ = std::fs::set_permissions(
17285            &tmp,
17286            <std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
17287        );
17288        // c:4677 — `fd = open(s, O_RDONLY | O_NOCTTY);`
17289        let file = match std::fs::File::open(&tmp) {
17290            Ok(f) => f,
17291            Err(_) => {
17292                let _ = std::fs::remove_file(&tmp);
17293                return;
17294            }
17295        };
17296        // c:4678 — `unlink(s);` — fd stays valid, name disappears.
17297        let _ = std::fs::remove_file(&tmp);
17298        let saved = unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_DUPFD, 10) };
17299        if saved >= 0 {
17300            if let Some(top) = self.redirect_scope_stack.last_mut() {
17301                top.push((libc::STDIN_FILENO, saved));
17302            } else {
17303                unsafe { libc::close(saved) };
17304            }
17305        }
17306        // c:Src/utils.c:redup — `if (x != y) { dup2(x, y); zclose(x); }`.
17307        // When fd 0 was already CLOSED before this heredoc runs,
17308        // `File::open` returns the lowest free descriptor, which is 0
17309        // itself — so `read_fd == STDIN_FILENO`. C's redup skips both the
17310        // dup2 (a no-op for equal fds) AND the close in that case, leaving
17311        // the just-opened temp file installed at fd 0. Unconditionally
17312        // dropping the File here closed that fd back to nothing, so an
17313        // external NULLCMD (`cat`) inherited a closed fd 0 and failed with
17314        // EBADF (`cat <<EOF` inside `$(...)` when exec 0<&- closed stdin).
17315        let read_fd = AsRawFd::as_raw_fd(&file);
17316        if read_fd != libc::STDIN_FILENO {
17317            // dup2 installs a fresh fd 0 with FD_CLOEXEC clear (dup2 never
17318            // copies the flag), then we close the CLOEXEC-tagged source.
17319            unsafe { libc::dup2(read_fd, libc::STDIN_FILENO) };
17320            drop(file); // c:redup zclose(x)
17321        } else {
17322            // File::open reused fd 0. Rust opens with O_CLOEXEC, so fd 0
17323            // now carries FD_CLOEXEC and would be auto-closed when an
17324            // external NULLCMD (`cat`) exec's — the child then reads a
17325            // closed fd 0 and fails with EBADF. zsh opens the heredoc temp
17326            // via `open(s, O_RDONLY|O_NOCTTY)` (no CLOEXEC), so its child
17327            // inherits the fd. Clear the flag to match, then keep fd 0 open
17328            // (redup's x==y arm: no dup2, no close).
17329            unsafe {
17330                let flags = libc::fcntl(libc::STDIN_FILENO, libc::F_GETFD);
17331                if flags >= 0 {
17332                    libc::fcntl(libc::STDIN_FILENO, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
17333                }
17334            }
17335            std::mem::forget(file);
17336        }
17337    }
17338
17339    /// Spawn an external command using zshrs's full dispatch logic
17340    /// (intercepts, command_hash, redirect handling). Used by
17341    /// `ZshrsHost::exec` so the bytecode VM's `Op::Exec` and
17342    /// `Op::CallFunction` external fallback get the same semantics as
17343    /// the tree-walker's `execute_external` rather than a plain
17344    /// `Command::new` shortcut. Returns the exit status.
17345    pub fn host_exec_external(&mut self, args: &[String]) -> i32 {
17346        // Native p10k API: the `p10k(){ zshrs-p10k-api "$@" }` stub's
17347        // body lands here (the name is neither function nor builtin).
17348        // Route into the engine instead of a PATH miss.
17349        if let Some(name) = args.first() {
17350            if let Some(status) = crate::p10k::maybe_intercept_command(name, &args[1..]) {
17351                self.set_last_status(status);
17352                return status;
17353            }
17354        }
17355        // If a glob expansion in this command's argv triggered the
17356        // nomatch error path, suppress the actual exec and return
17357        // status 1 — mirrors zsh's command-aborted-on-glob-error
17358        // behaviour. The flag is reset BEFORE returning so the next
17359        // command starts clean.
17360        //
17361        // c:Src/glob.c:1876-1880 + Src/exec.c — NOMATCH sets
17362        // ERRFLAG_ERROR but C's execlist clears the bit per-sublist
17363        // so subsequent commands run. Symmetric with the builtin
17364        // dispatcher's clear at fusevm_bridge.rs:299 — clear it here
17365        // too at the external-command post-command-boundary.
17366        consume_tilde_globsubst_carrier();
17367        if self.current_command_glob_failed.get() {
17368            self.current_command_glob_failed.set(false);
17369            crate::ported::utils::errflag.fetch_and(
17370                !crate::ported::zsh_h::ERRFLAG_ERROR,
17371                std::sync::atomic::Ordering::Relaxed,
17372            );
17373            self.set_last_status(1);
17374            return 1;
17375        }
17376        // c:Src/subst.c:505-507 — CSH_NULL_GLOB sibling of the
17377        // NOMATCH gate above, same external-path semantics (skip
17378        // command, `no match`, clear ERRFLAG so the next sublist
17379        // runs).
17380        if consume_badcshglob() {
17381            crate::ported::utils::errflag.fetch_and(
17382                !crate::ported::zsh_h::ERRFLAG_ERROR,
17383                std::sync::atomic::Ordering::Relaxed,
17384            );
17385            self.set_last_status(1);
17386            return 1;
17387        }
17388        let Some((cmd, rest)) = args.split_first() else {
17389            return 0;
17390        };
17391        // Empty command name (e.g. result of an empty `$(false)`
17392        // command-sub being the only word) — zsh: no command runs,
17393        // exit status preserved from prior step. Was hitting the
17394        // "command not found: " path with empty name.
17395        if cmd.is_empty() && rest.is_empty() {
17396            return self.last_status();
17397        }
17398        let rest_vec: Vec<String> = rest.to_vec();
17399        // Update `$_` with the just-arriving argv so the next command
17400        // reads `_=<last_arg>`. Mirrors C zsh's writeback in
17401        // `execcmd_exec` (Src/exec.c). Per `args.last()` semantics,
17402        // when invoked as `cmd a b c`, `$_` becomes "c" — for a bare
17403        // command with no args, `$_` becomes the command name itself.
17404        crate::ported::params::set_zunderscore(args);
17405
17406        // Builtins not in fusevm's name→id table fall through to
17407        // host.exec. Catch them here before the OS-level exec attempts
17408        // to spawn a non-existent binary.
17409        match cmd.as_str() {
17410            "sched" => return dispatch_builtin("sched", rest_vec.clone()),
17411            "echotc" => return dispatch_builtin("echotc", rest_vec.clone()),
17412            "echoti" => return dispatch_builtin("echoti", rest_vec.clone()),
17413            "zpty" => return dispatch_builtin("zpty", rest_vec.clone()),
17414            "ztcp" => return dispatch_builtin("ztcp", rest_vec.clone()),
17415            "zsocket" => {
17416                // c:Src/Modules/socket.c:276 BUILTIN spec — BUILTINS["zsocket"]
17417                // optstr "ad:ltv" parsed by execbuiltin.
17418                return dispatch_builtin("zsocket", rest_vec.clone());
17419            }
17420            "private" => {
17421                // c:Src/Modules/param_private.c:217 — bin_private via
17422                // BUILTINS["private"]. The autoload require_module
17423                // (exec.c:2700-2717) fires inside
17424                // dispatch_builtin_raw, the chokepoint for all routes.
17425                return dispatch_builtin("private", rest_vec.clone());
17426            }
17427            "zformat" => return dispatch_builtin("zformat", rest_vec.clone()),
17428            "zregexparse" => return dispatch_builtin("zregexparse", rest_vec.clone()),
17429            // `unalias`/`unhash`/`unfunction` share `bin_unhash` but
17430            // each carries its own funcid (BIN_UNALIAS / BIN_UNHASH /
17431            // BIN_UNFUNCTION) — dispatch_builtin handles the BUILTINS
17432            // lookup + funcid propagation via execbuiltin.
17433            "unalias" | "unhash" | "unfunction" => {
17434                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
17435            }
17436            // ACTUALLY A ZSH FUNCTION: zmv/zcp/zln/zcalc are zsh autoload
17437            // functions — implemented natively in Rust so `autoload -Uz zmv`
17438            // works without shipping the function source (and without the
17439            // fpath source hanging the parser). The `function_exists` guard
17440            // keeps them command-not-found until autoloaded, exactly like zsh;
17441            // an un-guarded arm ran them for bare `zmv`, diverging from
17442            // `zsh -f; zmv` → "command not found: zmv".
17443            "zmv" if self.function_exists("zmv") => {
17444                return crate::extensions::ext_builtins::zmv(&rest_vec, "mv")
17445            }
17446            "zcp" if self.function_exists("zcp") => {
17447                return crate::extensions::ext_builtins::zmv(&rest_vec, "cp")
17448            }
17449            "zln" if self.function_exists("zln") => {
17450                return crate::extensions::ext_builtins::zmv(&rest_vec, "ln")
17451            }
17452            "zcalc" if self.function_exists("zcalc") => {
17453                return crate::extensions::ext_builtins::zcalc(&rest_vec)
17454            }
17455            "zselect" => {
17456                // Route through canonical dispatch_builtin which goes
17457                // via execbuiltin → BUILTINS["zselect"] (zselect.c:272).
17458                return dispatch_builtin("zselect", rest_vec.clone());
17459            }
17460            "cap" => return dispatch_builtin("cap", rest_vec.clone()),
17461            "getcap" => return dispatch_builtin("getcap", rest_vec.clone()),
17462            "setcap" => return dispatch_builtin("setcap", rest_vec.clone()),
17463            "yes" => return self.builtin_yes(&rest_vec),
17464            "nl" => return self.builtin_nl(&rest_vec),
17465            "env" => return self.builtin_env(&rest_vec),
17466            "printenv" => return self.builtin_printenv(&rest_vec),
17467            "tty" => return self.builtin_tty(&rest_vec),
17468            // c:Src/Modules/files.c:806 — BUILTINS["chgrp"] with
17469            // BIN_CHGRP funcid + "hRs" optstr.
17470            "chgrp" => return dispatch_builtin("chgrp", rest_vec.clone()),
17471            "nproc" => return self.builtin_nproc(&rest_vec),
17472            "expr" => return self.builtin_expr(&rest_vec),
17473            "sha256sum" => return self.builtin_sha256sum(&rest_vec),
17474            "base64" => return self.builtin_base64(&rest_vec),
17475            "tac" => return self.builtin_tac(&rest_vec),
17476            "expand" => return self.builtin_expand(&rest_vec),
17477            "unexpand" => return self.builtin_unexpand(&rest_vec),
17478            "paste" => return self.builtin_paste(&rest_vec),
17479            "fold" => return self.builtin_fold(&rest_vec),
17480            "shuf" => return self.builtin_shuf(&rest_vec),
17481            "comm" => return self.builtin_comm(&rest_vec),
17482            "cksum" => return self.builtin_cksum(&rest_vec),
17483            "factor" => return self.builtin_factor(&rest_vec),
17484            "tsort" => return self.builtin_tsort(&rest_vec),
17485            "sum" => return self.builtin_sum(&rest_vec),
17486            "mkfifo" => return self.builtin_mkfifo(&rest_vec),
17487            "link" => return self.builtin_link(&rest_vec),
17488            "unlink" => return self.builtin_unlink(&rest_vec),
17489            "dircolors" => return self.builtin_dircolors(&rest_vec),
17490            "groups" => return self.builtin_groups(&rest_vec),
17491            "arch" => return self.builtin_arch(&rest_vec),
17492            "nice" => return self.builtin_nice(&rest_vec),
17493            "logname" => return self.builtin_logname(&rest_vec),
17494            "tput" => return self.builtin_tput(&rest_vec),
17495            "users" => return self.builtin_users(&rest_vec),
17496            // "sync" => return self.bin_sync(&rest_vec),
17497            "zbuild" => return self.builtin_zbuild(&rest_vec),
17498            // `zf_*` aliases from `zsh/files` (Src/Modules/files.c
17499            // BUILTIN table at line 816-824). The C source binds
17500            // both unprefixed (`chmod`) and prefixed (`zf_chmod`)
17501            // names to the SAME `bin_chmod` etc. handlers — the
17502            // prefixed forms exist so a script can portably reach
17503            // the builtin even when a function or alias has shadowed
17504            // the bare name. Each arm routes through the canonical
17505            // zf_* aliases route through canonical BUILTINS entries
17506            // (files.c:816-824) — execbuiltin parses each fn's optstr
17507            // automatically.
17508            "mkdir" | "zf_mkdir" | "zf_rm" | "zf_rmdir" | "zf_chmod" | "zf_chown" | "zf_chgrp"
17509            | "zf_ln" | "zf_mv" | "zf_sync"
17510                // `--zsh` parity gate: zsh -fc has zsh/files UNLOADED
17511                // — bare `mkdir` is /bin/mkdir (so `command mkdir -p`
17512                // honors the system flag set; zconvey.plugin.zsh:44
17513                // got "File exists" from the in-process bin_mkdir
17514                // that this arm intercepted) and `zf_*` names are
17515                // command-not-found 127 until `zmodload zsh/files`.
17516                // Fall through to the external/exec path in --zsh
17517                // mode; default zshrs mode keeps the anti-fork
17518                // intercept.
17519                if !crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed) =>
17520            {
17521                return dispatch_builtin(cmd.as_str(), rest_vec.clone());
17522            }
17523            // `zstat` — port of zsh/stat module (Src/Modules/stat.c
17524            // BUILTIN("zstat", …)). Returns file metadata as
17525            // `field value` pairs / an assoc / a plus-separated
17526            // list depending on flags. zsh ALSO registers `stat`
17527            // bound to the same handler, but that name conflicts
17528            // with the system `stat(1)` binary (every script that
17529            // calls `stat -f '%Lp' …` would break). zsh resolves
17530            // this through opt-in `zmodload`; zshrs's modules are
17531            // statically linked so we keep `stat` routing to the
17532            // external command and only intercept the unambiguous
17533            // `zstat` name.
17534            "zstat" => {
17535                // Canonical bin_stat per stat.c:638 via BUILTINS["zstat"].
17536                return dispatch_builtin("zstat", rest_vec.clone());
17537            }
17538            _ => {}
17539        }
17540
17541        // AOP intercepts: when an `intercept :before/:around/:after foo` block
17542        // is registered, dynamic-command-name dispatch must consult it before
17543        // spawning. Without this, `cmd=ls; $cmd` bypasses every intercept that
17544        // a literal `ls` would trigger. The full_cmd string mirrors what the
17545        // tree-walker era passed (cmd + args joined by space) so existing
17546        // pattern matchers continue to work.
17547        if !self.intercepts.is_empty() {
17548            let full_cmd = if rest_vec.is_empty() {
17549                cmd.clone()
17550            } else {
17551                format!("{} {}", cmd, rest_vec.join(" "))
17552            };
17553            if let Some(intercept_result) = self.run_intercepts(cmd, &full_cmd, &rest_vec) {
17554                return intercept_result.unwrap_or(127);
17555            }
17556        }
17557
17558        // User-defined function lookup before OS-level exec. zsh's
17559        // dynamic-command-name dispatch (`cmd=hook1; $cmd`) checks
17560        // the function table FIRST — without this, `$f` for a
17561        // function-name `f` was always falling through to
17562        // `execute_external` and erroring "command not found".
17563        // Plugin code uses this pattern constantly:
17564        //   for f in "${precmd_functions[@]}"; do "$f"; done
17565        if self.function_exists(cmd) {
17566            if let Some(status) = self.dispatch_function_call(cmd, &rest_vec) {
17567                return status;
17568            }
17569        }
17570
17571        self.execute_external(cmd, &rest_vec, &[]).unwrap_or(127)
17572    }
17573}
17574
17575#[cfg(test)]
17576mod word_assemble_tests {
17577    use super::{word_assemble_plan9, Value};
17578
17579    fn arr(xs: &[&str]) -> Value {
17580        Value::array(xs.iter().map(|s| Value::str(*s)).collect())
17581    }
17582    fn out(v: Value) -> Vec<String> {
17583        match v {
17584            Value::Array(items) => items.iter().map(|i| i.to_str()).collect(),
17585            other => vec![other.to_str()],
17586        }
17587    }
17588
17589    // The edge-tracking fold (c:Src/subst.c:4316-4437). A naive per-segment
17590    // operator gets s,p,p and p,s,p wrong because it forgets which trailing
17591    // elements are still the "growing edge". These pin the exact zsh output
17592    // (verified against zsh 5.9) for every plan9/splice permutation.
17593    #[test]
17594    fn plan9_then_splice() {
17595        // "${(@)^a}${(@)b}" a=(1 2) b=(A B) -> 1A 2A B
17596        let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[true, false]);
17597        assert_eq!(out(r), vec!["1A", "2A", "B"]);
17598    }
17599    #[test]
17600    fn splice_then_plan9() {
17601        // "${(@)a}${(@)^b}" -> 1 2A 2B
17602        let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[false, true]);
17603        assert_eq!(out(r), vec!["1", "2A", "2B"]);
17604    }
17605    #[test]
17606    fn plan9_then_plan9_is_full_cross() {
17607        let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[true, true]);
17608        assert_eq!(out(r), vec!["1A", "1B", "2A", "2B"]);
17609    }
17610    #[test]
17611    fn splice_then_splice() {
17612        let r = word_assemble_plan9(&[arr(&["1", "2"]), arr(&["A", "B"])], &[false, false]);
17613        assert_eq!(out(r), vec!["1", "2A", "B"]);
17614    }
17615    #[test]
17616    fn plan9_splice_plan9_growing_edge() {
17617        // "${(@)^a}${(@)b}${(@)^c}" -> 1A 2A Bp Bq  (only B, the edge, distributes)
17618        let r = word_assemble_plan9(
17619            &[arr(&["1", "2"]), arr(&["A", "B"]), arr(&["p", "q"])],
17620            &[true, false, true],
17621        );
17622        assert_eq!(out(r), vec!["1A", "2A", "Bp", "Bq"]);
17623    }
17624    #[test]
17625    fn splice_plan9_plan9_keeps_frozen_prefix() {
17626        // "${(@)a}${(@)^b}${(@)^c}" -> 1 2Ap 2Aq 2Bp 2Bq  (1 stays frozen)
17627        let r = word_assemble_plan9(
17628            &[arr(&["1", "2"]), arr(&["A", "B"]), arr(&["p", "q"])],
17629            &[false, true, true],
17630        );
17631        assert_eq!(out(r), vec!["1", "2Ap", "2Aq", "2Bp", "2Bq"]);
17632    }
17633    #[test]
17634    fn empty_plan9_array_deletes_word() {
17635        // "${(@)^a}${(@)b}" with a=() -> word deleted
17636        let r = word_assemble_plan9(&[Value::array(vec![]), arr(&["A", "B"])], &[true, false]);
17637        assert!(out(r).is_empty(), "plan9 empty array deletes the word");
17638    }
17639    #[test]
17640    fn leading_literal_then_mixed() {
17641        // "X${(@)^a}${(@)b}" -> X1A X2A B
17642        let r = word_assemble_plan9(
17643            &[Value::str("X"), arr(&["1", "2"]), arr(&["A", "B"])],
17644            &[false, true, false],
17645        );
17646        assert_eq!(out(r), vec!["X1A", "X2A", "B"]);
17647    }
17648
17649    // c:Src/subst.c:4261 — a NON-plan9 empty expansion collapses to the empty
17650    // string and the word SURVIVES; only plan9 (c:4362 `uremnode`) deletes it.
17651    // A leading empty segment used to leave `words` empty, so every following
17652    // segment cross-multiplied against nothing and the word vanished.
17653    // Verified against zsh 5.9:
17654    //     n=""; a=(x y z); print -rl -- $n${^a}      -> x / y / z
17655    #[test]
17656    fn leading_empty_splice_keeps_word_and_crosses() {
17657        let r = word_assemble_plan9(
17658            &[Value::array(vec![]), arr(&["x", "y", "z"])],
17659            &[false, true],
17660        );
17661        assert_eq!(out(r), vec!["x", "y", "z"]);
17662    }
17663
17664    //     n=""; a=(x y z); print -rl -- $n"pre"${^a} -> prex / prey / prez
17665    #[test]
17666    fn leading_empty_then_literal_then_plan9() {
17667        let r = word_assemble_plan9(
17668            &[
17669                Value::array(vec![]),
17670                Value::str("pre"),
17671                arr(&["x", "y", "z"]),
17672            ],
17673            &[false, false, true],
17674        );
17675        assert_eq!(out(r), vec!["prex", "prey", "prez"]);
17676    }
17677
17678    //     n=""; a=(x y z); print -rl -- $n$a${^a} -> x / y / zx / zy / zz
17679    // The leading empty must not consume the splice's first element.
17680    #[test]
17681    fn leading_empty_does_not_eat_first_splice_element() {
17682        let r = word_assemble_plan9(
17683            &[
17684                Value::array(vec![]),
17685                arr(&["x", "y", "z"]),
17686                arr(&["x", "y", "z"]),
17687            ],
17688            &[false, false, true],
17689        );
17690        assert_eq!(out(r), vec!["x", "y", "zx", "zy", "zz"]);
17691    }
17692
17693    //     n=""; e=(); print -rl -- $n${^e} -> nothing (plan9 empty still wins)
17694    #[test]
17695    fn leading_empty_then_empty_plan9_still_deletes_word() {
17696        let r = word_assemble_plan9(
17697            &[Value::array(vec![]), Value::array(vec![])],
17698            &[false, true],
17699        );
17700        assert!(
17701            out(r).is_empty(),
17702            "plan9 empty array still deletes the word"
17703        );
17704    }
17705}